riftenlabs-indexer/src/rpc/oracle.rs

261 lines
8.9 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// 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,
))
}