// 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?&")] pub async fn oracle_get_closest( token_id: Option, timestamp: Option, db: &State, ) -> CachedApiResult { 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//history` instead. #[get("/delphi/range?&&")] pub async fn oracle_get_range( token_id: Option, start: Option, end: Option, db: &State, ) -> CachedApiResult> { 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//history?&&")] pub async fn oracle_get_history( token: &str, start: Option, end: Option, stepsize: Option, db: &State, ) -> CachedApiResult { let current_timestamp = time_now(); let token_id = token.parse::().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 = 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?")] pub async fn oracle_cash_closest( timestamp: Option, db: &State, ) -> CachedApiResult { 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?&&")] pub async fn oracle_cash_history( start: Option, end: Option, stepsize: Option, db: &State, ) -> CachedApiResult { 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, )) }