261 lines
8.9 KiB
Rust
261 lines
8.9 KiB
Rust
// Copyright (C) 2024-2026 Whiterun LLC
|
||
//
|
||
// This software is licensed under the GNU Affero General Public License (AGPL), version 3.0 or later.
|
||
// A copy of the license can be found in the LICENSE file or at https://www.gnu.org/licenses/agpl-3.0.html
|
||
|
||
use crate::db::oracle::{get_closest, get_range, get_range_with_step};
|
||
use crate::db::DB;
|
||
use crate::rpc::err::{bad_request, db_error, not_found, ApiErrorCode, CachedApiResult};
|
||
use crate::rpc::response::{cached_ok, CACHE_AGGREGATE};
|
||
use crate::timeutil::time_now;
|
||
use bitcoin_hashes::hex::FromHex;
|
||
use bitcoincash::TokenID;
|
||
use rocket::{get, State};
|
||
use serde_json::{json, Value};
|
||
|
||
/// Get the closest oracle price for a given token and timestamp.
|
||
///
|
||
/// Status: Stable
|
||
///
|
||
/// - token_id: The 32 byte token ID of the oracle contract. Known IDs:
|
||
/// - **BCH/USD v2 (current, live)**: `be0d0d8324e8cda41d34b85bd203ce2482256eb337a0ad0fea82c2ddd7306c88`
|
||
/// - BCH/USD v1 (legacy, no longer updated): `d0d46f5cbd82188acede0d3e49c75700c19cb8331a30101f0bb6a260066ac972`
|
||
/// - timestamp: Unix timestamp (optional, defaults to now)
|
||
///
|
||
/// When `token_id` is omitted, the closest entry across **all** indexed
|
||
/// Delphi contracts is returned. After the v2 deploy this naturally resolves
|
||
/// to the most recent live oracle (v2), since v1 stopped advancing; callers
|
||
/// who want a specific contract should pass `token_id=…` explicitly.
|
||
///
|
||
/// Reserve UTXOs and `price=0` updates (the operator's "feed disabled"
|
||
/// signal) are filtered at index time, so any non-null entry returned here
|
||
/// is a live price.
|
||
///
|
||
/// Returns `null` if no oracle data is found.
|
||
///
|
||
/// **Important:** `oracle_price` is returned in **cents** (not dollars). Divide by 100
|
||
/// to get the price in USD:
|
||
/// ```
|
||
/// price_usd = oracle_price / 100
|
||
/// ```
|
||
///
|
||
/// **Response Example:**
|
||
/// ```json
|
||
/// {
|
||
/// "oracle_timestamp": 1777889180,
|
||
/// "oracle_price": 43972,
|
||
/// "oracle_sequence": 1646804,
|
||
/// "token_id": "be0d0d8324e8cda41d34b85bd203ce2482256eb337a0ad0fea82c2ddd7306c88",
|
||
/// "txid": "...",
|
||
/// "blockhash": "..."
|
||
/// }
|
||
/// ```
|
||
/// In this example, `oracle_price` of 43972 cents = $439.72 USD.
|
||
#[get("/delphi/closest?<token_id>&<timestamp>")]
|
||
pub async fn oracle_get_closest(
|
||
token_id: Option<String>,
|
||
timestamp: Option<i64>,
|
||
db: &State<DB>,
|
||
) -> CachedApiResult<serde_json::Value> {
|
||
let current_timestamp = timestamp.unwrap_or_else(time_now);
|
||
let token_id = token_id
|
||
.map(|t| {
|
||
TokenID::from_hex(&t).map_err(|e| {
|
||
bad_request(
|
||
ApiErrorCode::InvalidTokenId,
|
||
&format!("Invalid token ID: {e}"),
|
||
)
|
||
})
|
||
})
|
||
.transpose()?;
|
||
let entry = get_closest(&db.oracle_r, &token_id, current_timestamp)
|
||
.await
|
||
.map_err(db_error)?;
|
||
Ok(cached_ok(
|
||
entry.map_or(serde_json::Value::Null, |e| {
|
||
serde_json::to_value(e).unwrap()
|
||
}),
|
||
CACHE_AGGREGATE,
|
||
))
|
||
}
|
||
|
||
/// Status: Deprecated. Use `/delphi/<token>/history` instead.
|
||
#[get("/delphi/range?<token_id>&<start>&<end>")]
|
||
pub async fn oracle_get_range(
|
||
token_id: Option<String>,
|
||
start: Option<i64>,
|
||
end: Option<i64>,
|
||
db: &State<DB>,
|
||
) -> CachedApiResult<Vec<serde_json::Value>> {
|
||
let end_timestamp = end.unwrap_or_else(time_now);
|
||
let start_timestamp = start.unwrap_or_else(|| end_timestamp - 86400);
|
||
let token_id = token_id
|
||
.map(|t| {
|
||
TokenID::from_hex(&t).map_err(|e| {
|
||
bad_request(
|
||
ApiErrorCode::InvalidTokenId,
|
||
&format!("Invalid token ID: {e}"),
|
||
)
|
||
})
|
||
})
|
||
.transpose()?;
|
||
let entries = get_range(&db.oracle_r, &token_id, start_timestamp, end_timestamp)
|
||
.await
|
||
.map_err(db_error)?;
|
||
Ok(cached_ok(
|
||
entries
|
||
.into_iter()
|
||
.map(|e| serde_json::to_value(e).unwrap())
|
||
.collect(),
|
||
CACHE_AGGREGATE,
|
||
))
|
||
}
|
||
|
||
/// Get historical oracle prices for a given token.
|
||
///
|
||
/// - token: The 32 byte token ID of the oracle contract. Known IDs:
|
||
/// - **BCH/USD v2 (current, live)**: `be0d0d8324e8cda41d34b85bd203ce2482256eb337a0ad0fea82c2ddd7306c88`
|
||
/// - BCH/USD v1 (legacy, no longer updated): `d0d46f5cbd82188acede0d3e49c75700c19cb8331a30101f0bb6a260066ac972`
|
||
/// - start: Unix timestamp for start of period
|
||
/// - end: Unix timestamp for end of period (optional, defaults to now)
|
||
/// - stepsize: Seconds per interval (optional)
|
||
///
|
||
/// Reserve UTXOs and `price=0` updates (the operator's "feed disabled"
|
||
/// signal) are filtered at index time, so every entry returned here is a
|
||
/// live price.
|
||
///
|
||
/// **Important:** `oracle_price` values are in **cents**. Divide by 100 to convert to USD.
|
||
///
|
||
/// **Response Example:**
|
||
/// ```json
|
||
/// [
|
||
/// { "time": 1777886180, "price": 43900, "txid": "...", "blockhash": "...", "sequence": 1646803 },
|
||
/// { "time": 1777889180, "price": 43972, "txid": "...", "blockhash": "...", "sequence": 1646804 }
|
||
/// ]
|
||
/// ```
|
||
/// In this example, `price` values of 43972 cents = $439.72 USD.
|
||
#[get("/delphi/<token>/history?<start>&<end>&<stepsize>")]
|
||
pub async fn oracle_get_history(
|
||
token: &str,
|
||
start: Option<i64>,
|
||
end: Option<i64>,
|
||
stepsize: Option<i64>,
|
||
db: &State<DB>,
|
||
) -> CachedApiResult<Value> {
|
||
let current_timestamp = time_now();
|
||
|
||
let token_id = token.parse::<TokenID>().map_err(|e| {
|
||
bad_request(
|
||
ApiErrorCode::InvalidTokenId,
|
||
&format!("Invalid token ID: {e}"),
|
||
)
|
||
})?;
|
||
|
||
let start_ts = start.unwrap_or(current_timestamp - 30 * 24 * 3600);
|
||
let end_ts = end.unwrap_or(current_timestamp);
|
||
|
||
let entries = if let Some(step) = stepsize {
|
||
get_range_with_step(&db.oracle_r, &Some(token_id), start_ts, end_ts, step)
|
||
.await
|
||
.map_err(|e| {
|
||
bad_request(
|
||
ApiErrorCode::InvalidParameters,
|
||
&format!("Query error: {e}"),
|
||
)
|
||
})?
|
||
} else {
|
||
get_range(&db.oracle_r, &Some(token_id), start_ts, end_ts)
|
||
.await
|
||
.map_err(|e| {
|
||
bad_request(
|
||
ApiErrorCode::InvalidParameters,
|
||
&format!("Query error: {e}"),
|
||
)
|
||
})?
|
||
};
|
||
|
||
let json_entries: Vec<Value> = entries
|
||
.into_iter()
|
||
.map(|entry| {
|
||
json!({
|
||
"time": entry.oracle_timestamp,
|
||
"price": entry.oracle_price,
|
||
"txid": entry.txid,
|
||
"blockhash": entry.blockhash,
|
||
"sequence": entry.oracle_sequence,
|
||
})
|
||
})
|
||
.collect();
|
||
|
||
Ok(cached_ok(
|
||
serde_json::Value::Array(json_entries),
|
||
CACHE_AGGREGATE,
|
||
))
|
||
}
|
||
|
||
/// Delphi oracle updates roughly every 5–15 minutes. An entry more than 2 hours
|
||
/// older than the requested timestamp means there is genuinely no price for that
|
||
/// period (e.g. the Apr 23–May 4 2026 gap between v1 and v2).
|
||
const MAX_ORACLE_STALENESS_SECS: i64 = 7200;
|
||
|
||
/// Get the closest BCH/USD price for a given timestamp.
|
||
/// Returns 404 if no oracle price is available within 2 hours of the timestamp.
|
||
#[get("/cash/closest?<timestamp>")]
|
||
pub async fn oracle_cash_closest(
|
||
timestamp: Option<i64>,
|
||
db: &State<DB>,
|
||
) -> CachedApiResult<serde_json::Value> {
|
||
let ts = timestamp.unwrap_or_else(time_now);
|
||
let entry = get_closest(&db.oracle_r, &None, ts)
|
||
.await
|
||
.map_err(db_error)?;
|
||
|
||
match entry {
|
||
Some(e) if ts - e.oracle_timestamp <= MAX_ORACLE_STALENESS_SECS => {
|
||
Ok(cached_ok(serde_json::to_value(e).unwrap(), CACHE_AGGREGATE))
|
||
}
|
||
_ => Err(not_found(
|
||
ApiErrorCode::PriceNotFound,
|
||
&format!("No oracle price available for timestamp {ts}"),
|
||
)),
|
||
}
|
||
}
|
||
|
||
/// Get historical BCH/USD prices for a time range.
|
||
/// Backed by Delphi v2 (and v1 for historical timestamps).
|
||
#[get("/cash/history?<start>&<end>&<stepsize>")]
|
||
pub async fn oracle_cash_history(
|
||
start: Option<i64>,
|
||
end: Option<i64>,
|
||
stepsize: Option<i64>,
|
||
db: &State<DB>,
|
||
) -> CachedApiResult<Value> {
|
||
let current_timestamp = time_now();
|
||
let start_ts = start.unwrap_or(current_timestamp - 30 * 24 * 3600);
|
||
let end_ts = end.unwrap_or(current_timestamp);
|
||
|
||
let entries = if let Some(step) = stepsize {
|
||
get_range_with_step(&db.oracle_r, &None, start_ts, end_ts, step)
|
||
.await
|
||
.map_err(|e| {
|
||
bad_request(
|
||
ApiErrorCode::InvalidParameters,
|
||
&format!("Query error: {e}"),
|
||
)
|
||
})?
|
||
} else {
|
||
get_range(&db.oracle_r, &None, start_ts, end_ts)
|
||
.await
|
||
.map_err(|e| {
|
||
bad_request(
|
||
ApiErrorCode::InvalidParameters,
|
||
&format!("Query error: {e}"),
|
||
)
|
||
})?
|
||
};
|
||
|
||
Ok(cached_ok(
|
||
serde_json::to_value(entries).unwrap(),
|
||
CACHE_AGGREGATE,
|
||
))
|
||
}
|