Merge branch 'useDelphiForIndexer' into 'master'

Remove oracle_cash quickfix introduced Apr 24 2026

See merge request riftenlabs/riftenlabs-indexer!87
This commit is contained in:
jakobsn 2026-06-09 13:15:21 +00:00
commit 6ca7a194a2
9 changed files with 55 additions and 471 deletions

View file

@ -51,7 +51,7 @@ pub async fn update_tvl_and_price_now(
let sats_per_bch = Decimal::from_i64(SATS_PER_BCH).unwrap();
// USD per sat (now)
let usd_per_sat_now = usd_per_bch_at_or_before(oracle_pool, time_now()).await / sats_per_bch;
let usd_per_sat_now = usd_per_bch_at_or_before(oracle_pool, time_now()).await? / sats_per_bch;
let mut batch: Vec<(&str, i64, i64, f64, f64)> = Vec::with_capacity(WRITE_CHUNK);
for (token_id, (tvl_sats, tvl_tokens)) in tvl_by_token.iter() {
@ -349,9 +349,9 @@ pub async fn update_changes_score_volume_and_ranking(db: &DB) -> anyhow::Result<
let sats_per_bch = Decimal::from_i64(SATS_PER_BCH).unwrap();
// oracle (USD/BCH) → USD per sat
let usd_per_sat_now = usd_per_bch_at_or_before(&db.oracle_r, now).await / sats_per_bch;
let usd_per_sat_24h = usd_per_bch_at_or_before(&db.oracle_r, ts_24h).await / sats_per_bch;
let usd_per_sat_7d = usd_per_bch_at_or_before(&db.oracle_r, ts_7d).await / sats_per_bch;
let usd_per_sat_now = usd_per_bch_at_or_before(&db.oracle_r, now).await? / sats_per_bch;
let usd_per_sat_24h = usd_per_bch_at_or_before(&db.oracle_r, ts_24h).await? / sats_per_bch;
let usd_per_sat_7d = usd_per_bch_at_or_before(&db.oracle_r, ts_7d).await? / sats_per_bch;
// ========== READ PHASE: collect all token data ==========
let token_rows = sqlx::query(

View file

@ -29,7 +29,9 @@ mod tests {
use crate::db::cauldron::tx::{insert_block_tx, insert_mempool_tx};
use crate::db::cauldron::utxo_funding::insert_utxo_funding;
use crate::db::crc20::prepare_tables as crc20_prepare_tables;
use crate::db::oracle::prepare_tables as oracle_prepare_tables;
use crate::db::oracle::{
insert_delphi_entry, prepare_tables as oracle_prepare_tables, DelphiEntry,
};
use crate::utiltest::mock_db_pool;
use bitcoin_hashes::hex::ToHex;
@ -51,6 +53,19 @@ mod tests {
oracle_prepare_tables(&pool).await;
let _ = create_cached_token_metrics_table(&pool).await;
dummy_init_seq();
insert_delphi_entry(
&pool,
&DelphiEntry {
txid: Txid::hash(b"test_oracle_tx").to_hex(),
blockhash: BlockHash::hash(b"test_oracle_block").to_hex(),
token_id: BlockHash::hash(b"test_oracle_token").to_hex(),
oracle_timestamp: 0,
oracle_price: 50000,
oracle_sequence: 1,
},
)
.await
.unwrap();
}
// Insert one token with (initial) funding at t0 and a second funding at t1, plus pool history rows.

View file

@ -11,7 +11,6 @@ use sqlx::SqlitePool;
use crate::db::blob::display_hex_to_blob;
use crate::db::cauldron::pool::{get_injections_between, get_pool_period_snapshot};
use crate::db::oracle::get_closest;
use crate::db::oracle::oracle_cash::get_oracle_cash_closest;
use crate::rpc::apy::apyaggregator::APYAggregator;
use crate::rpc::apy::poolperiod::split_at_injections;
use malachite::base::num::arithmetic::traits::FloorSqrt;
@ -55,32 +54,20 @@ pub fn dec_to_f64_bounded(d: Decimal) -> f64 {
}
pub const SATS_PER_BCH: i64 = 100_000_000;
/// On-chain Delphi oracle scale: prices are stored in cents
/// (e.g. $384.24 → 38424). Same unit as the oracles.cash feed.
/// Oracle prices are stored in cents (38424 = $384.24). Divide by 100 to get USD.
pub const ORACLE_SCALE: i64 = 100;
/// Timestamps before this value are looked up in the on-chain Delphi indexed table.
/// Timestamps on or after are looked up in the oracle_cash table (oracles.cash feed).
/// Value: 2026-04-23 00:00:00 UTC (last reliable Delphi oracle update).
const ORACLE_CUTOFF_TS: i64 = 1776902400;
pub async fn usd_per_bch_at_or_before(oracle_pool: &SqlitePool, ts: i64) -> Decimal {
if ts < ORACLE_CUTOFF_TS {
match get_closest(oracle_pool, &None, ts).await {
Ok(Some(e)) => {
Decimal::from_i64(e.oracle_price).unwrap_or(Decimal::ZERO)
/ Decimal::from_i64(ORACLE_SCALE).unwrap()
}
_ => Decimal::ZERO,
}
} else {
// oracles.cash prices are in cents; divide by 100 to get USD/BCH
match get_oracle_cash_closest(oracle_pool, ts).await {
Ok(Some(e)) => {
Decimal::from_i64(e.oracle_price).unwrap_or(Decimal::ZERO) / Decimal::from(100)
}
_ => Decimal::ZERO,
}
}
pub async fn usd_per_bch_at_or_before(
oracle_pool: &SqlitePool,
ts: i64,
) -> anyhow::Result<Decimal> {
let entry = get_closest(oracle_pool, &None, ts)
.await?
.ok_or_else(|| anyhow::anyhow!("No oracle price found for timestamp {ts}"))?;
let price = Decimal::from_i64(entry.oracle_price).ok_or_else(|| {
anyhow::anyhow!("Oracle price out of Decimal range: {}", entry.oracle_price)
})?;
Ok(price / Decimal::from_i64(ORACLE_SCALE).unwrap())
}
#[inline]

View file

@ -16,7 +16,6 @@ use crate::db::cauldron::config::check_db_version;
use crate::db::cauldron::prepare_tables as cauldron_prepare_tables;
use crate::db::crc20::prepare_tables as crc20_prepare_tables;
use crate::db::moria::prepare_tables as moria_prepare_tables;
use crate::db::oracle::oracle_cash::prepare_oracle_cash_tables;
use crate::db::oracle::prepare_tables as oracle_prepare_tables;
/// Create read and write database pools for a given database path
@ -125,8 +124,6 @@ pub async fn initialize_databases(network: &str, read_slots: ReadSlots) -> Resul
if !db_exists {
oracle_prepare_tables(&oracle_db_write).await;
}
// Always-run migration: safe on both new and existing oracle.db
prepare_oracle_cash_tables(&oracle_db_write).await;
// Initialize moria lending database
let (db_exists, moria_db_write, moria_db_read) =

View file

@ -3,7 +3,6 @@
// 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
pub mod oracle_cash;
pub mod v2;
use anyhow::{bail, Result};

View file

@ -1,160 +0,0 @@
// 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 anyhow::{bail, Result};
use sqlx::{Row, SqlitePool};
#[derive(Debug, Clone, serde::Serialize)]
pub struct OracleCashPrice {
pub oracle_timestamp: i64,
/// Price in cents (same convention as the on-chain Delphi oracle)
pub oracle_price: i64,
pub message_sequence: Option<i64>,
}
/// Creates the oracle_cash_price table if it doesn't already exist.
/// Safe to call on both new and existing oracle.db instances.
pub async fn prepare_oracle_cash_tables(pool: &SqlitePool) {
sqlx::query(
"CREATE TABLE IF NOT EXISTS oracle_cash_price (
oracle_timestamp INTEGER NOT NULL PRIMARY KEY,
oracle_price INTEGER NOT NULL,
message_sequence INTEGER
)",
)
.execute(pool)
.await
.expect("failed to create oracle_cash_price table");
sqlx::query(
"CREATE INDEX IF NOT EXISTS idx_oracle_cash_seq
ON oracle_cash_price(message_sequence)
WHERE message_sequence IS NOT NULL",
)
.execute(pool)
.await
.expect("failed to create oracle_cash_seq index");
}
pub async fn upsert_oracle_cash_price(
pool: &SqlitePool,
oracle_timestamp: i64,
oracle_price: i64,
message_sequence: Option<i64>,
) -> Result<()> {
sqlx::query(
"INSERT OR REPLACE INTO oracle_cash_price
(oracle_timestamp, oracle_price, message_sequence)
VALUES (?, ?, ?)",
)
.bind(oracle_timestamp)
.bind(oracle_price)
.bind(message_sequence)
.execute(pool)
.await
.map_err(|e| anyhow::anyhow!("failed to upsert oracle_cash_price: {}", e))?;
Ok(())
}
/// Returns the earliest oracle_timestamp stored, or None if the table is empty.
pub async fn get_oracle_cash_min_timestamp(pool: &SqlitePool) -> Result<Option<i64>> {
let row: (Option<i64>,) = sqlx::query_as("SELECT MIN(oracle_timestamp) FROM oracle_cash_price")
.fetch_one(pool)
.await?;
Ok(row.0)
}
pub async fn get_oracle_cash_closest(
pool: &SqlitePool,
timestamp: i64,
) -> Result<Option<OracleCashPrice>> {
let row = sqlx::query(
"SELECT oracle_timestamp, oracle_price, message_sequence
FROM oracle_cash_price
WHERE oracle_timestamp <= ?
ORDER BY oracle_timestamp DESC
LIMIT 1",
)
.bind(timestamp)
.fetch_optional(pool)
.await?;
Ok(row.map(|r| OracleCashPrice {
oracle_timestamp: r.get(0),
oracle_price: r.get(1),
message_sequence: r.get(2),
}))
}
pub async fn get_oracle_cash_range(
pool: &SqlitePool,
start: i64,
end: i64,
) -> Result<Vec<OracleCashPrice>> {
let rows = sqlx::query(
"SELECT oracle_timestamp, oracle_price, message_sequence
FROM oracle_cash_price
WHERE oracle_timestamp BETWEEN ? AND ?
ORDER BY oracle_timestamp ASC",
)
.bind(start)
.bind(end)
.fetch_all(pool)
.await?;
Ok(rows
.iter()
.map(|r| OracleCashPrice {
oracle_timestamp: r.get(0),
oracle_price: r.get(1),
message_sequence: r.get(2),
})
.collect())
}
pub async fn get_oracle_cash_range_with_step(
pool: &SqlitePool,
start: i64,
end: i64,
step: i64,
) -> Result<Vec<OracleCashPrice>> {
let total_intervals = (end - start) / step;
const MAX_INTERVALS: i64 = 10_000;
if total_intervals > MAX_INTERVALS {
bail!(
"Too many intervals ({} > {})",
total_intervals,
MAX_INTERVALS
);
}
let all = get_oracle_cash_range(pool, start, end).await?;
let mut buckets: Vec<OracleCashPrice> = Vec::new();
let mut next_threshold = start + step;
let mut last_entry: Option<OracleCashPrice> = None;
for entry in all {
while entry.oracle_timestamp >= next_threshold {
if let Some(e) = last_entry.take() {
buckets.push(e);
}
next_threshold += step;
}
last_entry = Some(entry);
}
if let Some(e) = last_entry {
if buckets
.last()
.map(|b| b.oracle_timestamp != e.oracle_timestamp)
.unwrap_or(true)
{
buckets.push(e);
}
}
Ok(buckets)
}

View file

@ -79,7 +79,6 @@ mod db;
mod def;
mod electrum;
mod index;
mod oracle_cash;
mod rpc;
mod signal;
mod timeutil;
@ -119,7 +118,6 @@ async fn start_program(
BCMRDownloader,
WellKnownDownloader,
CRC20Fetcher,
oracle_cash::OracleCashFetcher,
Arc<IbdState>,
Arc<AtomicBool>, // indexing_in_progress
)> {
@ -354,15 +352,11 @@ async fn start_program(
spawn_token_metrics_updater(db.clone(), indexing_in_progress.clone());
let mut oracle_cash_fetcher = oracle_cash::OracleCashFetcher::new();
oracle_cash_fetcher.start(db.oracle_w.clone(), db.oracle_r.clone());
Ok((
db,
bcmrdownloader,
wellknowndownloader,
crc20fetcher,
oracle_cash_fetcher,
ibd_state,
indexing_in_progress,
))
@ -389,7 +383,6 @@ async fn launch() -> _ {
bcmrdownloader,
wellknowndownloader,
crc20fetcher,
oracle_cash_fetcher,
ibd_state,
indexing_in_progress,
) = match start_program(config).await {
@ -581,7 +574,6 @@ async fn launch() -> _ {
.manage(bcmrdownloader)
.manage(wellknowndownloader)
.manage(crc20fetcher)
.manage(oracle_cash_fetcher)
.mount(
"/cauldron/",
routes![

View file

@ -1,230 +0,0 @@
// 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
//! Background task that polls oracles.cash and keeps oracle_cash_price up-to-date.
//! This is a parallel feed alongside the on-chain Delphi oracle; both write to
//! oracle.db but to separate tables.
use std::{
sync::{
atomic::{AtomicBool, Ordering},
Arc,
},
time::Duration,
};
use log::{info, warn};
use serde::Deserialize;
use sqlx::SqlitePool;
use tokio::task::JoinHandle;
use crate::db::oracle::oracle_cash::{get_oracle_cash_min_timestamp, upsert_oracle_cash_price};
use crate::timeutil::time_now;
const ORACLES_CASH_URL: &str = "https://oracles.generalprotocols.com";
/// General Protocols BCH/USD oracle public key
const BCH_USD_ORACLE_PUBKEY: &str =
"02d09db08af1ff4e8453919cc866a4be427d7bfe18f2c05e5444c196fcf6fd2818";
const POLL_INTERVAL: Duration = Duration::from_secs(300); // 5 minutes
const REQUEST_TIMEOUT: Duration = Duration::from_secs(20);
const BACKFILL_AGGREGATION: i64 = 3600; // 1-hour buckets
/// Backfill oracle_cash data starting from this timestamp.
/// Everything before this point is served from the on-chain Delphi indexed table,
/// so oracle_cash only needs to cover from here onward.
/// Value: 2026-04-23 00:00:00 UTC (last reliable Delphi oracle update).
const ORACLE_CUTOFF_TS: i64 = 1776902400;
// ── API response types ────────────────────────────────────────────────────────
#[derive(Deserialize, Debug)]
struct MessageMetrics {
#[serde(rename = "maxMessageSequence")]
max_message_sequence: i64,
/// Price in cents (same unit as on-chain Delphi: 38424 = $384.24)
#[serde(rename = "currentPrice")]
current_price: i64,
#[serde(rename = "maxMessageTimestamp")]
max_message_timestamp: i64,
}
#[derive(Deserialize, Debug)]
struct OracleEntry {
#[serde(rename = "publicKey")]
public_key: String,
#[serde(rename = "messageMetrics")]
message_metrics: Option<MessageMetrics>,
}
#[derive(Deserialize, Debug)]
struct OraclesResponse {
oracles: Vec<OracleEntry>,
}
#[derive(Deserialize, Debug)]
struct PriceGraphPoint {
#[serde(rename = "averageTimestamp")]
average_timestamp: f64,
/// Average price in cents over the aggregation window
#[serde(rename = "averagePrice")]
average_price: f64,
}
#[derive(Deserialize, Debug)]
struct PriceGraphResponse {
#[serde(rename = "priceGraphPoints")]
price_graph_points: Vec<PriceGraphPoint>,
}
// ── Fetch helpers ─────────────────────────────────────────────────────────────
/// Returns (timestamp_secs, price_cents, message_sequence)
async fn fetch_current_price(client: &reqwest::Client) -> anyhow::Result<(i64, i64, i64)> {
let url = format!("{}/api/v1/oracles", ORACLES_CASH_URL);
let resp: OraclesResponse = client
.get(&url)
.timeout(REQUEST_TIMEOUT)
.send()
.await?
.error_for_status()?
.json()
.await?;
let oracle = resp
.oracles
.into_iter()
.find(|o| o.public_key == BCH_USD_ORACLE_PUBKEY)
.ok_or_else(|| anyhow::anyhow!("BCH/USD oracle not found in oracles.cash response"))?;
let m = oracle
.message_metrics
.ok_or_else(|| anyhow::anyhow!("No messageMetrics for BCH/USD oracle"))?;
Ok((
m.max_message_timestamp,
m.current_price,
m.max_message_sequence,
))
}
/// Fetches hourly price history from ORACLE_CUTOFF_TS to now and inserts into the DB.
async fn backfill_history(client: &reqwest::Client, pool: &SqlitePool) -> anyhow::Result<()> {
let now = time_now();
let min_ts = ORACLE_CUTOFF_TS;
let url = format!(
"{}/api/v2/priceGraphPoints?publicKey={}&minMessageTimestamp={}&maxMessageTimestamp={}&aggregationPeriod={}",
ORACLES_CASH_URL, BCH_USD_ORACLE_PUBKEY, min_ts, now, BACKFILL_AGGREGATION
);
let resp: PriceGraphResponse = client
.get(&url)
.timeout(Duration::from_secs(30))
.send()
.await?
.error_for_status()?
.json()
.await?;
let count = resp.price_graph_points.len();
for point in resp.price_graph_points {
let ts = point.average_timestamp as i64;
let price = point.average_price as i64;
// No real sequence available for aggregated points
upsert_oracle_cash_price(pool, ts, price, None).await?;
}
info!("oracle_cash: backfilled {count} hourly price points");
Ok(())
}
// ── Fetcher ───────────────────────────────────────────────────────────────────
pub struct OracleCashFetcher {
keep_running: Arc<AtomicBool>,
task: Option<JoinHandle<()>>,
}
impl OracleCashFetcher {
pub fn new() -> Self {
Self {
keep_running: Arc::new(AtomicBool::new(true)),
task: None,
}
}
pub fn start(&mut self, oracle_w: SqlitePool, oracle_r: SqlitePool) {
let keep_running = self.keep_running.clone();
self.task = Some(tokio::spawn(async move {
let client = reqwest::Client::new();
// Backfill if the table is empty or if the earliest entry is after
// ORACLE_CUTOFF_TS (meaning we're missing data from when the Delphi oracle
// stopped being updated).
let needs_backfill = match get_oracle_cash_min_timestamp(&oracle_r).await {
Ok(None) => {
info!("oracle_cash: table empty, will backfill from ORACLE_CUTOFF_TS");
true
}
Ok(Some(min_ts)) => {
if min_ts > ORACLE_CUTOFF_TS {
info!(
"oracle_cash: earliest entry {min_ts} is after cutoff {ORACLE_CUTOFF_TS} — will backfill"
);
true
} else {
info!(
"oracle_cash: earliest entry {min_ts} covers cutoff, skipping backfill"
);
false
}
}
Err(e) => {
warn!("oracle_cash: could not check earliest timestamp: {e}");
false
}
};
if needs_backfill {
if let Err(e) = backfill_history(&client, &oracle_w).await {
warn!("oracle_cash: backfill failed: {e}");
}
}
loop {
if !keep_running.load(Ordering::Relaxed) {
info!("oracle_cash: exiting fetch task");
return;
}
match fetch_current_price(&client).await {
Ok((ts, price_cents, seq)) => {
match upsert_oracle_cash_price(&oracle_w, ts, price_cents, Some(seq)).await
{
Ok(()) => info!(
"oracle_cash: stored price={price_cents} cents ts={ts} seq={seq}"
),
Err(e) => warn!("oracle_cash: failed to store price: {e}"),
}
}
Err(e) => warn!("oracle_cash: failed to fetch current price: {e}"),
}
tokio::time::sleep(POLL_INTERVAL).await;
}
}));
}
}
impl Drop for OracleCashFetcher {
fn drop(&mut self) {
self.keep_running.store(false, Ordering::SeqCst);
if let Some(task) = self.task.take() {
task.abort();
info!("oracle_cash fetch task aborted");
}
}
}

View file

@ -3,12 +3,9 @@
// 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::oracle_cash::{
get_oracle_cash_closest, get_oracle_cash_range, get_oracle_cash_range_with_step,
};
use crate::db::oracle::{get_closest, get_range, get_range_with_step};
use crate::db::DB;
use crate::rpc::err::{bad_request, db_error, ApiErrorCode, CachedApiResult};
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;
@ -196,49 +193,36 @@ pub async fn oracle_get_history(
))
}
/// Get the closest BCH/USD price from the oracles.cash feed for a given timestamp.
///
/// Status: Stable
///
/// - timestamp: Unix timestamp in seconds (optional, defaults to now)
///
/// Returns `null` if no data is available.
///
/// `oracle_price` is in **cents**. Divide by 100 to get USD.
/// Delphi oracle updates roughly every 515 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 23May 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_oracle_cash_closest(&db.oracle_r, ts)
let entry = get_closest(&db.oracle_r, &None, ts)
.await
.map_err(db_error)?;
Ok(cached_ok(
entry.map_or(serde_json::Value::Null, |e| {
serde_json::to_value(e).unwrap()
}),
CACHE_AGGREGATE,
))
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 from the oracles.cash feed.
///
/// Status: Stable
///
/// - start: Unix timestamp for start of period
/// - end: Unix timestamp for end of period (optional, defaults to now)
/// - stepsize: Seconds per interval (optional)
///
/// `oracle_price` values are in **cents**.
///
/// **Response Example:**
/// ```json
/// [
/// { "oracle_timestamp": 1709468902, "oracle_price": 38424, "message_sequence": 12345 },
/// { "oracle_timestamp": 1709472502, "oracle_price": 38501, "message_sequence": null }
/// ]
/// ```
/// 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>,
@ -251,7 +235,7 @@ pub async fn oracle_cash_history(
let end_ts = end.unwrap_or(current_timestamp);
let entries = if let Some(step) = stepsize {
get_oracle_cash_range_with_step(&db.oracle_r, start_ts, end_ts, step)
get_range_with_step(&db.oracle_r, &None, start_ts, end_ts, step)
.await
.map_err(|e| {
bad_request(
@ -260,7 +244,7 @@ pub async fn oracle_cash_history(
)
})?
} else {
get_oracle_cash_range(&db.oracle_r, start_ts, end_ts)
get_range(&db.oracle_r, &None, start_ts, end_ts)
.await
.map_err(|e| {
bad_request(