Remove oracle_cash quickfix introduced Apr 24 2026
Deletes all oracle_cash infrastructure: the background poller (oracle_cash.rs), DB layer (db/oracle/oracle_cash.rs), RPC endpoints (cash/closest, cash/history), table init, and all wiring in main.rs. usd_per_bch_at_or_before is simplified to a single get_closest(&None, ts) call now that Delphi v2 data is indexed in the same delphi_entry table. Returns anyhow::Result<Decimal> and errors instead of silently returning zero when no oracle price is available. Callers updated with ?. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
12f50429de
commit
acfed56475
9 changed files with 113 additions and 533 deletions
101
compare_price.sh
Executable file
101
compare_price.sh
Executable file
|
|
@ -0,0 +1,101 @@
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
# Compare price/<token>/at/<ts> between a local instance and production.
|
||||||
|
# Usage: ./compare_price.sh [local_base] [prod_base]
|
||||||
|
# Defaults: localhost:8000 indexer.riften.net
|
||||||
|
|
||||||
|
LOCAL="${1:-http://localhost:8000}"
|
||||||
|
PROD="${2:-https://indexer.riften.net}"
|
||||||
|
|
||||||
|
TOKENS=(
|
||||||
|
"422134612b1914d7f7ff2cf9b793f5b5356a424ca195887a0458e55b38017d71"
|
||||||
|
"b38a33f750f84c5c169a6f23cb873e6e79605021585d4f3408789689ed87f366"
|
||||||
|
)
|
||||||
|
|
||||||
|
# One timestamp per month from Jun 2024 through Mar 2026
|
||||||
|
TIMESTAMPS=(
|
||||||
|
1717200000 # 2024-06-01
|
||||||
|
1719878400 # 2024-07-02
|
||||||
|
1722470400 # 2024-08-01
|
||||||
|
1725148800 # 2024-09-01
|
||||||
|
1727740800 # 2024-10-01
|
||||||
|
1730419200 # 2024-11-01
|
||||||
|
1733011200 # 2024-12-01
|
||||||
|
1735689600 # 2025-01-01
|
||||||
|
1738368000 # 2025-02-01
|
||||||
|
1741046400 # 2025-03-04
|
||||||
|
1743724800 # 2025-04-04
|
||||||
|
1746316800 # 2025-05-04
|
||||||
|
1748736000 # 2025-06-01
|
||||||
|
1751328000 # 2025-07-01
|
||||||
|
1754006400 # 2025-08-01
|
||||||
|
1756684800 # 2025-09-01
|
||||||
|
1759276800 # 2025-10-01
|
||||||
|
1761955200 # 2025-11-01
|
||||||
|
1764547200 # 2025-12-01
|
||||||
|
1767225600 # 2026-01-01
|
||||||
|
1769904000 # 2026-02-01
|
||||||
|
1772323200 # 2026-03-01
|
||||||
|
)
|
||||||
|
|
||||||
|
query() {
|
||||||
|
local base="$1" token="$2" ts="$3"
|
||||||
|
curl -sf --max-time 8 "$base/cauldron/price/$token/at/$ts" 2>/dev/null \
|
||||||
|
|| echo '{"error":"no_response"}'
|
||||||
|
}
|
||||||
|
|
||||||
|
price_of() {
|
||||||
|
echo "$1" | python3 -c "
|
||||||
|
import sys, json
|
||||||
|
d = json.load(sys.stdin)
|
||||||
|
if 'price' in d:
|
||||||
|
print(f\"{d['price']:.6f}\")
|
||||||
|
elif 'error' in d:
|
||||||
|
code = d['error'].get('code', d['error']) if isinstance(d['error'], dict) else d['error']
|
||||||
|
print(f'ERR:{code}')
|
||||||
|
else:
|
||||||
|
print('ERR:unknown')
|
||||||
|
"
|
||||||
|
}
|
||||||
|
|
||||||
|
print_header() {
|
||||||
|
local token="$1"
|
||||||
|
echo ""
|
||||||
|
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
|
||||||
|
echo "Token: ${token:0:16}…"
|
||||||
|
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
|
||||||
|
printf "%-14s %-22s %-22s %s\n" "Date" "Local" "Prod" "Diff?"
|
||||||
|
printf "%-14s %-22s %-22s %s\n" "--------------" "----------------------" "----------------------" "------"
|
||||||
|
}
|
||||||
|
|
||||||
|
DIVERGED=0
|
||||||
|
|
||||||
|
for token in "${TOKENS[@]}"; do
|
||||||
|
print_header "$token"
|
||||||
|
for ts in "${TIMESTAMPS[@]}"; do
|
||||||
|
date_label=$(python3 -c "import datetime; print(datetime.datetime.utcfromtimestamp($ts).strftime('%Y-%m-%d'))")
|
||||||
|
local_resp=$(query "$LOCAL" "$token" "$ts")
|
||||||
|
prod_resp=$(query "$PROD" "$token" "$ts")
|
||||||
|
local_price=$(price_of "$local_resp")
|
||||||
|
prod_price=$(price_of "$prod_resp")
|
||||||
|
|
||||||
|
diff_marker=""
|
||||||
|
if [ "$local_price" != "$prod_price" ]; then
|
||||||
|
diff_marker="<-- DIFF"
|
||||||
|
DIVERGED=1
|
||||||
|
fi
|
||||||
|
|
||||||
|
printf "%-14s %-22s %-22s %s\n" "$date_label" "$local_price" "$prod_price" "$diff_marker"
|
||||||
|
done
|
||||||
|
done
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
if [ "$DIVERGED" -eq 1 ]; then
|
||||||
|
echo "DIVERGENCE FOUND — fix is exercised for at least one timestamp."
|
||||||
|
echo "Local returning price where prod returns ERR:PRICE_NOT_FOUND"
|
||||||
|
echo "means a withdrawn pool is correctly included in the historical query."
|
||||||
|
else
|
||||||
|
echo "No divergence found. Either:"
|
||||||
|
echo " - Neither token ever had a withdrawn pool, or"
|
||||||
|
echo " - Local is not running the fixed build, or"
|
||||||
|
echo " - Prod is already updated."
|
||||||
|
fi
|
||||||
|
|
@ -51,7 +51,7 @@ pub async fn update_tvl_and_price_now(
|
||||||
|
|
||||||
let sats_per_bch = Decimal::from_i64(SATS_PER_BCH).unwrap();
|
let sats_per_bch = Decimal::from_i64(SATS_PER_BCH).unwrap();
|
||||||
// USD per sat (now)
|
// 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);
|
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() {
|
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();
|
let sats_per_bch = Decimal::from_i64(SATS_PER_BCH).unwrap();
|
||||||
// oracle (USD/BCH) → USD per sat
|
// 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_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_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_7d = usd_per_bch_at_or_before(&db.oracle_r, ts_7d).await? / sats_per_bch;
|
||||||
|
|
||||||
// ========== READ PHASE: collect all token data ==========
|
// ========== READ PHASE: collect all token data ==========
|
||||||
let token_rows = sqlx::query(
|
let token_rows = sqlx::query(
|
||||||
|
|
|
||||||
|
|
@ -11,8 +11,6 @@ use sqlx::SqlitePool;
|
||||||
use crate::db::blob::display_hex_to_blob;
|
use crate::db::blob::display_hex_to_blob;
|
||||||
use crate::db::cauldron::pool::{get_injections_between, get_pool_period_snapshot};
|
use crate::db::cauldron::pool::{get_injections_between, get_pool_period_snapshot};
|
||||||
use crate::db::oracle::get_closest;
|
use crate::db::oracle::get_closest;
|
||||||
use crate::db::oracle::oracle_cash::get_oracle_cash_closest;
|
|
||||||
use crate::db::oracle::v2::v2_bchusd_token_id;
|
|
||||||
use crate::rpc::apy::apyaggregator::APYAggregator;
|
use crate::rpc::apy::apyaggregator::APYAggregator;
|
||||||
use crate::rpc::apy::poolperiod::split_at_injections;
|
use crate::rpc::apy::poolperiod::split_at_injections;
|
||||||
use malachite::base::num::arithmetic::traits::FloorSqrt;
|
use malachite::base::num::arithmetic::traits::FloorSqrt;
|
||||||
|
|
@ -58,41 +56,16 @@ pub fn dec_to_f64_bounded(d: Decimal) -> f64 {
|
||||||
pub const SATS_PER_BCH: i64 = 100_000_000;
|
pub const SATS_PER_BCH: i64 = 100_000_000;
|
||||||
/// On-chain Delphi oracle scale: prices are stored in cents
|
/// On-chain Delphi oracle scale: prices are stored in cents
|
||||||
/// (e.g. $384.24 → 38424). Same unit as the oracles.cash feed.
|
/// (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;
|
pub const ORACLE_SCALE: i64 = 100;
|
||||||
/// Delphi v1 stopped publishing at this timestamp (2026-04-23 00:00:00 UTC).
|
|
||||||
const ORACLE_CUTOFF_TS: i64 = 1776902400;
|
|
||||||
/// Delphi v2 went live at this timestamp (2026-05-04 00:00:00 UTC).
|
|
||||||
const V2_DEPLOY_TS: i64 = 1777852800;
|
|
||||||
|
|
||||||
pub async fn usd_per_bch_at_or_before(oracle_pool: &SqlitePool, ts: i64) -> Decimal {
|
pub async fn usd_per_bch_at_or_before(oracle_pool: &SqlitePool, ts: i64) -> anyhow::Result<Decimal> {
|
||||||
if ts < ORACLE_CUTOFF_TS {
|
let entry = get_closest(oracle_pool, &None, ts)
|
||||||
// Delphi v1: on-chain historical data
|
.await?
|
||||||
match get_closest(oracle_pool, &None, ts).await {
|
.ok_or_else(|| anyhow::anyhow!("No oracle price found for timestamp {ts}"))?;
|
||||||
Ok(Some(e)) => {
|
let price = Decimal::from_i64(entry.oracle_price)
|
||||||
Decimal::from_i64(e.oracle_price).unwrap_or(Decimal::ZERO)
|
.ok_or_else(|| anyhow::anyhow!("Oracle price out of Decimal range: {}", entry.oracle_price))?;
|
||||||
/ Decimal::from_i64(ORACLE_SCALE).unwrap()
|
Ok(price / Decimal::from_i64(ORACLE_SCALE).unwrap())
|
||||||
}
|
|
||||||
_ => Decimal::ZERO,
|
|
||||||
}
|
|
||||||
} else if ts < V2_DEPLOY_TS {
|
|
||||||
// oracles.cash: bridges the Apr 23 – May 4 gap
|
|
||||||
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,
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// Delphi v2: on-chain data from May 4 2026 onward
|
|
||||||
let token_id = Some(v2_bchusd_token_id().clone());
|
|
||||||
match get_closest(oracle_pool, &token_id, ts).await {
|
|
||||||
Ok(Some(e)) => {
|
|
||||||
Decimal::from_i64(e.oracle_price).unwrap_or(Decimal::ZERO)
|
|
||||||
/ Decimal::from_i64(ORACLE_SCALE).unwrap()
|
|
||||||
}
|
|
||||||
_ => Decimal::ZERO,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[inline]
|
#[inline]
|
||||||
|
|
|
||||||
|
|
@ -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::cauldron::prepare_tables as cauldron_prepare_tables;
|
||||||
use crate::db::crc20::prepare_tables as crc20_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::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;
|
use crate::db::oracle::prepare_tables as oracle_prepare_tables;
|
||||||
|
|
||||||
/// Create read and write database pools for a given database path
|
/// 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 {
|
if !db_exists {
|
||||||
oracle_prepare_tables(&oracle_db_write).await;
|
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
|
// Initialize moria lending database
|
||||||
let (db_exists, moria_db_write, moria_db_read) =
|
let (db_exists, moria_db_write, moria_db_read) =
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,6 @@
|
||||||
// This software is licensed under the GNU Affero General Public License (AGPL), version 3.0 or later.
|
// 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
|
// 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;
|
pub mod v2;
|
||||||
|
|
||||||
use anyhow::{bail, Result};
|
use anyhow::{bail, Result};
|
||||||
|
|
|
||||||
|
|
@ -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)
|
|
||||||
}
|
|
||||||
10
src/main.rs
10
src/main.rs
|
|
@ -79,7 +79,6 @@ mod db;
|
||||||
mod def;
|
mod def;
|
||||||
mod electrum;
|
mod electrum;
|
||||||
mod index;
|
mod index;
|
||||||
mod oracle_cash;
|
|
||||||
mod rpc;
|
mod rpc;
|
||||||
mod signal;
|
mod signal;
|
||||||
mod timeutil;
|
mod timeutil;
|
||||||
|
|
@ -119,7 +118,6 @@ async fn start_program(
|
||||||
BCMRDownloader,
|
BCMRDownloader,
|
||||||
WellKnownDownloader,
|
WellKnownDownloader,
|
||||||
CRC20Fetcher,
|
CRC20Fetcher,
|
||||||
oracle_cash::OracleCashFetcher,
|
|
||||||
Arc<IbdState>,
|
Arc<IbdState>,
|
||||||
Arc<AtomicBool>, // indexing_in_progress
|
Arc<AtomicBool>, // indexing_in_progress
|
||||||
)> {
|
)> {
|
||||||
|
|
@ -354,15 +352,11 @@ async fn start_program(
|
||||||
|
|
||||||
spawn_token_metrics_updater(db.clone(), indexing_in_progress.clone());
|
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((
|
Ok((
|
||||||
db,
|
db,
|
||||||
bcmrdownloader,
|
bcmrdownloader,
|
||||||
wellknowndownloader,
|
wellknowndownloader,
|
||||||
crc20fetcher,
|
crc20fetcher,
|
||||||
oracle_cash_fetcher,
|
|
||||||
ibd_state,
|
ibd_state,
|
||||||
indexing_in_progress,
|
indexing_in_progress,
|
||||||
))
|
))
|
||||||
|
|
@ -389,7 +383,6 @@ async fn launch() -> _ {
|
||||||
bcmrdownloader,
|
bcmrdownloader,
|
||||||
wellknowndownloader,
|
wellknowndownloader,
|
||||||
crc20fetcher,
|
crc20fetcher,
|
||||||
oracle_cash_fetcher,
|
|
||||||
ibd_state,
|
ibd_state,
|
||||||
indexing_in_progress,
|
indexing_in_progress,
|
||||||
) = match start_program(config).await {
|
) = match start_program(config).await {
|
||||||
|
|
@ -581,7 +574,6 @@ async fn launch() -> _ {
|
||||||
.manage(bcmrdownloader)
|
.manage(bcmrdownloader)
|
||||||
.manage(wellknowndownloader)
|
.manage(wellknowndownloader)
|
||||||
.manage(crc20fetcher)
|
.manage(crc20fetcher)
|
||||||
.manage(oracle_cash_fetcher)
|
|
||||||
.mount(
|
.mount(
|
||||||
"/cauldron/",
|
"/cauldron/",
|
||||||
routes![
|
routes![
|
||||||
|
|
@ -620,8 +612,6 @@ async fn launch() -> _ {
|
||||||
rpc::oracle::oracle_get_closest,
|
rpc::oracle::oracle_get_closest,
|
||||||
rpc::oracle::oracle_get_range,
|
rpc::oracle::oracle_get_range,
|
||||||
rpc::oracle::oracle_get_history,
|
rpc::oracle::oracle_get_history,
|
||||||
rpc::oracle::oracle_cash_closest,
|
|
||||||
rpc::oracle::oracle_cash_history,
|
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
.mount(
|
.mount(
|
||||||
|
|
|
||||||
|
|
@ -1,238 +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 maintains oracle_cash_price for the historical gap period
|
|
||||||
//! (Apr 23 – May 4 2026) between Delphi v1 and v2.
|
|
||||||
//!
|
|
||||||
//! Set `USE_ORACLES_CASH_LIVE_FEED = true` to re-enable continuous polling as a
|
|
||||||
//! live price fallback (e.g. if the on-chain v2 oracle becomes unavailable).
|
|
||||||
|
|
||||||
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";
|
|
||||||
|
|
||||||
/// Set to true to continuously poll oracles.cash for live price updates.
|
|
||||||
/// False: only backfill the gap period; Delphi v2 is the live source.
|
|
||||||
const USE_ORACLES_CASH_LIVE_FEED: bool = false;
|
|
||||||
|
|
||||||
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 starts from when Delphi v1 stopped (2026-04-23 00:00:00 UTC).
|
|
||||||
const ORACLE_CUTOFF_TS: i64 = 1776902400;
|
|
||||||
/// Backfill ends when Delphi v2 went live (2026-05-04 00:00:00 UTC).
|
|
||||||
/// Only the gap between these two timestamps needs oracles.cash coverage.
|
|
||||||
const V2_DEPLOY_TS: i64 = 1777852800;
|
|
||||||
|
|
||||||
// ── 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 for the gap period [ORACLE_CUTOFF_TS, V2_DEPLOY_TS].
|
|
||||||
async fn backfill_history(client: &reqwest::Client, pool: &SqlitePool) -> anyhow::Result<()> {
|
|
||||||
let url = format!(
|
|
||||||
"{}/api/v2/priceGraphPoints?publicKey={}&minMessageTimestamp={}&maxMessageTimestamp={}&aggregationPeriod={}",
|
|
||||||
ORACLES_CASH_URL, BCH_USD_ORACLE_PUBKEY, ORACLE_CUTOFF_TS, V2_DEPLOY_TS, 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}");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if !USE_ORACLES_CASH_LIVE_FEED {
|
|
||||||
info!("oracle_cash: live feed disabled, gap backfill complete");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
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");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -3,9 +3,6 @@
|
||||||
// This software is licensed under the GNU Affero General Public License (AGPL), version 3.0 or later.
|
// 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
|
// 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::oracle::{get_closest, get_range, get_range_with_step};
|
||||||
use crate::db::DB;
|
use crate::db::DB;
|
||||||
use crate::rpc::err::{bad_request, db_error, ApiErrorCode, CachedApiResult};
|
use crate::rpc::err::{bad_request, db_error, ApiErrorCode, CachedApiResult};
|
||||||
|
|
@ -196,82 +193,3 @@ 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.
|
|
||||||
#[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)
|
|
||||||
.await
|
|
||||||
.map_err(db_error)?;
|
|
||||||
Ok(cached_ok(
|
|
||||||
entry.map_or(serde_json::Value::Null, |e| {
|
|
||||||
serde_json::to_value(e).unwrap()
|
|
||||||
}),
|
|
||||||
CACHE_AGGREGATE,
|
|
||||||
))
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 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("/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_oracle_cash_range_with_step(&db.oracle_r, start_ts, end_ts, step)
|
|
||||||
.await
|
|
||||||
.map_err(|e| {
|
|
||||||
bad_request(
|
|
||||||
ApiErrorCode::InvalidParameters,
|
|
||||||
&format!("Query error: {e}"),
|
|
||||||
)
|
|
||||||
})?
|
|
||||||
} else {
|
|
||||||
get_oracle_cash_range(&db.oracle_r, 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,
|
|
||||||
))
|
|
||||||
}
|
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue