2026-01-21 12:34:59 +01:00
|
|
|
// Copyright (C) 2024-2026 Whiterun LLC
|
2025-09-05 13:53:10 +00:00
|
|
|
//
|
|
|
|
|
// 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
|
|
|
|
|
|
2026-02-01 13:23:57 +01:00
|
|
|
use anyhow::Result;
|
|
|
|
|
use bitcoincash::TokenID;
|
2025-09-05 13:53:10 +00:00
|
|
|
use log::warn;
|
2026-02-17 17:47:43 +01:00
|
|
|
use sqlx::SqlitePool;
|
2025-09-05 13:53:10 +00:00
|
|
|
|
2026-02-01 13:23:57 +01:00
|
|
|
use crate::db::blob::display_hex_to_blob;
|
2025-09-05 13:53:10 +00:00
|
|
|
use crate::db::cauldron::pool::get_pool_period_snapshot;
|
|
|
|
|
use crate::db::oracle::get_closest;
|
|
|
|
|
use crate::rpc::apy::apyaggregator::APYAggregator;
|
|
|
|
|
use crate::rpc::apy::poolperiod::PoolPeriod;
|
2025-10-03 13:32:37 +00:00
|
|
|
use malachite::base::num::arithmetic::traits::FloorSqrt;
|
|
|
|
|
use malachite::Integer;
|
2025-09-05 13:53:10 +00:00
|
|
|
|
|
|
|
|
use rust_decimal::prelude::*;
|
|
|
|
|
use rust_decimal::Decimal;
|
2025-10-03 13:32:37 +00:00
|
|
|
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);
|
2026-02-17 17:47:43 +01:00
|
|
|
let exp = (decimals as i32).clamp(0, 308);
|
2025-10-03 13:32:37 +00:00
|
|
|
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
|
|
|
|
|
}
|
|
|
|
|
}
|
2025-09-05 13:53:10 +00:00
|
|
|
|
|
|
|
|
pub const SATS_PER_BCH: i64 = 100_000_000;
|
2026-02-17 17:47:43 +01:00
|
|
|
pub const ORACLE_SCALE: i64 = 1_000_000;
|
2025-09-05 13:53:10 +00:00
|
|
|
|
2026-02-17 17:47:43 +01:00
|
|
|
pub async fn usd_per_bch_at_or_before(oracle_pool: &SqlitePool, ts: i64) -> Decimal {
|
|
|
|
|
match get_closest(oracle_pool, &None, ts).await {
|
2025-09-05 13:53:10 +00:00
|
|
|
Ok(Some(e)) => {
|
|
|
|
|
Decimal::from_i64(e.oracle_price).unwrap_or(Decimal::ZERO)
|
2026-02-17 17:47:43 +01:00
|
|
|
/ Decimal::from_i64(ORACLE_SCALE).unwrap()
|
2025-09-05 13:53:10 +00:00
|
|
|
}
|
|
|
|
|
_ => Decimal::ZERO,
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[inline]
|
|
|
|
|
pub fn pct_change_bp_dec(current: Decimal, past: Decimal) -> i64 {
|
|
|
|
|
if past.is_zero() {
|
2025-10-03 13:32:37 +00:00
|
|
|
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,
|
2026-02-17 17:47:43 +01:00
|
|
|
None => return 0,
|
2025-10-03 13:32:37 +00:00
|
|
|
};
|
|
|
|
|
|
|
|
|
|
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
|
|
|
|
|
}
|
|
|
|
|
}
|
2025-09-05 13:53:10 +00:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[inline]
|
2025-10-03 13:32:37 +00:00
|
|
|
pub fn compute_score(tvl_sats: u64, vol_30d: u64) -> i64 {
|
|
|
|
|
if tvl_sats == 0 || vol_30d == 0 {
|
2025-09-05 13:53:10 +00:00
|
|
|
return 0;
|
|
|
|
|
}
|
2025-10-03 13:32:37 +00:00
|
|
|
|
|
|
|
|
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;
|
2025-09-05 13:53:10 +00:00
|
|
|
}
|
|
|
|
|
|
2026-01-05 15:40:01 +01:00
|
|
|
score_big.to_string().parse::<i64>().unwrap_or(i64::MAX)
|
2025-10-03 13:32:37 +00:00
|
|
|
}
|
2026-02-17 17:47:43 +01:00
|
|
|
|
|
|
|
|
pub async fn resolve_decimals(
|
|
|
|
|
bcmr_pool: &SqlitePool,
|
|
|
|
|
crc20_pool: &SqlitePool,
|
|
|
|
|
token_id: &str,
|
|
|
|
|
) -> u32 {
|
2026-02-01 13:23:57 +01:00
|
|
|
let token_blob = match display_hex_to_blob::<TokenID>(token_id) {
|
Convert hash columns from TEXT to BLOB storage
Migrate all 32-byte hash fields from TEXT (64-char hex) to BLOB
(32 bytes) for ~50% storage savings on hash columns.
Changes:
- Add src/db/blob.rs with ToBlob/FromBlob traits for hash types
- Update all table schemas to use BLOB for hash columns
- Replace .to_hex() with .to_blob() for DB inserts
- Replace ::from_hex() with ::from_blob() for DB reads
- Use SQL hex() function where API responses need hex strings
- Bump DB_VERSION from 4 to 5
Affected tables: tx, pool, pool_history_entry, utxo_spending,
utxo_funding, user_action, auth_chain_entry, bcmr_data,
bcmr_failure, bcmr_well_known, crc20, crc20_candidates,
delphi_entry, cached_token_metrics
Fixes #1
2026-01-21 13:25:35 +01:00
|
|
|
Ok(b) => b,
|
2026-02-17 17:47:43 +01:00
|
|
|
Err(_) => return 0,
|
Convert hash columns from TEXT to BLOB storage
Migrate all 32-byte hash fields from TEXT (64-char hex) to BLOB
(32 bytes) for ~50% storage savings on hash columns.
Changes:
- Add src/db/blob.rs with ToBlob/FromBlob traits for hash types
- Update all table schemas to use BLOB for hash columns
- Replace .to_hex() with .to_blob() for DB inserts
- Replace ::from_hex() with ::from_blob() for DB reads
- Use SQL hex() function where API responses need hex strings
- Bump DB_VERSION from 4 to 5
Affected tables: tx, pool, pool_history_entry, utxo_spending,
utxo_funding, user_action, auth_chain_entry, bcmr_data,
bcmr_failure, bcmr_well_known, crc20, crc20_candidates,
delphi_entry, cached_token_metrics
Fixes #1
2026-01-21 13:25:35 +01:00
|
|
|
};
|
|
|
|
|
|
2026-02-17 17:47:43 +01:00
|
|
|
// On-chain BCMR
|
|
|
|
|
let row: Option<(Option<i64>,)> = sqlx::query_as(
|
2025-09-05 13:53:10 +00:00
|
|
|
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
|
|
|
|
|
"#,
|
2026-02-17 17:47:43 +01:00
|
|
|
)
|
|
|
|
|
.bind(&token_blob)
|
|
|
|
|
.fetch_optional(bcmr_pool)
|
|
|
|
|
.await
|
|
|
|
|
.ok()
|
|
|
|
|
.flatten();
|
|
|
|
|
|
|
|
|
|
if let Some((Some(v),)) = row {
|
2025-09-05 13:53:10 +00:00
|
|
|
let dd = v.max(0) as u32;
|
|
|
|
|
if dd > 0 {
|
2026-02-17 17:47:43 +01:00
|
|
|
return dd;
|
2025-09-05 13:53:10 +00:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Well-known BCMR
|
2026-02-17 17:47:43 +01:00
|
|
|
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 {
|
2025-09-05 13:53:10 +00:00
|
|
|
let dd = v.max(0) as u32;
|
|
|
|
|
if dd > 0 {
|
|
|
|
|
return dd;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// CRC20 fallback
|
2026-02-17 17:47:43 +01:00
|
|
|
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 {
|
2025-09-05 13:53:10 +00:00
|
|
|
let dd = v.max(0) as u32;
|
|
|
|
|
if dd > 0 {
|
|
|
|
|
return dd;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
0
|
|
|
|
|
}
|
|
|
|
|
|
2025-10-03 13:32:37 +00:00
|
|
|
/// 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)
|
|
|
|
|
}
|
|
|
|
|
|
2025-09-05 13:53:10 +00:00
|
|
|
#[inline]
|
|
|
|
|
pub fn pow10_dec(decimals: u32) -> Decimal {
|
2025-10-03 13:32:37 +00:00
|
|
|
dec!(10).powu(clamp_decimals(decimals) as u64)
|
2025-09-05 13:53:10 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[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)
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-17 17:47:43 +01:00
|
|
|
pub async fn apy_30d_bp_for_token(
|
|
|
|
|
cauldron_pool: &SqlitePool,
|
|
|
|
|
token_id: &str,
|
|
|
|
|
now: i64,
|
|
|
|
|
) -> Result<i64> {
|
2025-09-05 13:53:10 +00:00
|
|
|
let start = now - 30 * 86_400;
|
|
|
|
|
|
2026-02-17 17:47:43 +01:00
|
|
|
let pairs = get_pool_period_snapshot(cauldron_pool, Some(token_id), None, start, now).await?;
|
2025-09-05 13:53:10 +00:00
|
|
|
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
|
|
|
|
|
})
|
|
|
|
|
.map(|(s, e)| PoolPeriod::new(s, e))
|
|
|
|
|
.collect::<Result<Vec<_>>>()?;
|
|
|
|
|
|
|
|
|
|
if periods.is_empty() {
|
|
|
|
|
return Ok(0);
|
|
|
|
|
}
|
|
|
|
|
|
2025-09-11 07:36:06 +00:00
|
|
|
let apy_dec =
|
|
|
|
|
APYAggregator::aggregate_apy(periods.into_iter(), Some(start as u64)).map_err(|e| {
|
2025-09-05 13:53:10 +00:00
|
|
|
warn!(
|
|
|
|
|
"aggregate_apy failed for token {} over 30d: {}",
|
|
|
|
|
token_id, e
|
|
|
|
|
);
|
2025-09-11 07:36:06 +00:00
|
|
|
e
|
|
|
|
|
})?;
|
2025-09-05 13:53:10 +00:00
|
|
|
|
2025-10-03 13:32:37 +00:00
|
|
|
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,
|
|
|
|
|
};
|
2025-09-11 07:36:06 +00:00
|
|
|
Ok(apy_bp)
|
2025-09-05 13:53:10 +00:00
|
|
|
}
|
|
|
|
|
|
2026-02-17 17:47:43 +01:00
|
|
|
pub async fn resolve_display_labels(
|
|
|
|
|
bcmr_pool: &SqlitePool,
|
|
|
|
|
crc20_pool: &SqlitePool,
|
2025-09-05 13:53:10 +00:00
|
|
|
token_id: &str,
|
|
|
|
|
) -> Result<(String, String)> {
|
2026-02-01 13:23:57 +01:00
|
|
|
let token_blob = display_hex_to_blob::<TokenID>(token_id)?;
|
Convert hash columns from TEXT to BLOB storage
Migrate all 32-byte hash fields from TEXT (64-char hex) to BLOB
(32 bytes) for ~50% storage savings on hash columns.
Changes:
- Add src/db/blob.rs with ToBlob/FromBlob traits for hash types
- Update all table schemas to use BLOB for hash columns
- Replace .to_hex() with .to_blob() for DB inserts
- Replace ::from_hex() with ::from_blob() for DB reads
- Use SQL hex() function where API responses need hex strings
- Bump DB_VERSION from 4 to 5
Affected tables: tx, pool, pool_history_entry, utxo_spending,
utxo_funding, user_action, auth_chain_entry, bcmr_data,
bcmr_failure, bcmr_well_known, crc20, crc20_candidates,
delphi_entry, cached_token_metrics
Fixes #1
2026-01-21 13:25:35 +01:00
|
|
|
|
2026-02-17 17:47:43 +01:00
|
|
|
// On-chain BCMR
|
|
|
|
|
let row: Option<(String, String)> = sqlx::query_as(
|
2025-09-05 13:53:10 +00:00
|
|
|
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
|
|
|
|
|
"#,
|
2026-02-17 17:47:43 +01:00
|
|
|
)
|
|
|
|
|
.bind(&token_blob)
|
|
|
|
|
.fetch_optional(bcmr_pool)
|
|
|
|
|
.await?;
|
|
|
|
|
|
|
|
|
|
if let Some((name, sym)) = row {
|
2025-09-05 13:53:10 +00:00
|
|
|
let dn = if !name.is_empty() {
|
|
|
|
|
name
|
|
|
|
|
} else if !sym.is_empty() {
|
|
|
|
|
sym.clone()
|
|
|
|
|
} else {
|
|
|
|
|
token_id.to_string()
|
|
|
|
|
};
|
|
|
|
|
return Ok((dn, sym));
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-17 17:47:43 +01:00
|
|
|
// 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 {
|
2025-09-05 13:53:10 +00:00
|
|
|
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
|
2026-02-17 17:47:43 +01:00
|
|
|
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 {
|
2025-09-05 13:53:10 +00:00
|
|
|
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()))
|
|
|
|
|
}
|