riftenlabs-indexer/src/db/cauldron/tokenlist/token_utils.rs

432 lines
12 KiB
Rust

// 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::Result;
use bitcoincash::TokenID;
use log::warn;
use sqlx::SqlitePool;
use crate::db::blob::display_hex_to_blob;
use crate::db::cauldron::pool::{get_injections_between, get_pool_period_snapshot};
use crate::db::cauldron::tokenlist::db_utils::db_first_pool_creation_row;
use crate::db::oracle::get_closest;
use crate::rpc::apy::apyaggregator::APYAggregator;
use crate::rpc::apy::poolperiod::split_at_injections;
use crate::rpc::price::price_at_or_before_2;
use malachite::base::num::arithmetic::traits::FloorSqrt;
use malachite::Integer;
use rust_decimal::prelude::*;
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);
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;
/// Oracle prices are stored in cents (38424 = $384.24). Divide by 100 to get USD.
pub const ORACLE_SCALE: i64 = 100;
pub async fn usd_per_bch_at_or_before(
oracle_pool: &SqlitePool,
ts: i64,
) -> anyhow::Result<Decimal> {
let entry = get_closest(oracle_pool, &None, ts)
.await?
.ok_or_else(|| anyhow::anyhow!("No oracle price found for timestamp {ts}"))?;
let price = Decimal::from_i64(entry.oracle_price).ok_or_else(|| {
anyhow::anyhow!("Oracle price out of Decimal range: {}", entry.oracle_price)
})?;
Ok(price / Decimal::from_i64(ORACLE_SCALE).unwrap())
}
/// The historical end of a price-change window.
///
/// `ts` is the timestamp of the snapshot the price was actually read from, which is
/// *not* always the window start: for a token younger than the window it is the
/// token's first pool instead, and for a dormant one it can be far older than the
/// window. Callers persist it so consumers can tell a real 7d move from a
/// since-launch one — clamping it to their window first, since a price that merely
/// held does not shorten the period being measured.
#[derive(Debug, Clone, Copy)]
pub struct PriceAnchor {
pub ts: i64,
pub price: Decimal,
}
async fn price_snapshot_at(
cauldron_pool: &SqlitePool,
ts: i64,
token_id: &TokenID,
) -> Option<PriceAnchor> {
let (snapshot_ts, price) = price_at_or_before_2(cauldron_pool, ts, token_id)
.await
.ok()?;
Decimal::from_f64(price).map(|price| PriceAnchor {
ts: snapshot_ts,
price,
})
}
/// Price at `window_start`, falling back to the token's first pool when the token is
/// younger than the window.
///
/// Without the fallback every token under 7 days old has a NULL `change_7d_bp` and
/// the UI can only show "N/A"; with it a young token reports its change since launch.
/// `first_pool_ts` is the cached value when known — it is looked up on demand
/// otherwise, since brand-new tokens are exactly the ones the backfill hasn't reached.
pub async fn price_anchor_for_window(
cauldron_pool: &SqlitePool,
window_start: i64,
first_pool_ts: Option<i64>,
token_id: &TokenID,
) -> Option<PriceAnchor> {
if let Some(anchor) = price_snapshot_at(cauldron_pool, window_start, token_id).await {
return Some(anchor);
}
let launch_ts = match first_pool_ts.filter(|&ts| ts > 0) {
Some(ts) => ts,
None => db_first_pool_creation_row(cauldron_pool, &token_id.to_string())
.await
.ok()
.flatten()
.map(|(_creation_utxo, _txid, ts, _height)| ts)?,
};
// History already reaches past the window start, so the miss above is a gap in
// the data (e.g. every pool was withdrawn), not a young token. Don't invent an
// anchor that would report an older move as if it were a 7d one.
if launch_ts <= window_start {
return None;
}
price_snapshot_at(cauldron_pool, launch_ts, token_id).await
}
#[inline]
pub fn pct_change_bp_dec(current: Decimal, past: Decimal) -> i64 {
if past.is_zero() {
return 0;
}
let tenk = dec!(10000);
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,
};
let delta_bp = match ratio_bp.checked_sub(tenk) {
Some(v) => v,
None => return if current >= past { i64::MAX } else { i64::MIN },
};
match delta_bp.round().to_i64() {
Some(v) => v,
None => {
if delta_bp.is_sign_negative() {
i64::MIN
} else {
i64::MAX
}
}
}
}
#[inline]
pub fn compute_score(tvl_sats: u64, vol_30d: u64) -> i64 {
if tvl_sats == 0 || vol_30d == 0 {
return 0;
}
let sqrt_tvl: Integer = Integer::from(tvl_sats).floor_sqrt();
let score_big: Integer = Integer::from(vol_30d) * sqrt_tvl;
if score_big > i64::MAX {
return i64::MAX;
}
score_big.to_string().parse::<i64>().unwrap_or(i64::MAX)
}
pub async fn resolve_decimals(
bcmr_pool: &SqlitePool,
crc20_pool: &SqlitePool,
token_id: &str,
) -> u32 {
let token_blob = match display_hex_to_blob::<TokenID>(token_id) {
Ok(b) => b,
Err(_) => return 0,
};
// On-chain BCMR
let row: Option<(Option<i64>,)> = sqlx::query_as(
r#"
SELECT decimals FROM (
SELECT b.decimals, a.height,
ROW_NUMBER() OVER (PARTITION BY a.token_id ORDER BY a.height DESC) rn
FROM auth_chain_entry a
LEFT JOIN bcmr_data b ON a.utxo = b.utxo
WHERE a.bcmr_data IS NOT NULL AND a.token_id = ?1
) s WHERE rn = 1
"#,
)
.bind(&token_blob)
.fetch_optional(bcmr_pool)
.await
.ok()
.flatten();
if let Some((Some(v),)) = row {
let dd = v.max(0) as u32;
if dd > 0 {
return dd;
}
}
// Well-known BCMR
let row: Option<(Option<i64>,)> = sqlx::query_as(
"SELECT decimals FROM bcmr_well_known WHERE token_id = ?1 ORDER BY source LIMIT 1",
)
.bind(&token_blob)
.fetch_optional(bcmr_pool)
.await
.ok()
.flatten();
if let Some((Some(v),)) = row {
let dd = v.max(0) as u32;
if dd > 0 {
return dd;
}
}
// CRC20 fallback
let row: Option<(Option<i64>,)> =
sqlx::query_as("SELECT decimals FROM crc20 WHERE token_id = ?1 LIMIT 1")
.bind(&token_blob)
.fetch_optional(crc20_pool)
.await
.ok()
.flatten();
if let Some((Some(v),)) = row {
let dd = v.max(0) as u32;
if dd > 0 {
return dd;
}
}
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]
pub fn pow10_dec(decimals: u32) -> Decimal {
dec!(10).powu(clamp_decimals(decimals) as u64)
}
#[inline]
pub fn price_from_tvl(tvl_sats: u64, tvl_tokens: u64) -> Option<Decimal> {
if tvl_tokens == 0 {
return None;
}
let sats = Decimal::from_u64(tvl_sats)?;
let tokens = Decimal::from_u64(tvl_tokens)?;
sats.checked_div(tokens)
}
pub async fn apy_30d_bp_for_token(
cauldron_pool: &SqlitePool,
token_id: &str,
now: i64,
) -> Result<i64> {
let start = now - 30 * 86_400;
let pairs = get_pool_period_snapshot(cauldron_pool, Some(token_id), None, start, now).await?;
let pool_id_blobs: Vec<Vec<u8>> = pairs
.iter()
.filter_map(|(s, _)| display_hex_to_blob::<crate::def::PoolID>(&s.pool_id).ok())
.collect();
let injections_map = get_injections_between(cauldron_pool, &pool_id_blobs, start, now).await?;
let periods = pairs
.into_iter()
.filter(|(s, e)| {
e.timestamp > s.timestamp
&& s.sats > 0
&& s.token_amount > 0
&& e.sats > 0
&& e.token_amount > 0
})
.flat_map(|(s, e)| {
let injections = injections_map
.get(&s.pool_id)
.map(|entries| {
entries
.iter()
.filter(|r| r.timestamp > s.timestamp && r.timestamp < e.timestamp)
.collect::<Vec<_>>()
})
.unwrap_or_default();
split_at_injections(s, e, injections)
})
.collect::<Result<Vec<_>>>()?;
if periods.is_empty() {
return Ok(0);
}
let apy_dec =
APYAggregator::aggregate_apy(periods.into_iter(), Some(start as u64)).map_err(|e| {
warn!(
"aggregate_apy failed for token {} over 30d: {}",
token_id, e
);
e
})?;
let hundred = dec!(100);
let bp_dec = match apy_dec.checked_mul(hundred) {
Some(v) => v.round(),
None => {
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)
}
pub async fn resolve_display_labels(
bcmr_pool: &SqlitePool,
crc20_pool: &SqlitePool,
token_id: &str,
) -> Result<(String, String)> {
let token_blob = display_hex_to_blob::<TokenID>(token_id)?;
// On-chain BCMR
let row: Option<(String, String)> = sqlx::query_as(
r#"
SELECT name, symbol FROM (
SELECT b.name, b.symbol, a.height,
ROW_NUMBER() OVER (PARTITION BY a.token_id ORDER BY a.height DESC) rn
FROM auth_chain_entry a
LEFT JOIN bcmr_data b ON a.utxo = b.utxo
WHERE a.bcmr_data IS NOT NULL AND a.token_id = ?1
) s WHERE rn = 1
"#,
)
.bind(&token_blob)
.fetch_optional(bcmr_pool)
.await?;
if let Some((name, sym)) = row {
let dn = if !name.is_empty() {
name
} else if !sym.is_empty() {
sym.clone()
} else {
token_id.to_string()
};
return Ok((dn, sym));
}
// Well-known BCMR
let row: Option<(String, String)> = sqlx::query_as(
"SELECT name, symbol FROM bcmr_well_known WHERE token_id = ?1 ORDER BY source LIMIT 1",
)
.bind(&token_blob)
.fetch_optional(bcmr_pool)
.await?;
if let Some((name, sym)) = row {
let dn = if !name.is_empty() {
name
} else if !sym.is_empty() {
sym.clone()
} else {
token_id.to_string()
};
return Ok((dn, sym));
}
// CRC20 fallback
let row: Option<(String, String)> =
sqlx::query_as("SELECT name, symbol FROM crc20 WHERE token_id = ?1 LIMIT 1")
.bind(&token_blob)
.fetch_optional(crc20_pool)
.await?;
if let Some((name, sym)) = row {
let dn = if !name.is_empty() {
name
} else if !sym.is_empty() {
sym.clone()
} else {
token_id.to_string()
};
return Ok((dn, sym));
}
Ok((token_id.to_string(), String::new()))
}