Merge branch '14-panic-occurred-attempt-to-multiply-with-overflow' into 'master'
Resolve "Panic occurred: attempt to multiply with overflow" Closes #14 See merge request riftenlabs/riftenlabs-indexer!40
This commit is contained in:
commit
e999c2e32d
6 changed files with 1087 additions and 480 deletions
|
|
@ -401,7 +401,7 @@ pub fn get_new_headers(
|
||||||
let mut blockhash = *new_tip;
|
let mut blockhash = *new_tip;
|
||||||
|
|
||||||
while blockhash != null_hash {
|
while blockhash != null_hash {
|
||||||
if new_headers.len() % 1000 == 0 {
|
if new_headers.len().is_multiple_of(1000) {
|
||||||
info!(
|
info!(
|
||||||
"Downloading headers progress: {} fetched... ",
|
"Downloading headers progress: {} fetched... ",
|
||||||
new_headers.len()
|
new_headers.len()
|
||||||
|
|
|
||||||
|
|
@ -11,12 +11,10 @@ use crate::db::cauldron::poolvisitor::{db_visit_pool_entries, PoolFilters};
|
||||||
use crate::db::cauldron::tokenlist::db_utils::{
|
use crate::db::cauldron::tokenlist::db_utils::{
|
||||||
cache_first_pool_ts_if_empty, db_first_pool_creation_row,
|
cache_first_pool_ts_if_empty, db_first_pool_creation_row,
|
||||||
};
|
};
|
||||||
use crate::db::cauldron::tokenlist::token_utils::apy_30d_bp_for_token;
|
|
||||||
use crate::db::cauldron::tokenlist::token_utils::pct_change_bp_dec;
|
|
||||||
use crate::db::cauldron::tokenlist::token_utils::resolve_display_labels;
|
|
||||||
use crate::db::cauldron::tokenlist::token_utils::{
|
use crate::db::cauldron::tokenlist::token_utils::{
|
||||||
compute_score, pow10_dec, price_from_tvl, resolve_decimals, usd_per_bch_at_or_before,
|
apy_30d_bp_for_token, compute_score, dec_round, dec_to_f64_bounded, overflow_f64_fallback,
|
||||||
SATS_PER_BCH,
|
pct_change_bp_dec, pow10_dec, price_from_tvl, resolve_decimals, resolve_display_labels,
|
||||||
|
usd_per_bch_at_or_before, SATS_PER_BCH,
|
||||||
};
|
};
|
||||||
use crate::db::DB;
|
use crate::db::DB;
|
||||||
use crate::rpc::price::price_at_or_before_2;
|
use crate::rpc::price::price_at_or_before_2;
|
||||||
|
|
@ -27,15 +25,52 @@ use bitcoin_hashes::hex::FromHex;
|
||||||
use bitcoincash::TokenID;
|
use bitcoincash::TokenID;
|
||||||
use log::{error, info, warn};
|
use log::{error, info, warn};
|
||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
|
use std::sync::{
|
||||||
|
atomic::{AtomicBool, Ordering},
|
||||||
|
Arc,
|
||||||
|
};
|
||||||
|
use std::time::{Duration, SystemTime, UNIX_EPOCH};
|
||||||
|
|
||||||
// ---------- 2 min: TVL + price_now (and price_now_usd) ----------
|
const WRITE_CHUNK_RELEASE: usize = 300;
|
||||||
|
const WRITE_CHUNK: usize = WRITE_CHUNK_RELEASE;
|
||||||
|
|
||||||
|
/// Retry a DB op on SQLITE_BUSY with jittered backoff (no extra deps).
|
||||||
|
fn with_busy_retry<T, F>(mut f: F, max_wait: Duration) -> anyhow::Result<T>
|
||||||
|
where
|
||||||
|
F: FnMut() -> rusqlite::Result<T>,
|
||||||
|
{
|
||||||
|
use rusqlite::{Error as SqliteError, ErrorCode as SqliteCode};
|
||||||
|
let start = std::time::Instant::now();
|
||||||
|
let mut backoff_ms: u64 = 25;
|
||||||
|
loop {
|
||||||
|
match f() {
|
||||||
|
Ok(v) => return Ok(v),
|
||||||
|
Err(SqliteError::SqliteFailure(err, _)) if err.code == SqliteCode::DatabaseBusy => {
|
||||||
|
if start.elapsed() >= max_wait {
|
||||||
|
return Err(SqliteError::SqliteFailure(err, Some("busy-timeout".into())).into());
|
||||||
|
}
|
||||||
|
let seed_ms = SystemTime::now()
|
||||||
|
.duration_since(UNIX_EPOCH)
|
||||||
|
.unwrap_or_else(|_| Duration::from_millis(0))
|
||||||
|
.subsec_millis() as u64;
|
||||||
|
let sleep_ms = (seed_ms % backoff_ms.max(1)).min(2000);
|
||||||
|
std::thread::sleep(Duration::from_millis(sleep_ms));
|
||||||
|
backoff_ms = (backoff_ms * 2).min(2000);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
Err(e) => return Err(e.into()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- 2.5 min: TVL + price_now (+ price_now_usd), chunked ----------
|
||||||
pub fn update_tvl_and_price_now(
|
pub fn update_tvl_and_price_now(
|
||||||
cauldron_conn: &Connection,
|
cauldron_conn: &Connection,
|
||||||
bcmr_conn: &Connection,
|
bcmr_conn: &Connection,
|
||||||
crc20_conn: &Connection,
|
crc20_conn: &Connection,
|
||||||
oracle_conn: &Connection,
|
oracle_conn: &Connection,
|
||||||
) -> anyhow::Result<()> {
|
) -> anyhow::Result<()> {
|
||||||
// TVL
|
// TVL snapshot (read-only)
|
||||||
let mut tvl_vis = TvlByTokenVisitor::default();
|
let mut tvl_vis = TvlByTokenVisitor::default();
|
||||||
db_visit_pool_entries(cauldron_conn, &mut tvl_vis, PoolFilters::new())?;
|
db_visit_pool_entries(cauldron_conn, &mut tvl_vis, PoolFilters::new())?;
|
||||||
let tvl_by_token = tvl_vis.into_map();
|
let tvl_by_token = tvl_vis.into_map();
|
||||||
|
|
@ -44,17 +79,111 @@ pub 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();
|
||||||
let usd_per_sat_now = usd_per_bch_at_or_before(oracle_conn, time_now()) / sats_per_bch;
|
let usd_per_sat_now = usd_per_bch_at_or_before(oracle_conn, time_now()) / sats_per_bch;
|
||||||
|
|
||||||
let tx = cauldron_conn.unchecked_transaction()?;
|
// Upsert in short chunks
|
||||||
{
|
let mut batch: Vec<(&str, i64, i64, f64, f64)> = Vec::with_capacity(WRITE_CHUNK);
|
||||||
// Mark all cached rows stale for this tick so we can drop zero-liquidity rows deterministically
|
for (token_id, (tvl_sats, tvl_tokens)) in tvl_by_token.iter() {
|
||||||
tx.execute_batch(
|
if *tvl_sats == 0 && *tvl_tokens == 0 {
|
||||||
r#"
|
continue;
|
||||||
UPDATE cached_token_metrics
|
}
|
||||||
SET tvl_sats = 0,
|
|
||||||
tvl_tokens = 0;
|
|
||||||
"#,
|
|
||||||
)?;
|
|
||||||
|
|
||||||
|
let decimals = resolve_decimals(bcmr_conn, crc20_conn, token_id);
|
||||||
|
let factor = pow10_dec(decimals);
|
||||||
|
|
||||||
|
let price_now_dec = price_from_tvl(*tvl_sats, *tvl_tokens).unwrap_or(Decimal::ZERO);
|
||||||
|
let price_now_human_dec = match price_now_dec.checked_mul(factor) {
|
||||||
|
Some(v) => v,
|
||||||
|
None => {
|
||||||
|
warn!(
|
||||||
|
"price_now overflow mul; token={} base={} factor=10^{} tvl_sats={} tvl_tokens={}",
|
||||||
|
token_id, price_now_dec, decimals, tvl_sats, tvl_tokens
|
||||||
|
);
|
||||||
|
let price_now_human = overflow_f64_fallback(price_now_dec, decimals);
|
||||||
|
batch.push((
|
||||||
|
token_id.as_str(),
|
||||||
|
*tvl_sats as i64,
|
||||||
|
*tvl_tokens as i64,
|
||||||
|
price_now_human,
|
||||||
|
0.0_f64,
|
||||||
|
));
|
||||||
|
if batch.len() >= WRITE_CHUNK {
|
||||||
|
flush_fast_batch(cauldron_conn, &batch)?;
|
||||||
|
batch.clear();
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let price_now_human = dec_to_f64_bounded(dec_round(price_now_human_dec, 12));
|
||||||
|
let price_now_usd = match price_now_human_dec.checked_mul(usd_per_sat_now) {
|
||||||
|
Some(d) => dec_to_f64_bounded(dec_round(d, 12)),
|
||||||
|
None => {
|
||||||
|
// Decimal overflow → fallback in f64
|
||||||
|
let usdps_f = dec_to_f64_bounded(usd_per_sat_now);
|
||||||
|
let usd_f = price_now_human * usdps_f;
|
||||||
|
if usd_f.is_finite() {
|
||||||
|
usd_f
|
||||||
|
} else {
|
||||||
|
f64::MAX.copysign(usd_f)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
batch.push((
|
||||||
|
token_id.as_str(),
|
||||||
|
*tvl_sats as i64,
|
||||||
|
*tvl_tokens as i64,
|
||||||
|
price_now_human,
|
||||||
|
price_now_usd,
|
||||||
|
));
|
||||||
|
if batch.len() >= WRITE_CHUNK {
|
||||||
|
flush_fast_batch(cauldron_conn, &batch)?;
|
||||||
|
batch.clear();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !batch.is_empty() {
|
||||||
|
flush_fast_batch(cauldron_conn, &batch)?;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Drop rows that vanished in this snapshot (short tx + temp table)
|
||||||
|
flush_delete_absent(cauldron_conn, tvl_by_token.keys().map(|s| s.as_str()))?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn to_usd_f64_with_fallback(
|
||||||
|
human_dec_opt: Option<Decimal>,
|
||||||
|
usd_per_sat_dec: Decimal,
|
||||||
|
decimals: u32,
|
||||||
|
) -> Option<f64> {
|
||||||
|
human_dec_opt.map(|h| {
|
||||||
|
if let Some(usd_dec) = h.checked_mul(usd_per_sat_dec) {
|
||||||
|
dec_to_f64_bounded(dec_round(usd_dec, 12))
|
||||||
|
} else {
|
||||||
|
let human_f = to_human_f64_with_fallback(h, decimals);
|
||||||
|
let usd_f = dec_to_f64_bounded(usd_per_sat_dec) * human_f;
|
||||||
|
if usd_f.is_finite() {
|
||||||
|
usd_f
|
||||||
|
} else {
|
||||||
|
f64::MAX.copysign(usd_f)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
#[inline]
|
||||||
|
fn to_human_f64_with_fallback(base_dec: Decimal, decimals: u32) -> f64 {
|
||||||
|
if let Some(h) = base_dec.checked_mul(pow10_dec(decimals)) {
|
||||||
|
dec_to_f64_bounded(dec_round(h, 12))
|
||||||
|
} else {
|
||||||
|
overflow_f64_fallback(base_dec, decimals)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn flush_fast_batch(conn: &Connection, rows: &[(&str, i64, i64, f64, f64)]) -> anyhow::Result<()> {
|
||||||
|
with_busy_retry(
|
||||||
|
|| {
|
||||||
|
let tx = conn.unchecked_transaction()?;
|
||||||
|
{
|
||||||
let mut upsert = tx.prepare(
|
let mut upsert = tx.prepare(
|
||||||
r#"
|
r#"
|
||||||
INSERT INTO cached_token_metrics
|
INSERT INTO cached_token_metrics
|
||||||
|
|
@ -72,45 +201,56 @@ pub fn update_tvl_and_price_now(
|
||||||
tvl_sats = excluded.tvl_sats,
|
tvl_sats = excluded.tvl_sats,
|
||||||
tvl_tokens = excluded.tvl_tokens,
|
tvl_tokens = excluded.tvl_tokens,
|
||||||
price_now = excluded.price_now,
|
price_now = excluded.price_now,
|
||||||
price_now_usd = excluded.price_now_usd, -- ← only this USD field
|
price_now_usd = excluded.price_now_usd,
|
||||||
updated_at = excluded.updated_at
|
updated_at = excluded.updated_at
|
||||||
"#,
|
"#,
|
||||||
)?;
|
)?;
|
||||||
|
for (tid, sats, toks, p_now, p_now_usd) in rows {
|
||||||
for (token_id, (tvl_sats, tvl_tokens)) in tvl_by_token {
|
upsert.execute(params![*tid, *sats, *toks, *p_now, *p_now_usd])?;
|
||||||
// Skip on no liquidity
|
|
||||||
if tvl_sats == 0 && tvl_tokens == 0 {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
let decimals = resolve_decimals(bcmr_conn, crc20_conn, &token_id);
|
|
||||||
let factor = pow10_dec(decimals);
|
|
||||||
|
|
||||||
let price_now_dec = price_from_tvl(tvl_sats, tvl_tokens).unwrap_or(Decimal::ZERO);
|
|
||||||
let price_now_human = (price_now_dec * factor).to_f64().unwrap_or(0.0);
|
|
||||||
let price_now_usd = (price_now_dec * factor * usd_per_sat_now)
|
|
||||||
.to_f64()
|
|
||||||
.unwrap_or(0.0);
|
|
||||||
|
|
||||||
upsert.execute(rusqlite::params![
|
|
||||||
token_id,
|
|
||||||
tvl_sats as i64,
|
|
||||||
tvl_tokens as i64,
|
|
||||||
price_now_human,
|
|
||||||
price_now_usd,
|
|
||||||
])?;
|
|
||||||
}
|
|
||||||
// Remove rows that ended up with *no* liquidity (both sats AND tokens zero)
|
|
||||||
tx.execute(
|
|
||||||
"DELETE FROM cached_token_metrics WHERE tvl_sats = 0 AND tvl_tokens = 0",
|
|
||||||
[],
|
|
||||||
)?;
|
|
||||||
}
|
}
|
||||||
|
} // drop(upsert)
|
||||||
tx.commit()?;
|
tx.commit()?;
|
||||||
Ok(())
|
Ok(())
|
||||||
|
},
|
||||||
|
Duration::from_secs(120),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn flush_delete_absent<'a>(
|
||||||
|
conn: &Connection,
|
||||||
|
present_ids: impl Iterator<Item = &'a str>,
|
||||||
|
) -> anyhow::Result<()> {
|
||||||
|
let ids: Vec<String> = present_ids.map(|s| s.to_owned()).collect();
|
||||||
|
with_busy_retry(
|
||||||
|
|| {
|
||||||
|
let tx = conn.unchecked_transaction()?;
|
||||||
|
tx.execute_batch(
|
||||||
|
"CREATE TEMP TABLE IF NOT EXISTS __present (token_id TEXT PRIMARY KEY);",
|
||||||
|
)?;
|
||||||
|
tx.execute_batch("DELETE FROM __present;")?;
|
||||||
|
{
|
||||||
|
let mut ins = tx.prepare("INSERT INTO __present(token_id) VALUES (?1)")?;
|
||||||
|
for tid in &ids {
|
||||||
|
ins.execute(params![tid])?;
|
||||||
|
}
|
||||||
|
} // drop(ins)
|
||||||
|
tx.execute(
|
||||||
|
r#"
|
||||||
|
DELETE FROM cached_token_metrics
|
||||||
|
WHERE token_id NOT IN (SELECT token_id FROM __present)
|
||||||
|
"#,
|
||||||
|
[],
|
||||||
|
)?;
|
||||||
|
tx.commit()?;
|
||||||
|
Ok(())
|
||||||
|
},
|
||||||
|
Duration::from_secs(120),
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn recompute_score_ranks(conn: &Connection) -> anyhow::Result<()> {
|
pub fn recompute_score_ranks(conn: &Connection) -> anyhow::Result<()> {
|
||||||
|
with_busy_retry(
|
||||||
|
|| {
|
||||||
conn.execute_batch(
|
conn.execute_batch(
|
||||||
r#"
|
r#"
|
||||||
WITH ranked AS (
|
WITH ranked AS (
|
||||||
|
|
@ -133,6 +273,9 @@ pub fn recompute_score_ranks(conn: &Connection) -> anyhow::Result<()> {
|
||||||
"#,
|
"#,
|
||||||
)?;
|
)?;
|
||||||
Ok(())
|
Ok(())
|
||||||
|
},
|
||||||
|
Duration::from_secs(120),
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------- 5 min: price changes (24h/7d), score, volume + ranking ----------
|
// ---------- 5 min: price changes (24h/7d), score, volume + ranking ----------
|
||||||
|
|
@ -146,7 +289,7 @@ pub fn update_changes_score_volume_and_ranking(
|
||||||
let now = time_now();
|
let now = time_now();
|
||||||
let since = (now - 30 * 24 * 60 * 60).max(0);
|
let since = (now - 30 * 24 * 60 * 60).max(0);
|
||||||
|
|
||||||
// volume per token (unchanged)
|
// volume per token
|
||||||
let mut vol_stmt = cauldron_conn.prepare(
|
let mut vol_stmt = cauldron_conn.prepare(
|
||||||
r#"
|
r#"
|
||||||
SELECT p.token_id, COALESCE(SUM(ABS(phe.sats_delta)), 0) AS vol
|
SELECT p.token_id, COALESCE(SUM(ABS(phe.sats_delta)), 0) AS vol
|
||||||
|
|
@ -165,7 +308,7 @@ pub fn update_changes_score_volume_and_ranking(
|
||||||
vol_by_token.insert(token_id, vol.max(0) as u64);
|
vol_by_token.insert(token_id, vol.max(0) as u64);
|
||||||
}
|
}
|
||||||
|
|
||||||
// define window anchors
|
// window anchors
|
||||||
let ts_24h = now - 86_400;
|
let ts_24h = now - 86_400;
|
||||||
let ts_7d = now - 7 * 86_400;
|
let ts_7d = now - 7 * 86_400;
|
||||||
|
|
||||||
|
|
@ -175,6 +318,9 @@ pub fn update_changes_score_volume_and_ranking(
|
||||||
let usd_per_sat_24h = usd_per_bch_at_or_before(oracle_conn, ts_24h) / sats_per_bch;
|
let usd_per_sat_24h = usd_per_bch_at_or_before(oracle_conn, ts_24h) / sats_per_bch;
|
||||||
let usd_per_sat_7d = usd_per_bch_at_or_before(oracle_conn, ts_7d) / sats_per_bch;
|
let usd_per_sat_7d = usd_per_bch_at_or_before(oracle_conn, ts_7d) / sats_per_bch;
|
||||||
|
|
||||||
|
// One write tx; scope statements so they drop before commit.
|
||||||
|
with_busy_retry(
|
||||||
|
|| {
|
||||||
let tx = cauldron_conn.unchecked_transaction()?;
|
let tx = cauldron_conn.unchecked_transaction()?;
|
||||||
{
|
{
|
||||||
let mut upd = tx.prepare(
|
let mut upd = tx.prepare(
|
||||||
|
|
@ -183,13 +329,11 @@ pub fn update_changes_score_volume_and_ranking(
|
||||||
SET trade_volume = ?2,
|
SET trade_volume = ?2,
|
||||||
score = ?3,
|
score = ?3,
|
||||||
|
|
||||||
-- BCH/human historicals (nullable)
|
|
||||||
price_24h = ?4,
|
price_24h = ?4,
|
||||||
price_7d = ?5,
|
price_7d = ?5,
|
||||||
change_24h_bp = ?6,
|
change_24h_bp = ?6,
|
||||||
change_7d_bp = ?7,
|
change_7d_bp = ?7,
|
||||||
|
|
||||||
-- USD now always set; historical USD nullable
|
|
||||||
price_now_usd = ?8,
|
price_now_usd = ?8,
|
||||||
price_24h_usd = ?9,
|
price_24h_usd = ?9,
|
||||||
price_7d_usd = ?10,
|
price_7d_usd = ?10,
|
||||||
|
|
@ -203,9 +347,10 @@ pub fn update_changes_score_volume_and_ranking(
|
||||||
"#,
|
"#,
|
||||||
)?;
|
)?;
|
||||||
|
|
||||||
// we need tvl & decimals for price_now and scaling
|
// need tvl for price_now and scaling
|
||||||
let mut cur_stmt = tx
|
let mut cur_stmt = tx.prepare(
|
||||||
.prepare("SELECT tvl_sats, tvl_tokens FROM cached_token_metrics WHERE token_id = ?1")?;
|
"SELECT tvl_sats, tvl_tokens FROM cached_token_metrics WHERE token_id = ?1",
|
||||||
|
)?;
|
||||||
let mut id_stmt =
|
let mut id_stmt =
|
||||||
tx.prepare("SELECT token_id FROM cached_token_metrics WHERE tvl_sats > 0")?;
|
tx.prepare("SELECT token_id FROM cached_token_metrics WHERE tvl_sats > 0")?;
|
||||||
|
|
||||||
|
|
@ -227,52 +372,120 @@ pub fn update_changes_score_volume_and_ranking(
|
||||||
let tvl_sats = tvl_sats_i64.max(0) as u64;
|
let tvl_sats = tvl_sats_i64.max(0) as u64;
|
||||||
let tvl_tokens = tvl_tokens_i64.max(0) as u64;
|
let tvl_tokens = tvl_tokens_i64.max(0) as u64;
|
||||||
|
|
||||||
// price_now (base) & scaled
|
// price_now (base) & scaled with guards
|
||||||
let price_now_dec = price_from_tvl(tvl_sats, tvl_tokens).unwrap_or(Decimal::ZERO);
|
let price_now_dec =
|
||||||
let price_now_human_dec = price_now_dec * factor;
|
price_from_tvl(tvl_sats, tvl_tokens).unwrap_or(Decimal::ZERO);
|
||||||
let price_now_usd_dec = price_now_human_dec * usd_per_sat_now;
|
|
||||||
|
let price_now_human_dec = match price_now_dec.checked_mul(factor) {
|
||||||
|
Some(v) => v,
|
||||||
|
None => {
|
||||||
|
warn!(
|
||||||
|
"core price_now overflow; token={}, base={}, factor=10^{}, tvl_sats={}, tvl_tokens={}",
|
||||||
|
token_id, price_now_dec, decimals_u32, tvl_sats, tvl_tokens
|
||||||
|
);
|
||||||
|
|
||||||
|
// Keep existing fallback behavior here.
|
||||||
|
let p_now_human_f = overflow_f64_fallback(price_now_dec, decimals_u32);
|
||||||
|
let p_now_usd_f = {
|
||||||
|
let usd_per_sat_f = dec_to_f64_bounded(usd_per_sat_now);
|
||||||
|
let usd = p_now_human_f * usd_per_sat_f;
|
||||||
|
if usd.is_finite() {
|
||||||
|
usd
|
||||||
|
} else {
|
||||||
|
f64::MAX.copysign(usd)
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let vol = vol_by_token.get(&token_id).copied().unwrap_or(0);
|
||||||
|
let score = compute_score(tvl_sats, vol);
|
||||||
|
let labels_opt =
|
||||||
|
resolve_display_labels(bcmr_conn, crc20_conn, &token_id).ok();
|
||||||
|
let (display_name, display_symbol) = labels_opt
|
||||||
|
.map(|(dn, sym)| (Some(dn), Some(sym)))
|
||||||
|
.unwrap_or((None, None));
|
||||||
|
|
||||||
|
upd.execute(params![
|
||||||
|
token_id,
|
||||||
|
vol as i64,
|
||||||
|
score,
|
||||||
|
Option::<f64>::None, // price_24h
|
||||||
|
Option::<f64>::None, // price_7d
|
||||||
|
Option::<i64>::None, // change_24h_bp
|
||||||
|
Option::<i64>::None, // change_7d_bp
|
||||||
|
p_now_usd_f, // fallback (finite) on this branch
|
||||||
|
Option::<f64>::None, // price_24h_usd
|
||||||
|
Option::<f64>::None, // price_7d_usd
|
||||||
|
Option::<i64>::None, // change_24h_usd_bp
|
||||||
|
Option::<i64>::None, // change_7d_usd_bp
|
||||||
|
display_name,
|
||||||
|
display_symbol,
|
||||||
|
])?;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// NOTE: If multiplying human * usd_per_sat overflows, write NULL (unknown).
|
||||||
|
let price_now_usd_dec_opt = price_now_human_dec.checked_mul(usd_per_sat_now);
|
||||||
|
let price_now_usd_f_opt =
|
||||||
|
price_now_usd_dec_opt.map(|d| dec_to_f64_bounded(dec_round(d, 12)));
|
||||||
|
|
||||||
// volume/score
|
// volume/score
|
||||||
let vol = vol_by_token.get(&token_id).copied().unwrap_or(0);
|
let vol = vol_by_token.get(&token_id).copied().unwrap_or(0);
|
||||||
let score = compute_score(tvl_sats, vol);
|
let score = compute_score(tvl_sats, vol);
|
||||||
|
|
||||||
// labels (unchanged)
|
// labels
|
||||||
let labels_opt = resolve_display_labels(bcmr_conn, crc20_conn, &token_id).ok();
|
let labels_opt = resolve_display_labels(bcmr_conn, crc20_conn, &token_id).ok();
|
||||||
let (display_name, display_symbol): (Option<String>, Option<String>) = match labels_opt
|
let (display_name, display_symbol): (Option<String>, Option<String>) =
|
||||||
{
|
match labels_opt {
|
||||||
Some((dn, sym)) => (Some(dn), Some(sym)),
|
Some((dn, sym)) => (Some(dn), Some(sym)),
|
||||||
None => (None, None),
|
None => (None, None),
|
||||||
};
|
};
|
||||||
|
|
||||||
// Common conversions
|
|
||||||
let price_now_human_f = price_now_human_dec.to_f64().unwrap_or(0.0);
|
|
||||||
let price_now_usd_f = price_now_usd_dec.to_f64().unwrap_or(0.0);
|
|
||||||
|
|
||||||
// We'll need TokenID for historical probes
|
// We'll need TokenID for historical probes
|
||||||
let tid = match TokenID::from_hex(&token_id) {
|
let tid = match TokenID::from_hex(&token_id) {
|
||||||
Ok(t) => t,
|
Ok(t) => t,
|
||||||
Err(_) => continue,
|
Err(_) => {
|
||||||
|
// still update the "now" fields and basics
|
||||||
|
upd.execute(params![
|
||||||
|
token_id,
|
||||||
|
vol as i64,
|
||||||
|
score,
|
||||||
|
Option::<f64>::None,
|
||||||
|
Option::<f64>::None,
|
||||||
|
Option::<i64>::None,
|
||||||
|
Option::<i64>::None,
|
||||||
|
price_now_usd_f_opt, // ← may be NULL if overflowed
|
||||||
|
Option::<f64>::None,
|
||||||
|
Option::<f64>::None,
|
||||||
|
Option::<i64>::None,
|
||||||
|
Option::<i64>::None,
|
||||||
|
display_name,
|
||||||
|
display_symbol,
|
||||||
|
])?;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
if vol == 0 {
|
if vol == 0 {
|
||||||
// --------- FAST PATH (no trades in 30d) ----------
|
// --------- FAST PATH (no trades in 30d) ----------
|
||||||
// Only gate by *existence* of a historical point; if none → keep NULLs.
|
// Only gate by *existence* of a historical point; if none → keep NULLs
|
||||||
let p24_exists = matches!(
|
let p24_exists = matches!(
|
||||||
price_at_or_before_2(cauldron_conn, ts_24h, &tid),
|
price_at_or_before_2(cauldron_conn, ts_24h, &tid),
|
||||||
Ok((_ts, _p))
|
Ok((_ts, _))
|
||||||
);
|
);
|
||||||
let p7d_exists = matches!(
|
let p7d_exists = matches!(
|
||||||
price_at_or_before_2(cauldron_conn, ts_7d, &tid),
|
price_at_or_before_2(cauldron_conn, ts_7d, &tid),
|
||||||
Ok((_ts, _p))
|
Ok((_ts, _))
|
||||||
);
|
);
|
||||||
|
|
||||||
let price_24h_human_f = if p24_exists {
|
let price_24h_human_f = if p24_exists {
|
||||||
Some(price_now_human_f)
|
Some(dec_to_f64_bounded(dec_round(price_now_human_dec, 12)))
|
||||||
} else {
|
} else {
|
||||||
None
|
None
|
||||||
};
|
};
|
||||||
|
|
||||||
let price_7d_human_f = if p7d_exists {
|
let price_7d_human_f = if p7d_exists {
|
||||||
Some(price_now_human_f)
|
Some(dec_to_f64_bounded(dec_round(price_now_human_dec, 12)))
|
||||||
} else {
|
} else {
|
||||||
None
|
None
|
||||||
};
|
};
|
||||||
|
|
@ -281,22 +494,31 @@ pub fn update_changes_score_volume_and_ranking(
|
||||||
let d7d_bp_opt = if p7d_exists { Some(0) } else { None };
|
let d7d_bp_opt = if p7d_exists { Some(0) } else { None };
|
||||||
|
|
||||||
let price_24h_usd_f = if p24_exists && !usd_per_sat_24h.is_zero() {
|
let price_24h_usd_f = if p24_exists && !usd_per_sat_24h.is_zero() {
|
||||||
(price_now_human_dec * usd_per_sat_24h).to_f64()
|
price_now_human_dec
|
||||||
|
.checked_mul(usd_per_sat_24h)
|
||||||
|
.map(|d| dec_to_f64_bounded(dec_round(d, 12)))
|
||||||
} else {
|
} else {
|
||||||
None
|
None
|
||||||
};
|
};
|
||||||
let price_7d_usd_f = if p7d_exists && !usd_per_sat_7d.is_zero() {
|
let price_7d_usd_f = if p7d_exists && !usd_per_sat_7d.is_zero() {
|
||||||
(price_now_human_dec * usd_per_sat_7d).to_f64()
|
price_now_human_dec
|
||||||
|
.checked_mul(usd_per_sat_7d)
|
||||||
|
.map(|d| dec_to_f64_bounded(dec_round(d, 12)))
|
||||||
} else {
|
} else {
|
||||||
None
|
None
|
||||||
};
|
};
|
||||||
|
|
||||||
let d24_usd_bp_opt = price_24h_usd_f
|
let old24_u_dec_opt = price_24h_usd_f.and_then(Decimal::from_f64);
|
||||||
.and_then(Decimal::from_f64)
|
let old7d_u_dec_opt = price_7d_usd_f.and_then(Decimal::from_f64);
|
||||||
.map(|old| pct_change_bp_dec(price_now_usd_dec, old));
|
|
||||||
let d7d_usd_bp_opt = price_7d_usd_f
|
let d24_usd_bp_opt = match (price_now_usd_dec_opt, old24_u_dec_opt) {
|
||||||
.and_then(Decimal::from_f64)
|
(Some(now_u), Some(old_u)) => Some(pct_change_bp_dec(now_u, old_u)),
|
||||||
.map(|old| pct_change_bp_dec(price_now_usd_dec, old));
|
_ => None,
|
||||||
|
};
|
||||||
|
let d7d_usd_bp_opt = match (price_now_usd_dec_opt, old7d_u_dec_opt) {
|
||||||
|
(Some(now_u), Some(old_u)) => Some(pct_change_bp_dec(now_u, old_u)),
|
||||||
|
_ => None,
|
||||||
|
};
|
||||||
|
|
||||||
upd.execute(params![
|
upd.execute(params![
|
||||||
token_id,
|
token_id,
|
||||||
|
|
@ -306,7 +528,7 @@ pub fn update_changes_score_volume_and_ranking(
|
||||||
price_7d_human_f,
|
price_7d_human_f,
|
||||||
d24_bp_opt,
|
d24_bp_opt,
|
||||||
d7d_bp_opt,
|
d7d_bp_opt,
|
||||||
price_now_usd_f,
|
price_now_usd_f_opt, // ← may be NULL
|
||||||
price_24h_usd_f,
|
price_24h_usd_f,
|
||||||
price_7d_usd_f,
|
price_7d_usd_f,
|
||||||
d24_usd_bp_opt,
|
d24_usd_bp_opt,
|
||||||
|
|
@ -325,34 +547,42 @@ pub fn update_changes_score_volume_and_ranking(
|
||||||
.ok()
|
.ok()
|
||||||
.and_then(|(_, p)| Decimal::from_f64(p));
|
.and_then(|(_, p)| Decimal::from_f64(p));
|
||||||
|
|
||||||
// scale to “human” (nullable)
|
// scale to “human” with overflow guard (nullable)
|
||||||
let price_24h_human_opt = p24_dec_opt.map(|p| p * factor);
|
let price_24h_human_opt = p24_dec_opt.and_then(|p| p.checked_mul(factor));
|
||||||
let price_7d_human_opt = p7d_dec_opt.map(|p| p * factor);
|
let price_7d_human_opt = p7d_dec_opt.and_then(|p| p.checked_mul(factor));
|
||||||
|
|
||||||
// BCH deltas (nullable)
|
// BCH deltas (nullable)
|
||||||
let d24_bp_opt = p24_dec_opt.map(|p| pct_change_bp_dec(price_now_dec, p));
|
let d24_bp_opt = p24_dec_opt.map(|p| pct_change_bp_dec(price_now_dec, p));
|
||||||
let d7d_bp_opt = p7d_dec_opt.map(|p| pct_change_bp_dec(price_now_dec, p));
|
let d7d_bp_opt = p7d_dec_opt.map(|p| pct_change_bp_dec(price_now_dec, p));
|
||||||
|
|
||||||
// USD historicals (nullable if oracle missing)
|
// USD historicals (nullable if oracle missing or overflow)
|
||||||
let price_24h_usd_opt = match (price_24h_human_opt, !usd_per_sat_24h.is_zero()) {
|
let price_24h_usd_opt = match (price_24h_human_opt, !usd_per_sat_24h.is_zero())
|
||||||
(Some(h), true) => Some(h * usd_per_sat_24h),
|
{
|
||||||
|
(Some(h), true) => h.checked_mul(usd_per_sat_24h),
|
||||||
_ => None,
|
_ => None,
|
||||||
};
|
};
|
||||||
let price_7d_usd_opt = match (price_7d_human_opt, !usd_per_sat_7d.is_zero()) {
|
let price_7d_usd_opt = match (price_7d_human_opt, !usd_per_sat_7d.is_zero()) {
|
||||||
(Some(h), true) => Some(h * usd_per_sat_7d),
|
(Some(h), true) => h.checked_mul(usd_per_sat_7d),
|
||||||
_ => None,
|
_ => None,
|
||||||
};
|
};
|
||||||
|
|
||||||
let price_24h_human_f = price_24h_human_opt.and_then(|d| d.to_f64());
|
let price_24h_human_f =
|
||||||
let price_7d_human_f = price_7d_human_opt.and_then(|d| d.to_f64());
|
p24_dec_opt.map(|p24| to_human_f64_with_fallback(p24, decimals_u32));
|
||||||
let price_24h_usd_f = price_24h_usd_opt.and_then(|d| d.to_f64());
|
let price_7d_human_f =
|
||||||
let price_7d_usd_f = price_7d_usd_opt.and_then(|d| d.to_f64());
|
p7d_dec_opt.map(|p7d| to_human_f64_with_fallback(p7d, decimals_u32));
|
||||||
|
let price_24h_usd_f = to_usd_f64_with_fallback(
|
||||||
|
price_24h_human_opt,
|
||||||
|
usd_per_sat_24h,
|
||||||
|
decimals_u32,
|
||||||
|
);
|
||||||
|
let price_7d_usd_f =
|
||||||
|
to_usd_f64_with_fallback(price_7d_human_opt, usd_per_sat_7d, decimals_u32);
|
||||||
|
|
||||||
let d24_usd_bp_opt = match (Some(price_now_usd_dec), price_24h_usd_opt) {
|
let d24_usd_bp_opt = match (price_now_usd_dec_opt, price_24h_usd_opt) {
|
||||||
(Some(now_u), Some(old_u)) => Some(pct_change_bp_dec(now_u, old_u)),
|
(Some(now_u), Some(old_u)) => Some(pct_change_bp_dec(now_u, old_u)),
|
||||||
_ => None,
|
_ => None,
|
||||||
};
|
};
|
||||||
let d7d_usd_bp_opt = match (Some(price_now_usd_dec), price_7d_usd_opt) {
|
let d7d_usd_bp_opt = match (price_now_usd_dec_opt, price_7d_usd_opt) {
|
||||||
(Some(now_u), Some(old_u)) => Some(pct_change_bp_dec(now_u, old_u)),
|
(Some(now_u), Some(old_u)) => Some(pct_change_bp_dec(now_u, old_u)),
|
||||||
_ => None,
|
_ => None,
|
||||||
};
|
};
|
||||||
|
|
@ -365,7 +595,7 @@ pub fn update_changes_score_volume_and_ranking(
|
||||||
price_7d_human_f,
|
price_7d_human_f,
|
||||||
d24_bp_opt,
|
d24_bp_opt,
|
||||||
d7d_bp_opt,
|
d7d_bp_opt,
|
||||||
price_now_usd_f,
|
price_now_usd_f_opt, // ← may be NULL on overflow
|
||||||
price_24h_usd_f,
|
price_24h_usd_f,
|
||||||
price_7d_usd_f,
|
price_7d_usd_f,
|
||||||
d24_usd_bp_opt,
|
d24_usd_bp_opt,
|
||||||
|
|
@ -373,11 +603,10 @@ pub fn update_changes_score_volume_and_ranking(
|
||||||
display_name,
|
display_name,
|
||||||
display_symbol,
|
display_symbol,
|
||||||
])?;
|
])?;
|
||||||
}
|
} // while
|
||||||
}
|
} // scope statements
|
||||||
|
|
||||||
// ranking
|
// ranking (after statements dropped)
|
||||||
// ranking
|
|
||||||
tx.execute_batch(
|
tx.execute_batch(
|
||||||
r#"
|
r#"
|
||||||
WITH ranked AS (
|
WITH ranked AS (
|
||||||
|
|
@ -386,18 +615,26 @@ pub fn update_changes_score_volume_and_ranking(
|
||||||
ORDER BY
|
ORDER BY
|
||||||
score DESC,
|
score DESC,
|
||||||
trade_volume DESC,
|
trade_volume DESC,
|
||||||
tvl_sats DESC, -- <-- same tie-breaker here
|
tvl_sats DESC,
|
||||||
token_id ASC
|
token_id ASC
|
||||||
) AS rnk
|
) AS rnk
|
||||||
FROM cached_token_metrics
|
FROM cached_token_metrics
|
||||||
WHERE tvl_sats > 0
|
WHERE tvl_sats > 0
|
||||||
)
|
)
|
||||||
UPDATE cached_token_metrics
|
UPDATE cached_token_metrics
|
||||||
SET score_rank = (SELECT rnk FROM ranked WHERE ranked.token_id = cached_token_metrics.token_id)
|
SET score_rank = (SELECT rnk
|
||||||
|
FROM ranked
|
||||||
|
WHERE ranked.token_id = cached_token_metrics.token_id)
|
||||||
WHERE token_id IN (SELECT token_id FROM ranked);
|
WHERE token_id IN (SELECT token_id FROM ranked);
|
||||||
"#,
|
"#,
|
||||||
)?;
|
)?;
|
||||||
|
|
||||||
tx.commit()?;
|
tx.commit()?;
|
||||||
|
Ok(())
|
||||||
|
},
|
||||||
|
Duration::from_secs(120),
|
||||||
|
)?;
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -417,11 +654,10 @@ pub fn update_apy_only(cauldron_conn: &Connection) -> anyhow::Result<()> {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Compute/set APY; when vol==0, write 0 directly (no heavy compute)
|
// Compute/set APY; when vol==0, write 0 directly
|
||||||
let mut apys = Vec::<(String, Option<i64>)>::with_capacity(ids.len());
|
let mut apys = Vec::<(String, Option<i64>)>::with_capacity(ids.len());
|
||||||
for (token_id, vol30) in ids {
|
for (token_id, vol30) in ids {
|
||||||
let apy_opt = if vol30 == 0 {
|
let apy_opt = if vol30 == 0 {
|
||||||
// optimization: don't compute; by definition over 30d there were no trades → 0
|
|
||||||
Some(0)
|
Some(0)
|
||||||
} else {
|
} else {
|
||||||
match apy_30d_bp_for_token(cauldron_conn, &token_id, now) {
|
match apy_30d_bp_for_token(cauldron_conn, &token_id, now) {
|
||||||
|
|
@ -435,6 +671,8 @@ pub fn update_apy_only(cauldron_conn: &Connection) -> anyhow::Result<()> {
|
||||||
apys.push((token_id, apy_opt));
|
apys.push((token_id, apy_opt));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
with_busy_retry(
|
||||||
|
|| {
|
||||||
let tx = cauldron_conn.unchecked_transaction()?;
|
let tx = cauldron_conn.unchecked_transaction()?;
|
||||||
{
|
{
|
||||||
let mut upd = tx.prepare(
|
let mut upd = tx.prepare(
|
||||||
|
|
@ -443,143 +681,129 @@ pub fn update_apy_only(cauldron_conn: &Connection) -> anyhow::Result<()> {
|
||||||
updated_at = CAST(strftime('%s','now') AS INTEGER)
|
updated_at = CAST(strftime('%s','now') AS INTEGER)
|
||||||
WHERE token_id = ?1",
|
WHERE token_id = ?1",
|
||||||
)?;
|
)?;
|
||||||
for (tid, apy_opt) in apys {
|
for (tid, apy_opt) in apys.iter() {
|
||||||
upd.execute(params![tid, apy_opt])?;
|
upd.execute(params![tid, apy_opt])?;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
tx.commit()?;
|
tx.commit()?;
|
||||||
|
Ok(())
|
||||||
|
},
|
||||||
|
Duration::from_secs(120),
|
||||||
|
)?;
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn spawn_token_metrics_updater(db: DB) {
|
pub fn spawn_token_metrics_updater(db: DB, indexing_in_progress: Arc<AtomicBool>) {
|
||||||
std::thread::spawn(move || {
|
std::thread::spawn(move || {
|
||||||
use std::time::{Duration, Instant};
|
use std::time::Instant;
|
||||||
|
|
||||||
// ---- PRODUCTION CADENCE ----
|
// ---- PRODUCTION CADENCE ----
|
||||||
const BASE_SECS: u64 = 150; // 2.5 min
|
const BASE_SECS: u64 = 150; // 2.5 min
|
||||||
const CORE_SECS: u64 = 300; // 5 min
|
const CORE_SECS: u64 = 300; // 5 min
|
||||||
const APY_SECS: u64 = 900; // 15 min
|
const APY_SECS: u64 = 900; // 15 min
|
||||||
|
|
||||||
// ---- DEV QUICK CADENCE (uncomment to test locally) ----
|
|
||||||
// const BASE_SECS: u64 = 60;
|
|
||||||
// const CORE_SECS: u64 = 120;
|
|
||||||
// const APY_SECS: u64 = 320;
|
|
||||||
|
|
||||||
let base_d = Duration::from_secs(BASE_SECS);
|
let base_d = Duration::from_secs(BASE_SECS);
|
||||||
let core_every = (CORE_SECS / BASE_SECS).max(1);
|
let core_every = (CORE_SECS / BASE_SECS).max(1);
|
||||||
let apy_every = (APY_SECS / BASE_SECS).max(1);
|
let apy_every = (APY_SECS / BASE_SECS).max(1);
|
||||||
|
|
||||||
// anchor to a stable base schedule
|
|
||||||
let mut tick: u64 = 0;
|
let mut tick: u64 = 0;
|
||||||
let mut next_deadline = Instant::now(); // first tick runs immediately
|
let mut next_deadline = Instant::now(); // first tick runs immediately
|
||||||
|
|
||||||
loop {
|
loop {
|
||||||
// Wait until the next base boundary (but don't sleep negative)
|
// Back off if the indexer is mutating the DB
|
||||||
|
if indexing_in_progress.load(Ordering::Relaxed) {
|
||||||
|
std::thread::sleep(Duration::from_millis(250));
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Wait until the next base boundary
|
||||||
let now = Instant::now();
|
let now = Instant::now();
|
||||||
if now < next_deadline {
|
if now < next_deadline {
|
||||||
std::thread::sleep(next_deadline - now);
|
std::thread::sleep(next_deadline - now);
|
||||||
}
|
}
|
||||||
|
|
||||||
// advance schedule for the *next* loop before doing work (prevents drift)
|
|
||||||
tick = tick.saturating_add(1);
|
tick = tick.saturating_add(1);
|
||||||
next_deadline += base_d;
|
next_deadline += base_d;
|
||||||
|
|
||||||
// Acquire connections once per tick; reuse across the cascade
|
|
||||||
let cw = db.cauldron_w.get();
|
|
||||||
let bcmr = db.bcmr_r.get();
|
|
||||||
let crc = db.crc20_r.get();
|
|
||||||
let orc = db.oracle_r.get();
|
|
||||||
|
|
||||||
// ---------- FAST (every base tick): TVL + price_now ----------
|
// ---------- FAST (every base tick): TVL + price_now ----------
|
||||||
{
|
if indexing_in_progress.load(Ordering::Relaxed) {
|
||||||
let t0: Instant = Instant::now();
|
continue;
|
||||||
match (&cw, &bcmr, &crc, &orc) {
|
|
||||||
(Ok(cw), Ok(bcmr), Ok(crc), Ok(orc)) => {
|
|
||||||
match update_tvl_and_price_now(cw, bcmr, crc, orc) {
|
|
||||||
Ok(_) => info!("✅ fast(2.5m) TVL+price_now in {:.3?}", t0.elapsed()),
|
|
||||||
Err(e) => {
|
|
||||||
error!("❌ fast(2.5m) failed after {:.3?}: {e:?}", t0.elapsed())
|
|
||||||
}
|
}
|
||||||
|
{
|
||||||
|
let t0 = Instant::now();
|
||||||
|
if let (Ok(cw), Ok(bcmr), Ok(crc), Ok(orc)) = (
|
||||||
|
db.cauldron_w.get(),
|
||||||
|
db.bcmr_r.get(),
|
||||||
|
db.crc20_r.get(),
|
||||||
|
db.oracle_r.get(),
|
||||||
|
) {
|
||||||
|
match update_tvl_and_price_now(&cw, &bcmr, &crc, &orc) {
|
||||||
|
Ok(_) => info!("✅ fast(2.5m) TVL+price_now in {:.3?}", t0.elapsed()),
|
||||||
|
Err(e) => error!("❌ fast(2.5m) failed after {:.3?}: {e:?}", t0.elapsed()),
|
||||||
}
|
}
|
||||||
|
|
||||||
// Compact ranks immediately on fast-only ticks (avoid gap after deletions)
|
if indexing_in_progress.load(Ordering::Relaxed) {
|
||||||
if tick % core_every != 0 {
|
continue;
|
||||||
if let Err(e) = recompute_score_ranks(cw) {
|
}
|
||||||
|
if !tick.is_multiple_of(core_every) {
|
||||||
|
if let Err(e) = recompute_score_ranks(&cw) {
|
||||||
error!("rank recompute (fast-only tick) failed: {e:?}");
|
error!("rank recompute (fast-only tick) failed: {e:?}");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Batched, idempotent backfill (does nothing once populated)
|
if indexing_in_progress.load(Ordering::Relaxed) {
|
||||||
match backfill_first_pool_ts_batch(cw, 500) {
|
continue;
|
||||||
|
}
|
||||||
|
match backfill_first_pool_ts_batch(&cw, WRITE_CHUNK as i64) {
|
||||||
Ok(n) if n > 0 => info!("first_pool_ts backfill: set {n} tokens"),
|
Ok(n) if n > 0 => info!("first_pool_ts backfill: set {n} tokens"),
|
||||||
Ok(_) => {}
|
Ok(_) => {}
|
||||||
Err(e) => warn!("first_pool_ts backfill failed: {e:?}"),
|
Err(e) => warn!("first_pool_ts backfill failed: {e:?}"),
|
||||||
}
|
}
|
||||||
|
} else {
|
||||||
|
error!("Pool checkout failed for fast tick");
|
||||||
}
|
}
|
||||||
_ => {
|
} // pooled conns dropped here
|
||||||
if let Err(e) = &cw {
|
|
||||||
error!("DB unavailable (cauldron_w fast): {e}");
|
|
||||||
}
|
|
||||||
if let Err(e) = &bcmr {
|
|
||||||
error!("DB unavailable (bcmr_r fast): {e}");
|
|
||||||
}
|
|
||||||
if let Err(e) = &crc {
|
|
||||||
error!("DB unavailable (crc20_r fast): {e}");
|
|
||||||
}
|
|
||||||
if let Err(e) = &orc {
|
|
||||||
error!("DB unavailable (oracle_r fast): {e}");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------- CORE (every 2 base ticks): fast + core ----------
|
// ---------- CORE (every 2 base ticks): fast + core ----------
|
||||||
if tick % core_every == 0 {
|
if tick.is_multiple_of(core_every) {
|
||||||
|
if indexing_in_progress.load(Ordering::Relaxed) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
let t0 = Instant::now();
|
let t0 = Instant::now();
|
||||||
match (&cw, &bcmr, &crc, &orc) {
|
if let (Ok(cw), Ok(bcmr), Ok(crc), Ok(orc)) = (
|
||||||
(Ok(cw), Ok(bcmr), Ok(crc), Ok(orc)) => {
|
db.cauldron_w.get(),
|
||||||
match update_changes_score_volume_and_ranking(cw, bcmr, crc, orc) {
|
db.bcmr_r.get(),
|
||||||
Ok(_) => info!(
|
db.crc20_r.get(),
|
||||||
"✅ core(5m) changes+score+volume(+rank) in {:.3?}",
|
db.oracle_r.get(),
|
||||||
t0.elapsed()
|
) {
|
||||||
),
|
match update_changes_score_volume_and_ranking(&cw, &bcmr, &crc, &orc) {
|
||||||
Err(e) => {
|
Ok(_) => info!("✅ core(5m) in {:.3?}", t0.elapsed()),
|
||||||
error!("❌ core(5m) failed after {:.3?}: {e:?}", t0.elapsed())
|
Err(e) => error!("❌ core(5m) failed after {:.3?}: {e:?}", t0.elapsed()),
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
_ => {
|
|
||||||
if let Err(e) = &cw {
|
|
||||||
error!("DB unavailable (cauldron_w core): {e}");
|
|
||||||
}
|
|
||||||
if let Err(e) = &bcmr {
|
|
||||||
error!("DB unavailable (bcmr_r core): {e}");
|
|
||||||
}
|
|
||||||
if let Err(e) = &crc {
|
|
||||||
error!("DB unavailable (crc20_r core): {e}");
|
|
||||||
}
|
|
||||||
if let Err(e) = &orc {
|
|
||||||
error!("DB unavailable (oracle_r core): {e}");
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
} else {
|
||||||
|
error!("Pool checkout failed for core tick");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------- APY (every 6 base ticks): fast + core + apy ----------
|
// ---------- APY (every 6 base ticks): fast + core + apy ----------
|
||||||
if tick % apy_every == 0 {
|
if tick.is_multiple_of(apy_every) {
|
||||||
|
if indexing_in_progress.load(Ordering::Relaxed) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
let t0 = Instant::now();
|
let t0 = Instant::now();
|
||||||
match &cw {
|
if let Ok(cw) = db.cauldron_w.get() {
|
||||||
Ok(cw) => match update_apy_only(cw) {
|
match update_apy_only(&cw) {
|
||||||
Ok(_) => info!("✅ apy(15m) in {:.3?}", t0.elapsed()),
|
Ok(_) => info!("✅ apy(15m) in {:.3?}", t0.elapsed()),
|
||||||
Err(e) => error!("❌ apy(15m) failed after {:.3?}: {e:?}", t0.elapsed()),
|
Err(e) => error!("❌ apy(15m) failed after {:.3?}: {e:?}", t0.elapsed()),
|
||||||
},
|
}
|
||||||
Err(e) => error!("DB unavailable (cauldron_w apy): {e}"),
|
} else {
|
||||||
|
error!("Pool checkout failed for apy tick");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// If we overran the next base boundary (work took > base), skip ahead
|
// Skip ahead if we overran the boundary
|
||||||
// to the next future boundary to avoid back-to-back catch-up storms.
|
let now2 = std::time::Instant::now();
|
||||||
let now2 = Instant::now();
|
|
||||||
while now2 >= next_deadline {
|
while now2 >= next_deadline {
|
||||||
next_deadline += base_d;
|
next_deadline += base_d;
|
||||||
tick = tick.saturating_add(1);
|
tick = tick.saturating_add(1);
|
||||||
|
|
@ -589,13 +813,13 @@ pub fn spawn_token_metrics_updater(db: DB) {
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn backfill_first_pool_ts_batch(conn: &Connection, limit: i64) -> anyhow::Result<usize> {
|
pub fn backfill_first_pool_ts_batch(conn: &Connection, limit: i64) -> anyhow::Result<usize> {
|
||||||
// 1) Read the batch WITHOUT keeping a borrow alive past this block
|
// 1) Read the batch WITHOUT holding borrows over the tx
|
||||||
let token_ids: Vec<String> = {
|
let token_ids: Vec<String> = {
|
||||||
let mut stmt = conn.prepare(
|
let mut stmt = conn.prepare(
|
||||||
r#"
|
r#"
|
||||||
SELECT token_id
|
SELECT token_id
|
||||||
FROM cached_token_metrics
|
FROM cached_token_metrics
|
||||||
WHERE first_pool_ts IS NULL OR first_pool_ts = 0 -- ← include 0 as “unset”
|
WHERE first_pool_ts IS NULL OR first_pool_ts = 0
|
||||||
ORDER BY token_id
|
ORDER BY token_id
|
||||||
LIMIT ?1;
|
LIMIT ?1;
|
||||||
"#,
|
"#,
|
||||||
|
|
@ -608,14 +832,14 @@ pub fn backfill_first_pool_ts_batch(conn: &Connection, limit: i64) -> anyhow::Re
|
||||||
v
|
v
|
||||||
};
|
};
|
||||||
|
|
||||||
// 2) Do the updates inside a short transaction
|
// 2) Short write tx; all fallible helpers here return anyhow::Result, so `?` is fine.
|
||||||
let tx = conn.unchecked_transaction()?;
|
let tx = conn.unchecked_transaction()?;
|
||||||
let mut updated = 0usize;
|
let mut updated = 0usize;
|
||||||
for token_id in token_ids {
|
for token_id in token_ids {
|
||||||
if let Some((_creation_utxo, _txid, ts, _height)) =
|
if let Some((_creation_utxo, _txid, ts, _height)) =
|
||||||
db_first_pool_creation_row(&tx, &token_id)?
|
db_first_pool_creation_row(&tx, &token_id)?
|
||||||
{
|
{
|
||||||
cache_first_pool_ts_if_empty(&tx, &token_id, ts)?; // will also replace 0; see below
|
cache_first_pool_ts_if_empty(&tx, &token_id, ts)?;
|
||||||
updated += 1;
|
updated += 1;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -23,8 +23,8 @@ mod tests {
|
||||||
};
|
};
|
||||||
use crate::db::cauldron::tokenlist::list_tokens_volume::db_list_tokens_by_volume;
|
use crate::db::cauldron::tokenlist::list_tokens_volume::db_list_tokens_by_volume;
|
||||||
use crate::db::cauldron::tokenlist::token_utils::{
|
use crate::db::cauldron::tokenlist::token_utils::{
|
||||||
apy_30d_bp_for_token, compute_score, pct_change_bp_dec, pow10_dec, price_from_tvl,
|
apy_30d_bp_for_token, compute_score, overflow_f64_fallback, pct_change_bp_dec, pow10_dec,
|
||||||
resolve_decimals, resolve_display_labels,
|
price_from_tvl, resolve_decimals, resolve_display_labels,
|
||||||
};
|
};
|
||||||
use crate::db::cauldron::tx::{insert_block_tx, insert_mempool_tx};
|
use crate::db::cauldron::tx::{insert_block_tx, insert_mempool_tx};
|
||||||
use crate::db::cauldron::utxo_funding::insert_utxo_funding;
|
use crate::db::cauldron::utxo_funding::insert_utxo_funding;
|
||||||
|
|
@ -150,18 +150,15 @@ mod tests {
|
||||||
fn test_helper_math() {
|
fn test_helper_math() {
|
||||||
// pow10
|
// pow10
|
||||||
assert_eq!(pow10_dec(0), Decimal::ONE);
|
assert_eq!(pow10_dec(0), Decimal::ONE);
|
||||||
assert_eq!(pow10_dec(2), Decimal::from_i32(100).unwrap());
|
assert_eq!(pow10_dec(2), dec!(100));
|
||||||
|
|
||||||
// price_from_tvl
|
// price_from_tvl
|
||||||
assert_eq!(
|
assert_eq!(price_from_tvl(100, 10).unwrap(), dec!(10));
|
||||||
price_from_tvl(100, 10).unwrap(),
|
|
||||||
Decimal::from_i32(10).unwrap()
|
|
||||||
);
|
|
||||||
assert!(price_from_tvl(100, 0).is_none());
|
assert!(price_from_tvl(100, 0).is_none());
|
||||||
|
|
||||||
// pct_change (Decimal)
|
// pct_change (Decimal)
|
||||||
let a = Decimal::from_i32(110).unwrap();
|
let a = dec!(110);
|
||||||
let b = Decimal::from_i32(100).unwrap();
|
let b = dec!(100);
|
||||||
assert_eq!(pct_change_bp_dec(a, b), 1000);
|
assert_eq!(pct_change_bp_dec(a, b), 1000);
|
||||||
|
|
||||||
// compute_score
|
// compute_score
|
||||||
|
|
@ -1003,4 +1000,257 @@ mod tests {
|
||||||
.unwrap();
|
.unwrap();
|
||||||
assert!(cached_ts.is_none());
|
assert!(cached_ts.is_none());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn fast_updater_handles_extreme_decimals_without_panicking() {
|
||||||
|
// Arrange: fresh DBs/tables
|
||||||
|
let mock = mock_db_pool(|conn| setup_basic_schemas(conn));
|
||||||
|
let cw = mock.cauldron_w.get().expect("cauldron_w");
|
||||||
|
let bcmr_w = mock.bcmr_w.get().expect("bcmr_w");
|
||||||
|
let bcmr_r = mock.bcmr_r.get().expect("bcmr_r");
|
||||||
|
let crc_r = mock.crc20_r.get().expect("crc20_r");
|
||||||
|
let orc_r = mock.oracle_r.get().expect("oracle_r");
|
||||||
|
|
||||||
|
// Seed BCMR with absurd decimals (should trigger overflow pre-patch)
|
||||||
|
let token = TokenID::from_inner([0xDE; 32]);
|
||||||
|
let token_hex = token.to_hex();
|
||||||
|
let utxo = OutPointHash::from_inner([0xEE; 32]);
|
||||||
|
let txid = Txid::from_inner([0xEF; 32]);
|
||||||
|
|
||||||
|
let row = BCMRRow {
|
||||||
|
name: "OverflowCoin".into(),
|
||||||
|
description: "trigger pow10 overflow".into(),
|
||||||
|
token: BCMRToken {
|
||||||
|
category: "cat".into(),
|
||||||
|
symbol: "OF".into(),
|
||||||
|
decimals: 30, // <-- extreme
|
||||||
|
},
|
||||||
|
uris: Uris {
|
||||||
|
icon: None,
|
||||||
|
web: None,
|
||||||
|
},
|
||||||
|
filemeta: FileMeta {
|
||||||
|
expected_hash: Some("x".into()),
|
||||||
|
actual_hash: Some("y".into()),
|
||||||
|
source: "test".into(),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
insert_bcmr_data(&bcmr_w, &utxo, &row).unwrap();
|
||||||
|
insert_authheader(
|
||||||
|
&bcmr_w,
|
||||||
|
&utxo,
|
||||||
|
&BlockHash::all_zeros(),
|
||||||
|
&txid,
|
||||||
|
&token,
|
||||||
|
100,
|
||||||
|
Some(Vec::from("bcmr".as_bytes())),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
// Sanity: ensure resolve_decimals sees 30 via the same code path the updater uses
|
||||||
|
let dec = resolve_decimals(&bcmr_r, &crc_r, &token_hex);
|
||||||
|
assert_eq!(
|
||||||
|
dec, 30,
|
||||||
|
"resolve_decimals must read the extreme value for this test"
|
||||||
|
);
|
||||||
|
|
||||||
|
// Ensure TVL so the updater scales price by 10^decimals
|
||||||
|
let now = crate::timeutil::time_now();
|
||||||
|
super::tests::seed_minimal_token_history(
|
||||||
|
&cw,
|
||||||
|
token,
|
||||||
|
now - 120,
|
||||||
|
now - 60,
|
||||||
|
1_000,
|
||||||
|
500,
|
||||||
|
2_000,
|
||||||
|
1_000,
|
||||||
|
);
|
||||||
|
|
||||||
|
update_tvl_and_price_now(&cw, &bcmr_r, &crc_r, &orc_r)
|
||||||
|
.expect("fast updater should not panic on extreme decimals");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn pow10_dec_clamps_and_does_not_overflow() {
|
||||||
|
use rust_decimal_macros::dec;
|
||||||
|
|
||||||
|
// Exact 10^28 (1 followed by 28 zeros)
|
||||||
|
let e28 = crate::db::cauldron::tokenlist::token_utils::pow10_dec(28);
|
||||||
|
let e28_expected = dec!(10000000000000000000000000000); // ← 29 digits total
|
||||||
|
assert_eq!(e28, e28_expected);
|
||||||
|
|
||||||
|
// Asking for 30 should clamp to 28 and not panic
|
||||||
|
let e30 = crate::db::cauldron::tokenlist::token_utils::pow10_dec(30);
|
||||||
|
assert_eq!(e30, e28_expected);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn core_overflow_paths_set_prices_null_not_panic() {
|
||||||
|
let mock = mock_db_pool(|conn| setup_basic_schemas(conn));
|
||||||
|
let cw = mock.cauldron_w.get().unwrap();
|
||||||
|
let bcmr_w = mock.bcmr_w.get().unwrap();
|
||||||
|
let bcmr_r = mock.bcmr_r.get().unwrap();
|
||||||
|
let crc_r = mock.crc20_r.get().unwrap();
|
||||||
|
let orc_r = mock.oracle_r.get().unwrap();
|
||||||
|
|
||||||
|
let token = TokenID::from_inner([0xEE; 32]);
|
||||||
|
let utxo = OutPointHash::from_inner([0xCD; 32]);
|
||||||
|
let txid = Txid::from_inner([0xAB; 32]);
|
||||||
|
|
||||||
|
// decimals = 28 OK, but we’ll make price huge by tiny tokens
|
||||||
|
insert_bcmr_data(
|
||||||
|
&bcmr_w,
|
||||||
|
&utxo,
|
||||||
|
&BCMRRow {
|
||||||
|
name: "HugePrice".into(),
|
||||||
|
description: "".into(),
|
||||||
|
token: BCMRToken {
|
||||||
|
category: "c".into(),
|
||||||
|
symbol: "HP".into(),
|
||||||
|
decimals: 28,
|
||||||
|
},
|
||||||
|
uris: Uris {
|
||||||
|
icon: None,
|
||||||
|
web: None,
|
||||||
|
},
|
||||||
|
filemeta: FileMeta {
|
||||||
|
expected_hash: Some("x".into()),
|
||||||
|
actual_hash: Some("y".into()),
|
||||||
|
source: "test".into(),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
insert_authheader(
|
||||||
|
&bcmr_w,
|
||||||
|
&utxo,
|
||||||
|
&BlockHash::all_zeros(),
|
||||||
|
&txid,
|
||||||
|
&token,
|
||||||
|
100,
|
||||||
|
Some(b"bcmr".to_vec()),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
// seed: huge sats, tiny tokens (1) to push price_now_dec very large
|
||||||
|
let now = crate::timeutil::time_now();
|
||||||
|
super::tests::seed_minimal_token_history(
|
||||||
|
&cw,
|
||||||
|
token,
|
||||||
|
now - 120,
|
||||||
|
now - 60,
|
||||||
|
9_000_000_000_000_000,
|
||||||
|
1,
|
||||||
|
9_000_000_000_000_000,
|
||||||
|
1,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Create presence in cache
|
||||||
|
cw.execute("INSERT INTO cached_token_metrics(token_id, trade_volume, tvl_sats, tvl_tokens, score, updated_at)
|
||||||
|
VALUES(?1,0,1,1,0,strftime('%s','now'))
|
||||||
|
ON CONFLICT(token_id) DO NOTHING", rusqlite::params![token.to_hex()]).unwrap();
|
||||||
|
|
||||||
|
// Should not panic
|
||||||
|
update_changes_score_volume_and_ranking(&cw, &bcmr_r, &crc_r, &orc_r).unwrap();
|
||||||
|
|
||||||
|
// Prices likely NULL after overflow guard
|
||||||
|
let (p_now_usd, p_24h, p_7d): (Option<f64>, Option<f64>, Option<f64>) =
|
||||||
|
cw.query_row("SELECT price_now_usd, price_24h, price_7d FROM cached_token_metrics WHERE token_id=?1",
|
||||||
|
rusqlite::params![token.to_hex()],
|
||||||
|
|r| Ok((r.get(0)?, r.get(1)?, r.get(2)?))).unwrap();
|
||||||
|
assert!(p_now_usd.is_none() || p_now_usd.unwrap().is_finite());
|
||||||
|
// allow None here; the point is: no panic
|
||||||
|
let _ = (p_24h, p_7d);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn pct_change_bp_handles_huge_ratio_without_panic() {
|
||||||
|
use crate::db::cauldron::tokenlist::token_utils::{pct_change_bp_dec, pow10_dec};
|
||||||
|
use rust_decimal::Decimal;
|
||||||
|
|
||||||
|
// Arrange: absurd values that would overflow in current code
|
||||||
|
let current: Decimal = pow10_dec(28); // ~1e28
|
||||||
|
let past: Decimal = Decimal::ONE / pow10_dec(18); // ~1e-18
|
||||||
|
|
||||||
|
// Act: call pct_change_bp_dec
|
||||||
|
let result = std::panic::catch_unwind(|| pct_change_bp_dec(current, past));
|
||||||
|
|
||||||
|
// Assert: should NOT panic once patched
|
||||||
|
assert!(
|
||||||
|
result.is_ok(),
|
||||||
|
"pct_change_bp_dec panicked on huge ratio (needs overflow guard)"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn overflow_fallback_uses_f64_and_does_not_panic() {
|
||||||
|
// simulate absurdly high decimals that would overflow Decimal multiplication
|
||||||
|
let base = Decimal::new(123456789, 0); // 123456789
|
||||||
|
let decimals: u32 = 1000;
|
||||||
|
|
||||||
|
let price_now_human = overflow_f64_fallback(base, decimals);
|
||||||
|
|
||||||
|
assert!(price_now_human.is_finite() || price_now_human.is_infinite());
|
||||||
|
}
|
||||||
|
#[test]
|
||||||
|
fn price_from_tvl_handles_u64_extremes_without_overflow() {
|
||||||
|
use std::u64;
|
||||||
|
|
||||||
|
// Max sats, 1 token → result should be exactly MAX as Decimal
|
||||||
|
let p = price_from_tvl(u64::MAX, 1).expect("not None");
|
||||||
|
assert_eq!(p, Decimal::from_u128(u64::MAX as u128).unwrap());
|
||||||
|
|
||||||
|
// Symmetric max → 1
|
||||||
|
let p = price_from_tvl(u64::MAX, u64::MAX).expect("not None");
|
||||||
|
assert_eq!(p, Decimal::ONE);
|
||||||
|
|
||||||
|
// Max sats, 2 tokens → roughly MAX/2
|
||||||
|
let p = price_from_tvl(u64::MAX, 2).expect("not None");
|
||||||
|
// Use a tolerant comparison because Decimal division can normalize scale
|
||||||
|
let half = Decimal::from_u128((u64::MAX as u128) / 2).unwrap();
|
||||||
|
// Allow a difference of 1 ulp at integer scale if needed
|
||||||
|
assert!(p >= half && p <= half + Decimal::ONE);
|
||||||
|
|
||||||
|
// Tiny sats, huge tokens → small decimal > 0
|
||||||
|
let p = price_from_tvl(1, u64::MAX).expect("not None");
|
||||||
|
assert!(p > Decimal::ZERO && p < Decimal::ONE);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn price_from_tvl_zero_tokens_is_none() {
|
||||||
|
assert!(price_from_tvl(0, 0).is_none());
|
||||||
|
assert!(price_from_tvl(100, 0).is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn price_from_tvl_no_u64_intermediate_multiply() {
|
||||||
|
// This test is about behavior: if an accidental u64 multiply had crept in,
|
||||||
|
// some extreme combos would panic or wrap. We assert it doesn't.
|
||||||
|
let cases = [
|
||||||
|
(std::u64::MAX, 1u64),
|
||||||
|
(std::u64::MAX, std::u64::MAX),
|
||||||
|
(1u64, std::u64::MAX),
|
||||||
|
(9_000_000_000_000_000u64, 1u64),
|
||||||
|
];
|
||||||
|
for (sats, toks) in cases {
|
||||||
|
let _ = price_from_tvl(sats, toks); // should not panic
|
||||||
|
}
|
||||||
|
}
|
||||||
|
#[test]
|
||||||
|
fn scaling_price_never_requires_integer_multiply() {
|
||||||
|
// Simulate a very large ratio, then scale by decimals via Decimal first
|
||||||
|
let p = price_from_tvl(std::u64::MAX, 1).unwrap(); // huge base
|
||||||
|
// Use your clamped pow10_dec; should not panic
|
||||||
|
let factor = crate::db::cauldron::tokenlist::token_utils::pow10_dec(28);
|
||||||
|
let scaled = p.checked_mul(factor).unwrap_or_else(|| Decimal::ZERO);
|
||||||
|
// Convert to f64 bounded; must be finite or clamped per your helper
|
||||||
|
let f = crate::db::cauldron::tokenlist::token_utils::dec_to_f64_bounded(scaled);
|
||||||
|
assert!(f.is_finite() || f.abs() == f64::MAX);
|
||||||
|
}
|
||||||
|
#[test]
|
||||||
|
fn compute_score_never_overflows() {
|
||||||
|
let s = compute_score(u64::MAX, u64::MAX);
|
||||||
|
assert!(s >= 0); // and, importantly, no panic occurred
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,7 @@
|
||||||
// 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 anyhow::{anyhow, Result};
|
use anyhow::Result;
|
||||||
use log::warn;
|
use log::warn;
|
||||||
use rusqlite::Connection;
|
use rusqlite::Connection;
|
||||||
|
|
||||||
|
|
@ -11,9 +11,45 @@ use crate::db::cauldron::pool::get_pool_period_snapshot;
|
||||||
use crate::db::oracle::get_closest;
|
use crate::db::oracle::get_closest;
|
||||||
use crate::rpc::apy::apyaggregator::APYAggregator;
|
use crate::rpc::apy::apyaggregator::APYAggregator;
|
||||||
use crate::rpc::apy::poolperiod::PoolPeriod;
|
use crate::rpc::apy::poolperiod::PoolPeriod;
|
||||||
|
use malachite::base::num::arithmetic::traits::FloorSqrt;
|
||||||
|
use malachite::Integer;
|
||||||
|
|
||||||
use rust_decimal::prelude::*;
|
use rust_decimal::prelude::*;
|
||||||
use rust_decimal::Decimal;
|
use rust_decimal::Decimal;
|
||||||
|
use rust_decimal_macros::dec;
|
||||||
|
|
||||||
|
/// Fallback path when Decimal scaling overflows: convert to f64 with clamped exponent.
|
||||||
|
pub fn overflow_f64_fallback(base: Decimal, decimals: u32) -> f64 {
|
||||||
|
let base_f = dec_to_f64_bounded(base);
|
||||||
|
let exp = (decimals as i32).clamp(0, 308); // max safe for f64::powi
|
||||||
|
let factor_f = 10f64.powi(exp);
|
||||||
|
let p = base_f * factor_f;
|
||||||
|
if p.is_finite() {
|
||||||
|
p
|
||||||
|
} else {
|
||||||
|
f64::MAX.copysign(p)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Round to a reasonable scale before conversion (tunable).
|
||||||
|
pub fn dec_round(d: Decimal, scale: u32) -> Decimal {
|
||||||
|
d.round_dp_with_strategy(scale, rust_decimal::RoundingStrategy::MidpointNearestEven)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Convert Decimal -> f64, guaranteed finite (never NaN/∞). Clamps if needed.
|
||||||
|
/// Optional: enforce a floor so tiny non-zero values don't collapse to 0.
|
||||||
|
pub fn dec_to_f64_bounded(d: Decimal) -> f64 {
|
||||||
|
if let Some(v) = d.to_f64() {
|
||||||
|
if v.is_finite() {
|
||||||
|
return v;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if d.is_sign_negative() {
|
||||||
|
-f64::MAX
|
||||||
|
} else {
|
||||||
|
f64::MAX
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
pub const SATS_PER_BCH: i64 = 100_000_000;
|
pub const SATS_PER_BCH: i64 = 100_000_000;
|
||||||
pub const ORACLE_SCALE: i64 = 1_000_000; // match the delphi scale
|
pub const ORACLE_SCALE: i64 = 1_000_000; // match the delphi scale
|
||||||
|
|
@ -31,31 +67,62 @@ pub fn usd_per_bch_at_or_before(conn: &Connection, ts: i64) -> Decimal {
|
||||||
#[inline]
|
#[inline]
|
||||||
pub fn pct_change_bp_dec(current: Decimal, past: Decimal) -> i64 {
|
pub fn pct_change_bp_dec(current: Decimal, past: Decimal) -> i64 {
|
||||||
if past.is_zero() {
|
if past.is_zero() {
|
||||||
0
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
let tenk = dec!(10000);
|
||||||
|
|
||||||
|
// Compute: ((current / past) - 1) * 10_000 in a safer order:
|
||||||
|
// => (current * 10_000 / past) - 10_000
|
||||||
|
let scaled_current = match current.checked_mul(tenk) {
|
||||||
|
Some(v) => v,
|
||||||
|
None => return if current >= past { i64::MAX } else { i64::MIN },
|
||||||
|
};
|
||||||
|
|
||||||
|
let ratio_bp = match scaled_current.checked_div(past) {
|
||||||
|
Some(v) => v,
|
||||||
|
None => return 0, // should not happen (past==0 handled), but be defensive
|
||||||
|
};
|
||||||
|
|
||||||
|
let delta_bp = match ratio_bp.checked_sub(tenk) {
|
||||||
|
Some(v) => v,
|
||||||
|
None => return if current >= past { i64::MAX } else { i64::MIN },
|
||||||
|
};
|
||||||
|
|
||||||
|
// Round and convert; clamp if it wouldn't fit in i64
|
||||||
|
match delta_bp.round().to_i64() {
|
||||||
|
Some(v) => v,
|
||||||
|
None => {
|
||||||
|
if delta_bp.is_sign_negative() {
|
||||||
|
i64::MIN
|
||||||
} else {
|
} else {
|
||||||
((current - past) / past * Decimal::from_i32(10_000).unwrap())
|
i64::MAX
|
||||||
.round()
|
}
|
||||||
.to_i64()
|
}
|
||||||
.unwrap_or(0)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[inline]
|
#[inline]
|
||||||
pub fn compute_score(tvl_sats: u64, volume: u64) -> i64 {
|
pub fn compute_score(tvl_sats: u64, vol_30d: u64) -> i64 {
|
||||||
if tvl_sats == 0 || volume == 0 {
|
if tvl_sats == 0 || vol_30d == 0 {
|
||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
let s = (volume as f64) * (tvl_sats as f64).sqrt(); // same as BigNumber sqrt + pow(2) pattern
|
|
||||||
if !s.is_finite() {
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
if s >= i64::MAX as f64 {
|
|
||||||
i64::MAX
|
|
||||||
} else {
|
|
||||||
s.round() as i64
|
|
||||||
} // ROUND_HALF_UP for positives
|
|
||||||
}
|
|
||||||
|
|
||||||
|
// score = vol_30d * floor_sqrt(tvl_sats), all in big-int to avoid overflow
|
||||||
|
let sqrt_tvl: Integer = Integer::from(tvl_sats).floor_sqrt();
|
||||||
|
let score_big: Integer = Integer::from(vol_30d) * sqrt_tvl;
|
||||||
|
|
||||||
|
// Clamp to i64 range (non-negative by construction)
|
||||||
|
if score_big > i64::MAX {
|
||||||
|
return i64::MAX;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Convert safely; fall back to MAX on unexpected parse failure
|
||||||
|
match score_big.to_string().parse::<i64>() {
|
||||||
|
Ok(v) => v,
|
||||||
|
Err(_) => i64::MAX,
|
||||||
|
}
|
||||||
|
}
|
||||||
pub fn resolve_decimals(bcmr_conn: &Connection, crc20_conn: &Connection, token_id: &str) -> u32 {
|
pub fn resolve_decimals(bcmr_conn: &Connection, crc20_conn: &Connection, token_id: &str) -> u32 {
|
||||||
// On-chain BCMR (latest by height)
|
// On-chain BCMR (latest by height)
|
||||||
if let Ok(Some(v)) = bcmr_conn.query_row(
|
if let Ok(Some(v)) = bcmr_conn.query_row(
|
||||||
|
|
@ -105,10 +172,25 @@ pub fn resolve_decimals(bcmr_conn: &Connection, crc20_conn: &Connection, token_i
|
||||||
0
|
0
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Max safe exponent for 10^d that fits in `rust_decimal` (1e28 fits; 1e29 does not).
|
||||||
|
pub const MAX_DECIMALS_FOR_SCALING: u32 = 28;
|
||||||
|
|
||||||
|
/// Clamp a metadata decimals value to something rust_decimal can represent.
|
||||||
|
#[inline]
|
||||||
|
pub fn clamp_decimals(decimals: u32) -> u32 {
|
||||||
|
if decimals > MAX_DECIMALS_FOR_SCALING {
|
||||||
|
warn!(
|
||||||
|
"Clamping decimals {} -> {} to avoid overflow in pow10_dec()",
|
||||||
|
decimals, MAX_DECIMALS_FOR_SCALING
|
||||||
|
);
|
||||||
|
}
|
||||||
|
decimals.min(MAX_DECIMALS_FOR_SCALING)
|
||||||
|
}
|
||||||
|
|
||||||
#[inline]
|
#[inline]
|
||||||
pub fn pow10_dec(decimals: u32) -> Decimal {
|
pub fn pow10_dec(decimals: u32) -> Decimal {
|
||||||
// rust_decimal supports integer powers
|
// After clamping, powu is safe and exact.
|
||||||
Decimal::from_i32(10).unwrap().powu(decimals as u64)
|
dec!(10).powu(clamp_decimals(decimals) as u64)
|
||||||
}
|
}
|
||||||
|
|
||||||
#[inline]
|
#[inline]
|
||||||
|
|
@ -151,11 +233,22 @@ pub fn apy_30d_bp_for_token(conn: &Connection, token_id: &str, now: i64) -> Resu
|
||||||
e
|
e
|
||||||
})?;
|
})?;
|
||||||
|
|
||||||
// Percent → basis points (100 bp = 1%). If it won’t fit into i64, surface an error.
|
// Percent → basis points (100 bp = 1%)
|
||||||
let bp_dec = (apy_dec * Decimal::from_i32(100).unwrap()).round();
|
let hundred = dec!(100);
|
||||||
let apy_bp = bp_dec
|
|
||||||
.to_i64()
|
let bp_dec = match apy_dec.checked_mul(hundred) {
|
||||||
.ok_or_else(|| anyhow!("APY bp overflow for {}", token_id))?;
|
Some(v) => v.round(),
|
||||||
|
None => {
|
||||||
|
// clamp on overflow instead of panicking
|
||||||
|
return Ok(i64::MAX);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let apy_bp = match bp_dec.to_i64() {
|
||||||
|
Some(v) => v,
|
||||||
|
None if bp_dec.is_sign_negative() => i64::MIN,
|
||||||
|
None => i64::MAX,
|
||||||
|
};
|
||||||
Ok(apy_bp)
|
Ok(apy_bp)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
35
src/main.rs
35
src/main.rs
|
|
@ -15,7 +15,7 @@ use rocket::{launch, routes};
|
||||||
use rocket_cors::{AllowedHeaders, AllowedOrigins};
|
use rocket_cors::{AllowedHeaders, AllowedOrigins};
|
||||||
use rpc::ResponseCache;
|
use rpc::ResponseCache;
|
||||||
use rusqlite::OpenFlags;
|
use rusqlite::OpenFlags;
|
||||||
use std::sync::atomic::AtomicBool;
|
use std::sync::atomic::{AtomicBool, Ordering};
|
||||||
use std::{
|
use std::{
|
||||||
backtrace::Backtrace,
|
backtrace::Backtrace,
|
||||||
collections::HashMap,
|
collections::HashMap,
|
||||||
|
|
@ -23,7 +23,7 @@ use std::{
|
||||||
path::Path,
|
path::Path,
|
||||||
process,
|
process,
|
||||||
sync::{Arc, Mutex},
|
sync::{Arc, Mutex},
|
||||||
thread::{self, sleep},
|
thread::sleep,
|
||||||
time::{Duration, Instant},
|
time::{Duration, Instant},
|
||||||
};
|
};
|
||||||
use stderrlog::LogLevelNum;
|
use stderrlog::LogLevelNum;
|
||||||
|
|
@ -136,7 +136,6 @@ fn start_program(
|
||||||
) -> Result<(DB, BCMRDownloader, WellKnownDownloader, CRC20Fetcher)> {
|
) -> Result<(DB, BCMRDownloader, WellKnownDownloader, CRC20Fetcher)> {
|
||||||
let create_db_pool = |db_path| -> (bool, DBPool, DBPool) {
|
let create_db_pool = |db_path| -> (bool, DBPool, DBPool) {
|
||||||
let db_exists = Path::new(db_path).exists();
|
let db_exists = Path::new(db_path).exists();
|
||||||
|
|
||||||
info!("Initializing connection to {db_path}");
|
info!("Initializing connection to {db_path}");
|
||||||
|
|
||||||
let write_manager = r2d2_sqlite::SqliteConnectionManager::file(db_path)
|
let write_manager = r2d2_sqlite::SqliteConnectionManager::file(db_path)
|
||||||
|
|
@ -258,12 +257,12 @@ fn start_program(
|
||||||
|
|
||||||
let indexing_in_progress_clone = indexing_in_progress.clone();
|
let indexing_in_progress_clone = indexing_in_progress.clone();
|
||||||
|
|
||||||
thread::spawn(move || {
|
std::thread::spawn(move || {
|
||||||
let db = db_cpy;
|
let db = db_cpy;
|
||||||
|
|
||||||
|
// Initial full index
|
||||||
let mut tip: BlockHash = loop {
|
let mut tip: BlockHash = loop {
|
||||||
indexing_in_progress_clone.store(true, std::sync::atomic::Ordering::Relaxed);
|
indexing_in_progress_clone.store(true, Ordering::Relaxed);
|
||||||
|
|
||||||
break match index_blocks(chain.clone(), db.clone(), client.clone(), true) {
|
break match index_blocks(chain.clone(), db.clone(), client.clone(), true) {
|
||||||
Ok(tip) => tip,
|
Ok(tip) => tip,
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
|
|
@ -275,22 +274,21 @@ fn start_program(
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
indexing_in_progress_clone.store(false, Ordering::Relaxed);
|
||||||
|
|
||||||
indexing_in_progress_clone.store(false, std::sync::atomic::Ordering::Relaxed);
|
// Follow chain
|
||||||
|
|
||||||
loop {
|
loop {
|
||||||
let new_tip = match electrum_get_tip(&client.lock().unwrap()) {
|
let new_tip = match electrum_get_tip(&client.lock().unwrap()) {
|
||||||
Ok(t) => t.0.block_hash(),
|
Ok(t) => t.0.block_hash(),
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
warn!("Failed to get block chain tip from electrum: {e}");
|
warn!("Failed to get chain tip from electrum: {e}");
|
||||||
thread::sleep(Duration::from_secs(5));
|
std::thread::sleep(Duration::from_secs(5));
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
if new_tip != tip {
|
if new_tip != tip {
|
||||||
indexing_in_progress_clone.store(true, std::sync::atomic::Ordering::Relaxed);
|
indexing_in_progress_clone.store(true, Ordering::Relaxed);
|
||||||
|
|
||||||
tip = match index_blocks(chain.clone(), db.clone(), client.clone(), true) {
|
tip = match index_blocks(chain.clone(), db.clone(), client.clone(), true) {
|
||||||
Ok(t) => t,
|
Ok(t) => t,
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
|
|
@ -298,15 +296,18 @@ fn start_program(
|
||||||
tip
|
tip
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
indexing_in_progress_clone.store(false, Ordering::Relaxed);
|
||||||
indexing_in_progress_clone.store(false, std::sync::atomic::Ordering::Relaxed);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Avoid overlapping writer while indexer is on
|
||||||
|
if !indexing_in_progress_clone.load(Ordering::Relaxed) {
|
||||||
if let Err(e) =
|
if let Err(e) =
|
||||||
update_mempool(db.cauldron_w.clone(), db.oracle_w.clone(), client.clone())
|
update_mempool(db.cauldron_w.clone(), db.oracle_w.clone(), client.clone())
|
||||||
{
|
{
|
||||||
error!("Failed to update mempool: {e}");
|
error!("Failed to update mempool: {e}");
|
||||||
}
|
}
|
||||||
thread::sleep(Duration::from_secs(5));
|
}
|
||||||
|
std::thread::sleep(Duration::from_secs(5));
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -316,6 +317,8 @@ fn start_program(
|
||||||
let mut wellknowndownloader = WellKnownDownloader::new(db.bcmr_w.clone());
|
let mut wellknowndownloader = WellKnownDownloader::new(db.bcmr_w.clone());
|
||||||
wellknowndownloader.start()?;
|
wellknowndownloader.start()?;
|
||||||
|
|
||||||
|
spawn_token_metrics_updater(db.clone(), indexing_in_progress.clone());
|
||||||
|
|
||||||
Ok((db, bcmrdownloader, wellknowndownloader, crc20fetcher))
|
Ok((db, bcmrdownloader, wellknowndownloader, crc20fetcher))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -364,8 +367,6 @@ fn launch() -> _ {
|
||||||
create_cached_token_metrics_table(&conn).expect("ensure cached_token_metrics exists");
|
create_cached_token_metrics_table(&conn).expect("ensure cached_token_metrics exists");
|
||||||
}
|
}
|
||||||
|
|
||||||
spawn_token_metrics_updater(dbpool.clone());
|
|
||||||
|
|
||||||
rocket::build()
|
rocket::build()
|
||||||
.manage(dbpool)
|
.manage(dbpool)
|
||||||
.manage(response_cache)
|
.manage(response_cache)
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,9 @@
|
||||||
// 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 anyhow::{bail, Context, Result};
|
use anyhow::{anyhow, bail, Context, Result};
|
||||||
|
use malachite::base::num::arithmetic::traits::FloorSqrt;
|
||||||
|
use malachite::Integer;
|
||||||
use rust_decimal::MathematicalOps;
|
use rust_decimal::MathematicalOps;
|
||||||
use rust_decimal::{prelude::FromPrimitive, Decimal};
|
use rust_decimal::{prelude::FromPrimitive, Decimal};
|
||||||
use rust_decimal_macros::dec;
|
use rust_decimal_macros::dec;
|
||||||
|
|
@ -18,8 +20,8 @@ pub struct PoolPeriod {
|
||||||
pub start: PoolSnapshot,
|
pub start: PoolSnapshot,
|
||||||
pub end: PoolSnapshot,
|
pub end: PoolSnapshot,
|
||||||
|
|
||||||
start_k: Decimal,
|
start_k: Integer,
|
||||||
end_k: Decimal,
|
end_k: Integer,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl PoolPeriod {
|
impl PoolPeriod {
|
||||||
|
|
@ -29,14 +31,12 @@ impl PoolPeriod {
|
||||||
"start timestamp ({}) > end timestamp ({})",
|
"start timestamp ({}) > end timestamp ({})",
|
||||||
start.timestamp,
|
start.timestamp,
|
||||||
end.timestamp
|
end.timestamp
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
let start_k = Decimal::from_u64(start.sats * start.token_amount)
|
// Multiply with big-int to avoid u64 overflow, then downcast to Decimal.
|
||||||
.context("failed to convert inital_k to decimal")?;
|
let start_k = Integer::from(start.sats) * Integer::from(start.token_amount);
|
||||||
|
let end_k = Integer::from(end.sats) * Integer::from(end.token_amount);
|
||||||
let end_k = Decimal::from_u64(end.sats * end.token_amount)
|
|
||||||
.context("failed to convert final_k to decimal")?;
|
|
||||||
|
|
||||||
Ok(Self {
|
Ok(Self {
|
||||||
start,
|
start,
|
||||||
|
|
@ -72,40 +72,79 @@ impl PoolPeriod {
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn days_over_year(&self, starting: &Option<u64>) -> Result<Decimal> {
|
pub fn days_over_year(&self, starting: &Option<u64>) -> Result<Decimal> {
|
||||||
|
// If duration_days is 0, checked_div returns None → default to 0
|
||||||
Ok(DAYS_IN_YEAR
|
Ok(DAYS_IN_YEAR
|
||||||
.checked_div(self.duration_days(starting)?)
|
.checked_div(self.duration_days(starting)?)
|
||||||
.unwrap_or_default())
|
.unwrap_or_default())
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn end_k_sqrt(&self) -> Result<Decimal> {
|
fn sqrt_integer_as_decimal(k: &Integer) -> anyhow::Result<Decimal> {
|
||||||
self.end_k.sqrt().context("failed to sqrt end")
|
use std::str::FromStr;
|
||||||
|
let s: Integer = k.clone().floor_sqrt();
|
||||||
|
if s == 0u8 {
|
||||||
|
return Ok(Decimal::ZERO);
|
||||||
|
}
|
||||||
|
let r: Integer = k - &(&s * &s); // r = k - s^2 (exact)
|
||||||
|
let s_dec = Decimal::from_str(&s.to_string())?;
|
||||||
|
let r_dec = Decimal::from_str(&r.to_string())?;
|
||||||
|
// sqrt(k) ≈ s + r/(2s) (first-order correction)
|
||||||
|
let corr = r_dec
|
||||||
|
.checked_div(s_dec * dec!(2))
|
||||||
|
.ok_or_else(|| anyhow::anyhow!("div"))?;
|
||||||
|
s_dec
|
||||||
|
.checked_add(corr)
|
||||||
|
.ok_or_else(|| anyhow::anyhow!("add"))
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn pool_yield(&self) -> Result<Decimal> {
|
pub fn end_k_sqrt(&self) -> anyhow::Result<Decimal> {
|
||||||
let start_k_sr = self.start_k.sqrt().context("failed to sqrt start")?;
|
Self::sqrt_integer_as_decimal(&self.end_k)
|
||||||
let end_k_sr = self.end_k.sqrt().context("failed to sqrt end")?;
|
}
|
||||||
|
|
||||||
Ok(((end_k_sr - start_k_sr) / start_k_sr) * Decimal::ONE_HUNDRED)
|
pub fn pool_yield(&self) -> anyhow::Result<Decimal> {
|
||||||
|
let start_k_sr = Self::sqrt_integer_as_decimal(&self.start_k)?;
|
||||||
|
let end_k_sr = Self::sqrt_integer_as_decimal(&self.end_k)?;
|
||||||
|
if start_k_sr.is_zero() {
|
||||||
|
anyhow::bail!("start sqrt is zero; division by zero");
|
||||||
|
}
|
||||||
|
let num = end_k_sr
|
||||||
|
.checked_sub(start_k_sr)
|
||||||
|
.ok_or_else(|| anyhow::anyhow!("sub"))?;
|
||||||
|
let ratio = num
|
||||||
|
.checked_div(start_k_sr)
|
||||||
|
.ok_or_else(|| anyhow::anyhow!("div"))?;
|
||||||
|
ratio
|
||||||
|
.checked_mul(Decimal::ONE_HUNDRED)
|
||||||
|
.ok_or_else(|| anyhow::anyhow!("mul"))
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn yield_and_apy(&self, starting: &Option<u64>) -> Result<(Decimal, Decimal)> {
|
pub fn yield_and_apy(&self, starting: &Option<u64>) -> Result<(Decimal, Decimal)> {
|
||||||
let pool_yield = self.pool_yield()?;
|
let pool_yield = self.pool_yield()?;
|
||||||
|
let years_elapsed = self.days_over_year(starting)?; // > 0 means at least some duration
|
||||||
|
|
||||||
let years_elapsed = self.days_over_year(starting)?;
|
// avoid powd blowups for very short periods (< 6h)
|
||||||
|
if self.duration(starting) < 6 * 3600 {
|
||||||
// to avoid powd overflow; don't calculate for pools < 6 hour old
|
|
||||||
if self.duration(starting) < 3600 * 6 {
|
|
||||||
return Ok((pool_yield, Decimal::ZERO));
|
return Ok((pool_yield, Decimal::ZERO));
|
||||||
}
|
}
|
||||||
|
|
||||||
let apy = if years_elapsed.is_zero() {
|
let apy = if years_elapsed.is_zero() {
|
||||||
Decimal::ZERO
|
Decimal::ZERO
|
||||||
} else {
|
} else {
|
||||||
(((pool_yield / Decimal::ONE_HUNDRED) + Decimal::ONE)
|
// APY = ((1 + y)^years - 1) * 100, with overflow guards
|
||||||
|
let one_plus = (pool_yield
|
||||||
|
.checked_div(Decimal::ONE_HUNDRED)
|
||||||
|
.ok_or_else(|| anyhow!("divide by 100 overflow"))?)
|
||||||
|
.checked_add(Decimal::ONE)
|
||||||
|
.ok_or_else(|| anyhow!("1 + yield overflow"))?;
|
||||||
|
|
||||||
|
let powd = one_plus
|
||||||
.checked_powd(years_elapsed)
|
.checked_powd(years_elapsed)
|
||||||
.context("powd overflow")?
|
.context("powd overflow")?;
|
||||||
- Decimal::ONE)
|
let minus_one = powd
|
||||||
* Decimal::ONE_HUNDRED
|
.checked_sub(Decimal::ONE)
|
||||||
|
.ok_or_else(|| anyhow!("pow - 1 underflow"))?;
|
||||||
|
minus_one
|
||||||
|
.checked_mul(Decimal::ONE_HUNDRED)
|
||||||
|
.ok_or_else(|| anyhow!("apy * 100 overflow"))?
|
||||||
};
|
};
|
||||||
|
|
||||||
Ok((pool_yield, apy))
|
Ok((pool_yield, apy))
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue