From 381cb94295c2cc0163dab085f3f21e6bad11b5f1 Mon Sep 17 00:00:00 2001 From: jakobsn Date: Wed, 11 Mar 2026 09:31:21 +0000 Subject: [PATCH] Commit alternative solution for pool inflation --- src/db/cauldron/pool.rs | 72 ++++ src/db/cauldron/tokenlist/token_utils.rs | 25 +- src/rpc/apy/apyaggregator.rs | 59 ++- src/rpc/apy/mod.rs | 42 +- src/rpc/apy/poolperiod.rs | 466 ++++++++++++----------- 5 files changed, 429 insertions(+), 235 deletions(-) diff --git a/src/db/cauldron/pool.rs b/src/db/cauldron/pool.rs index 3eb5237..96b3992 100644 --- a/src/db/cauldron/pool.rs +++ b/src/db/cauldron/pool.rs @@ -401,6 +401,78 @@ async fn get_nearest_entries( Ok(pools) } +/// A capital injection event: both-side-positive delta on a pool_history_entry row. +/// Carries the actual pool state immediately before and after the injection so callers +/// can split a pool period into clean sub-periods without synthetic values. +pub struct InjectionRecord { + pub sats_before: u64, + pub tokens_before: u64, + pub sats_after: u64, + pub tokens_after: u64, + pub timestamp: u64, +} + +/// Returns injection events (both-side-positive deltas) for a set of pools within a time window. +/// An injection is a pool state where both sats_delta >= 0 and token_delta >= 0, meaning +/// capital was added rather than a normal trade occurring. +/// Callers use the k_before/k_after ratio to neutralize the K jump in APY calculations. +pub async fn get_injections_between( + pool: &SqlitePool, + pool_id_blobs: &[Vec], + min_start_ts: i64, + end_ts: i64, +) -> Result>> { + if pool_id_blobs.is_empty() { + return Ok(HashMap::new()); + } + + let placeholders = (1..=pool_id_blobs.len()) + .map(|i| format!("?{i}")) + .collect::>() + .join(","); + let n = pool_id_blobs.len(); + let query = format!( + "SELECT pool, sats, token_amount, sats_delta, token_delta, effective_timestamp + FROM pool_history_entry + WHERE pool IN ({placeholders}) + AND effective_timestamp > ?{start_bind} + AND effective_timestamp <= ?{end_bind} + AND sats_delta >= 0 AND token_delta >= 0 + AND (sats_delta > 0 OR token_delta > 0)", + start_bind = n + 1, + end_bind = n + 2, + ); + + let mut query_builder = sqlx::query(&query); + for blob in pool_id_blobs { + query_builder = query_builder.bind(blob.clone()); + } + query_builder = query_builder.bind(min_start_ts).bind(end_ts); + + let rows = query_builder.fetch_all(pool).await?; + let mut result: HashMap> = HashMap::new(); + + for row in rows { + let pool_blob: Vec = row.get(0); + let pool_id = blob_to_display_hex::(&pool_blob)?; + let sats: i64 = row.get(1); + let token_amount: i64 = row.get(2); + let sats_delta: i64 = row.get(3); + let token_delta: i64 = row.get(4); + let ts: i64 = row.get(5); + + result.entry(pool_id).or_default().push(InjectionRecord { + sats_before: (sats - sats_delta).max(0) as u64, + tokens_before: (token_amount - token_delta).max(0) as u64, + sats_after: sats.max(0) as u64, + tokens_after: token_amount.max(0) as u64, + timestamp: ts as u64, + }); + } + + Ok(result) +} + /// Returns pool period snapshots filtered by token and/or owner PKH. pub async fn get_pool_period_snapshot( pool: &SqlitePool, diff --git a/src/db/cauldron/tokenlist/token_utils.rs b/src/db/cauldron/tokenlist/token_utils.rs index 10b3cd7..8794a57 100644 --- a/src/db/cauldron/tokenlist/token_utils.rs +++ b/src/db/cauldron/tokenlist/token_utils.rs @@ -9,10 +9,10 @@ use log::warn; use sqlx::SqlitePool; use crate::db::blob::display_hex_to_blob; -use crate::db::cauldron::pool::get_pool_period_snapshot; +use crate::db::cauldron::pool::{get_injections_between, get_pool_period_snapshot}; use crate::db::oracle::get_closest; use crate::rpc::apy::apyaggregator::APYAggregator; -use crate::rpc::apy::poolperiod::PoolPeriod; +use crate::rpc::apy::poolperiod::split_at_injections; use malachite::base::num::arithmetic::traits::FloorSqrt; use malachite::Integer; @@ -226,6 +226,14 @@ pub async fn apy_30d_bp_for_token( 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> = pairs + .iter() + .filter_map(|(s, _)| display_hex_to_blob::(&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)| { @@ -235,7 +243,18 @@ pub async fn apy_30d_bp_for_token( && e.sats > 0 && e.token_amount > 0 }) - .map(|(s, e)| PoolPeriod::new(s, e)) + .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::>() + }) + .unwrap_or_default(); + split_at_injections(s, e, injections) + }) .collect::>>()?; if periods.is_empty() { diff --git a/src/rpc/apy/apyaggregator.rs b/src/rpc/apy/apyaggregator.rs index 1a019a9..6c67679 100644 --- a/src/rpc/apy/apyaggregator.rs +++ b/src/rpc/apy/apyaggregator.rs @@ -3,41 +3,76 @@ // 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 anyhow::{anyhow, Context, Result}; use rust_decimal::Decimal; +use rust_decimal::MathematicalOps; +use rust_decimal_macros::dec; use super::poolperiod::PoolPeriod; +const DAYS_IN_YEAR: Decimal = dec!(365.25); + pub struct APYAggregator; impl APYAggregator { + /// Aggregate APY across pools by computing a weighted average yield first, + /// then annualizing once. This avoids per-pool overflow from (1+y)^(365/d) + /// on short high-yield periods, and correctly dilutes extreme outlier pools. pub fn aggregate_apy(pools: I, period_start: Option) -> Result where I: Iterator, { - let mut weighted_apy_sum = Decimal::ZERO; + let mut weighted_yield_sum = Decimal::ZERO; + let mut weighted_duration_sum = Decimal::ZERO; let mut total_weight = Decimal::ZERO; for pool in pools { let days_active = pool.duration_days(&period_start)?; if !days_active.is_zero() { - let (_pool_yield, apy) = pool.yield_and_apy(&period_start)?; - - // we give larger pools more weight in the aggregated apy. They have more liquidity, - // so the aggregated number will be more accurate. + let pool_yield = pool.pool_yield(&period_start)?; + // Weight larger pools more — they represent more real liquidity. let pool_size = pool.end_k_sqrt()?; - let weighted_apy = apy * days_active * pool_size; + let weight = days_active * pool_size; - weighted_apy_sum += weighted_apy; - total_weight += days_active * pool_size; + weighted_yield_sum += pool_yield * weight; + weighted_duration_sum += days_active * weight; + total_weight += weight; } } if total_weight.is_zero() { - Ok(Decimal::ZERO) - } else { - Ok(weighted_apy_sum / total_weight) + return Ok(Decimal::ZERO); } + + let avg_yield = weighted_yield_sum / total_weight; + let avg_duration_days = weighted_duration_sum / total_weight; + + if avg_duration_days.is_zero() { + return Ok(Decimal::ZERO); + } + + let years_elapsed = DAYS_IN_YEAR + .checked_div(avg_duration_days) + .ok_or_else(|| anyhow!("days over year div"))?; + + if years_elapsed.is_zero() { + return Ok(Decimal::ZERO); + } + + let one_plus = avg_yield + .checked_div(Decimal::ONE_HUNDRED) + .ok_or_else(|| anyhow!("div 100"))? + .checked_add(Decimal::ONE) + .ok_or_else(|| anyhow!("1 + yield"))?; + + let powd = one_plus + .checked_powd(years_elapsed) + .context("powd overflow")?; + + powd.checked_sub(Decimal::ONE) + .ok_or_else(|| anyhow!("powd - 1"))? + .checked_mul(Decimal::ONE_HUNDRED) + .ok_or_else(|| anyhow!("* 100")) } } diff --git a/src/rpc/apy/mod.rs b/src/rpc/apy/mod.rs index 183deb3..56d1571 100644 --- a/src/rpc/apy/mod.rs +++ b/src/rpc/apy/mod.rs @@ -9,9 +9,13 @@ use serde_json::Value; use crate::{ db::{ - cauldron::pool::{get_pool_period_snapshot, get_pool_period_snapshot_by_pool_ids}, + blob::display_hex_to_blob, + cauldron::pool::{ + get_injections_between, get_pool_period_snapshot, get_pool_period_snapshot_by_pool_ids, + }, DB, }, + def::PoolID, rpc::err::{bad_request, db_error, ApiErrorCode, CachedApiResult}, rpc::response::{cached_ok, CACHE_AGGREGATE}, timeutil::time_now, @@ -104,14 +108,44 @@ pub async fn aggregate_apy( .map_err(db_error)? }; + // Fetch injection events (capital additions) for all pools in the result. + // Each pool period is split into sub-periods at injection boundaries so the + // APY calculation only sees real fee growth, never injected capital. + let pool_id_blobs: Vec> = pool_snapshots + .iter() + .filter_map(|(s, _)| display_hex_to_blob::(&s.pool_id).ok()) + .collect(); + + let min_start_ts = pool_snapshots + .iter() + .map(|(s, _)| s.timestamp as i64) + .min() + .unwrap_or(start); + + let injections_map = get_injections_between(&db.cauldron_r, &pool_id_blobs, min_start_ts, end) + .await + .map_err(db_error)?; + + let pools_count = pool_snapshots.len(); let pools: anyhow::Result> = pool_snapshots .into_iter() - .map(|(start, end)| PoolPeriod::new(start, end)) + .flat_map(|(start_snap, end_snap)| { + let injections = injections_map + .get(&start_snap.pool_id) + .map(|entries| { + entries + .iter() + .filter(|r| { + r.timestamp > start_snap.timestamp && r.timestamp < end_snap.timestamp + }) + .collect::>() + }) + .unwrap_or_default(); + poolperiod::split_at_injections(start_snap, end_snap, injections) + }) .collect(); let pools = pools.map_err(db_error)?; - - let pools_count = pools.len(); let apy = apyaggregator::APYAggregator::aggregate_apy(pools.into_iter(), Some(start as u64)) .map_err(db_error)?; diff --git a/src/rpc/apy/poolperiod.rs b/src/rpc/apy/poolperiod.rs index ea62111..c703546 100644 --- a/src/rpc/apy/poolperiod.rs +++ b/src/rpc/apy/poolperiod.rs @@ -3,21 +3,16 @@ // 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::{anyhow, bail, Context, Result}; +use anyhow::{bail, Context, Result}; use malachite::base::num::arithmetic::traits::FloorSqrt; use malachite::Integer; -use rust_decimal::MathematicalOps; use rust_decimal::{prelude::FromPrimitive, Decimal}; use rust_decimal_macros::dec; use super::PoolSnapshot; +use crate::db::cauldron::pool::InjectionRecord; const SECONDS_IN_DAY: Decimal = dec!(86400.0); -const DAYS_IN_YEAR: Decimal = dec!(365.25); -/// Maximum APY value (100,000%) to guard against overflow in edge cases where -/// pools with very small initial liquidity or large gaps in activity could -/// produce unrealistic annualized returns that exceed Decimal precision limits. -const MAX_APY: Decimal = dec!(100000.0); /// Linear interpolation between two Decimal values. /// Returns start + (end - start) * (elapsed / total). @@ -31,6 +26,51 @@ fn interpolate_linear(start: Decimal, end: Decimal, elapsed: u64, total: u64) -> start.checked_add(delta.checked_mul(ratio)?) } +/// Split a pool's (start, end) snapshot pair into sub-periods at injection boundaries. +/// Each sub-period uses only real DB values and contains no injected capital, so the +/// APY calculation sees only fee growth within each sub-period. +pub fn split_at_injections( + start: PoolSnapshot, + end: PoolSnapshot, + mut injections: Vec<&InjectionRecord>, +) -> Vec> { + injections.sort_by_key(|r| r.timestamp); + + let mut result = Vec::new(); + let mut current_start = start; + + for inj in injections { + // Skip degenerate injections (pool was empty before injection) + if inj.sats_before == 0 || inj.tokens_before == 0 { + current_start = PoolSnapshot { + pool_id: current_start.pool_id, + timestamp: inj.timestamp, + sats: inj.sats_after, + token_amount: inj.tokens_after, + }; + continue; + } + + let snap_before = PoolSnapshot { + pool_id: current_start.pool_id.clone(), + timestamp: inj.timestamp, + sats: inj.sats_before, + token_amount: inj.tokens_before, + }; + result.push(PoolPeriod::new(current_start, snap_before)); + + current_start = PoolSnapshot { + pool_id: end.pool_id.clone(), + timestamp: inj.timestamp, + sats: inj.sats_after, + token_amount: inj.tokens_after, + }; + } + + result.push(PoolPeriod::new(current_start, end)); + result +} + #[derive(Debug, Clone)] pub struct PoolPeriod { pub start: PoolSnapshot, @@ -87,13 +127,6 @@ impl PoolPeriod { Ok(self.durationd(starting)? / SECONDS_IN_DAY) } - pub fn days_over_year(&self, starting: &Option) -> Result { - // If duration_days is 0, checked_div returns None → default to 0 - Ok(DAYS_IN_YEAR - .checked_div(self.duration_days(starting)?) - .unwrap_or_default()) - } - fn sqrt_integer_as_decimal(k: &Integer) -> anyhow::Result { use std::str::FromStr; let s: Integer = k.clone().floor_sqrt(); @@ -152,42 +185,6 @@ impl PoolPeriod { .checked_mul(Decimal::ONE_HUNDRED) .ok_or_else(|| anyhow::anyhow!("mul")) } - - pub fn yield_and_apy(&self, starting: &Option) -> Result<(Decimal, Decimal)> { - let pool_yield = self.pool_yield(starting)?; - let years_elapsed = self.days_over_year(starting)?; // > 0 means at least some duration - - // avoid powd blowups for very short periods (< 6h) - if self.duration(starting) < 6 * 3600 { - return Ok((pool_yield, Decimal::ZERO)); - } - - let apy = if years_elapsed.is_zero() { - Decimal::ZERO - } else { - // 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) - .context("powd overflow")?; - let minus_one = powd - .checked_sub(Decimal::ONE) - .ok_or_else(|| anyhow!("pow - 1 underflow"))?; - let apy_raw = minus_one - .checked_mul(Decimal::ONE_HUNDRED) - .ok_or_else(|| anyhow!("apy * 100 overflow"))?; - - // Safety cap to prevent extreme values - apy_raw.min(MAX_APY) - }; - - Ok((pool_yield, apy)) - } } #[cfg(test)] @@ -195,6 +192,105 @@ mod tests { use super::*; + fn make_injection( + timestamp: u64, + sats_before: u64, + tokens_before: u64, + sats_after: u64, + tokens_after: u64, + ) -> InjectionRecord { + InjectionRecord { + sats_before, + tokens_before, + sats_after, + tokens_after, + timestamp, + } + } + + #[test] + fn test_split_no_injections() { + let start = PoolSnapshot::dummy(1000, 1000, 500); + let end = PoolSnapshot::dummy(2000, 1100, 490); + let periods: Vec = split_at_injections(start, end, vec![]) + .into_iter() + .collect::>>() + .unwrap(); + + assert_eq!(periods.len(), 1); + assert_eq!(periods[0].start.sats, 1000); + assert_eq!(periods[0].end.sats, 1100); + } + + #[test] + fn test_split_one_injection() { + let start = PoolSnapshot::dummy(1000, 1000, 500); + let end = PoolSnapshot::dummy(3000, 2100, 1490); + // injection at t=2000: sats 1000→2000, tokens 500→1500 + let inj = make_injection(2000, 1000, 500, 2000, 1500); + + let periods: Vec = split_at_injections(start, end, vec![&inj]) + .into_iter() + .collect::>>() + .unwrap(); + + assert_eq!(periods.len(), 2); + // Sub-period 1: start → state just before injection + assert_eq!(periods[0].start.timestamp, 1000); + assert_eq!(periods[0].end.timestamp, 2000); + assert_eq!(periods[0].end.sats, 1000); + assert_eq!(periods[0].end.token_amount, 500); + // Sub-period 2: state just after injection → end + assert_eq!(periods[1].start.timestamp, 2000); + assert_eq!(periods[1].start.sats, 2000); + assert_eq!(periods[1].start.token_amount, 1500); + assert_eq!(periods[1].end.timestamp, 3000); + } + + #[test] + fn test_split_multiple_injections() { + let start = PoolSnapshot::dummy(1000, 1000, 500); + let end = PoolSnapshot::dummy(5000, 4100, 3490); + let inj1 = make_injection(2000, 1000, 500, 2000, 1500); + let inj2 = make_injection(3000, 2000, 1500, 3000, 2500); + let inj3 = make_injection(4000, 3000, 2500, 4000, 3500); + + let periods: Vec = split_at_injections(start, end, vec![&inj1, &inj2, &inj3]) + .into_iter() + .collect::>>() + .unwrap(); + + assert_eq!(periods.len(), 4); + assert_eq!(periods[0].start.timestamp, 1000); + assert_eq!(periods[0].end.timestamp, 2000); + assert_eq!(periods[1].start.timestamp, 2000); + assert_eq!(periods[1].end.timestamp, 3000); + assert_eq!(periods[2].start.timestamp, 3000); + assert_eq!(periods[2].end.timestamp, 4000); + assert_eq!(periods[3].start.timestamp, 4000); + assert_eq!(periods[3].end.timestamp, 5000); + } + + #[test] + fn test_split_degenerate_injection_skipped() { + // An injection where sats_before=0 should be skipped (pool was empty before injection). + // The current_start should jump forward to the post-injection state. + let start = PoolSnapshot::dummy(1000, 0, 0); + let end = PoolSnapshot::dummy(3000, 2100, 1490); + let inj = make_injection(2000, 0, 0, 2000, 1500); + + let periods: Vec = split_at_injections(start, end, vec![&inj]) + .into_iter() + .collect::>>() + .unwrap(); + + // Degenerate injection skipped; only the post-injection → end sub-period remains + assert_eq!(periods.len(), 1); + assert_eq!(periods[0].start.sats, 2000); + assert_eq!(periods[0].start.timestamp, 2000); + assert_eq!(periods[0].end.timestamp, 3000); + } + #[test] fn test_interpolate_linear_basic() { // Basic interpolation at midpoint @@ -253,32 +349,120 @@ mod tests { } #[test] - fn test_apy_and_yield() { + fn test_yield_known_reference_values() { + // Reference values from a real pool: // http://localhost:3000/position/f67ff489fb3b8efaf5db1a2cf9e3faa07fdfd7903079262a534c921e7e7d0d2c17 - // APY: 0.2815061492818 numberOfDaysInPeriod: 28.668907708332494 first timestamp 1727201259 last trade 1729678252.626 first sats 1648241n first tokens 116674414n last sats 1759576n last tokens 109340225n yield 0.02206714530974295 - // compare numbers to our existing implementation - + // yield: 0.02206714530974295, APY: 0.2815061492818, period: 28.67 days let start = PoolSnapshot::dummy(1727201259, 1648241, 116674414); let end = PoolSnapshot::dummy(1729678252, 1759576, 109340225); - - let expected_yield = dec!(0.02206714530974295); - let expected_apy = dec!(0.2815061492818); - let period = PoolPeriod::new(start, end).unwrap(); - let (pool_yield, pool_apy) = period.yield_and_apy(&None).unwrap(); - assert!((expected_yield - pool_yield).abs() < dec!(1e-12)); + let pool_yield = period.pool_yield(&None).unwrap(); + let expected_yield = dec!(0.02206714530974295); assert!( - (expected_apy - pool_apy).abs() < dec!(1e-7), - "expected {expected_apy} != actual {pool_apy}" + (expected_yield - pool_yield).abs() < dec!(1e-12), + "yield mismatch: expected {expected_yield}, got {pool_yield}" + ); + } + + #[test] + fn test_aggregate_apy_known_reference_values() { + // Same pool as test_yield_known_reference_values. + // Single-pool aggregation should annualize correctly: ~28.2% APY. + use crate::rpc::apy::apyaggregator::APYAggregator; + let start = PoolSnapshot::dummy(1727201259, 1648241, 116674414); + let end = PoolSnapshot::dummy(1729678252, 1759576, 109340225); + let period = PoolPeriod::new(start, end).unwrap(); + + let apy = APYAggregator::aggregate_apy(std::iter::once(period), None).unwrap(); + let expected_apy = dec!(0.2815061492818); + assert!( + (expected_apy - apy).abs() < dec!(1e-6), + "apy mismatch: expected {expected_apy}, got {apy}" + ); + } + + #[test] + fn test_aggregate_apy_no_overflow_with_period_start() { + // Regression: pool 0b36662c had start_k barely above zero (sats=294608, tokens=2) + // and a 42.8-day period. With the old per-pool annualization, passing period_start + // inside that window shrank the effective duration to ~10.9 days while keeping the + // full-period yield (52.5%), causing (1.525)^33.4 = powd overflow. + // The new aggregator averages yield and duration first, so this must not overflow. + use crate::rpc::apy::apyaggregator::APYAggregator; + let start = PoolSnapshot::dummy(1763661872, 294608, 2); + let end = PoolSnapshot::dummy(1767359139, 2948, 465); + let period = PoolPeriod::new(start, end).unwrap(); + + let period_start = Some(1766414517u64); // 30-day window starts inside this pool period + let result = APYAggregator::aggregate_apy(std::iter::once(period), period_start); + assert!(result.is_ok(), "unexpected overflow: {:?}", result.err()); + } + + #[test] + fn test_aggregate_apy_token_d03a_no_overflow() { + // Regression: real 30-day window for token d03a0d87... (Jan 21, 2026). + // The aggregation previously failed with "powd overflow" due to pool 0b36662c. + use crate::rpc::apy::apyaggregator::APYAggregator; + let pools_data = vec![ + ( + 1766272253u64, + 27996919u64, + 1174620u64, + 1768387876u64, + 16301236u64, + 2028343u64, + ), + (1763661872, 294608, 2, 1767359139, 2948, 465), + (1766272253, 14025779, 588453, 1768385423, 8166999, 1016090), + (1766272253, 254947, 10697, 1768664725, 144935, 18923), + (1766272253, 13957573, 585591, 1768385423, 8127341, 1011139), + (1764336593, 3824, 187, 1767359139, 2135, 336), + (1766272253, 38225, 1602, 1767377622, 23136, 2661), + ]; + let periods: Vec = pools_data + .into_iter() + .map(|(s_ts, s_sats, s_tok, e_ts, e_sats, e_tok)| { + PoolPeriod::new( + PoolSnapshot::dummy(s_ts, s_sats, s_tok), + PoolSnapshot::dummy(e_ts, e_sats, e_tok), + ) + .unwrap() + }) + .collect(); + + let period_start = Some(1766414517u64); + let result = APYAggregator::aggregate_apy(periods.into_iter(), period_start); + assert!(result.is_ok(), "unexpected error: {:?}", result.err()); + } + + #[test] + fn test_aggregate_apy_zero_duration_period_skipped() { + // A period where start == end timestamp contributes zero duration_days. + // The aggregator should skip it rather than panic or divide by zero. + use crate::rpc::apy::apyaggregator::APYAggregator; + let ts = 1727201259u64; + let normal = PoolPeriod::new( + PoolSnapshot::dummy(ts, 1648241, 116674414), + PoolSnapshot::dummy(ts + 86400, 1659000, 115000000), + ) + .unwrap(); + // Zero-duration period: start_ts == end_ts (same timestamp, validated as equal) + // PoolPeriod::new allows equal timestamps (only rejects start > end). + let zero_dur = PoolPeriod::new( + PoolSnapshot::dummy(ts, 1648241, 116674414), + PoolSnapshot::dummy(ts, 1648241, 116674414), + ) + .unwrap(); + let result = APYAggregator::aggregate_apy(vec![normal, zero_dur].into_iter(), None); + assert!( + result.is_ok(), + "should not panic on zero-duration period: {:?}", + result.err() ); } /// Test APY calculation failure for token d03a0d876afba161101674e363398e33939cc2164bd7ea5baccd937497d6216f - /// This test reproduces the APY calculation failure by testing the documented error conditions: - /// - "start sqrt is zero; division by zero" when start_k (sats * token_amount) is zero - /// - "powd overflow" when exponentiation in APY calculation overflows - /// - "pow - 1 underflow" when arithmetic underflow occurs after power calculation #[test] fn test_apy_failure_start_sqrt_zero() { // Test case: start sats is zero, causing division by zero @@ -317,156 +501,6 @@ mod tests { ); } - #[test] - fn test_apy_powd_overflow_with_period_start() { - // BUG REPRODUCTION: Pool 0b36662c from token d03a0d87... - // This pool has a large yield (52.5%) over a long period (42.8 days). - // When yield_and_apy() is called with period_start that is AFTER the pool's - // start timestamp, the duration shrinks but yield stays the same. - // This causes the APY calculation to overflow because: - // - yield = 52.5% (calculated from full period k values) - // - effective duration = ~10.9 days (from period_start to end) - // - years_elapsed = 365.25 / 10.9 = 33.4 - // - APY = (1.525^33.4 - 1) * 100 = OVERFLOW! - // - // Real data from cauldron.db: - // Pool: 0b36662c82f7ff3c36f2aad7fd0c06c99735dcce22fffde940b1d0731334c3f5 - // Start: ts=1763661872, sats=294608, tokens=2 - // End: ts=1767359139, sats=2948, tokens=465 - let start = PoolSnapshot::dummy(1763661872, 294608, 2); - let end = PoolSnapshot::dummy(1767359139, 2948, 465); - - let period = PoolPeriod::new(start, end).unwrap(); - - // Without period_start, it works (42.8 days) - let result_none = period.yield_and_apy(&None); - assert!( - result_none.is_ok(), - "Should work without period_start: {:?}", - result_none.err() - ); - let (yield_val, apy_val) = result_none.unwrap(); - println!("Without period_start: yield={}, apy={}", yield_val, apy_val); - - // With period_start = API's (NOW - 30 days), duration shrinks to ~10.9 days - // but yield stays at 52.5%, causing overflow - // - // BUG: Currently fails with "powd overflow" because yield is calculated from - // full period but duration uses period_start, creating (1.525)^33.4 overflow. - // This test should pass once the bug is fixed. - let period_start = Some(1766414517u64); // NOW - 30 days when API was called - let result_with_start = period.yield_and_apy(&period_start); - - assert!( - result_with_start.is_ok(), - "BUG: Should not overflow - got error: {:?}", - result_with_start.err() - ); - let (yield_val, apy_val) = result_with_start.unwrap(); - println!("With period_start: yield={}, apy={}", yield_val, apy_val); - } - - #[test] - fn test_apy_token_d03a_current_window() { - // Test case for token d03a0d876afba161101674e363398e33939cc2164bd7ea5baccd937497d6216f - // Real pool periods from current 30-day API window (as of Jan 21, 2026) - // These are the actual periods that cause "powd overflow" error - - use crate::rpc::apy::apyaggregator::APYAggregator; - - let pools_data = vec![ - // pool, start_ts, start_sats, start_tokens, end_ts, end_sats, end_tokens - ( - "0a6f7bad", - 1766272253u64, - 27996919u64, - 1174620u64, - 1768387876u64, - 16301236u64, - 2028343u64, - ), - ("0b36662c", 1763661872, 294608, 2, 1767359139, 2948, 465), // Very low start tokens! - ( - "22ddbf2e", 1766272253, 14025779, 588453, 1768385423, 8166999, 1016090, - ), - ( - "b6ac601f", 1766272253, 254947, 10697, 1768664725, 144935, 18923, - ), - ( - "c1517c3a", 1766272253, 13957573, 585591, 1768385423, 8127341, 1011139, - ), - ("fc9d23e1", 1764336593, 3824, 187, 1767359139, 2135, 336), - ("fd77e2da", 1766272253, 38225, 1602, 1767377622, 23136, 2661), - ]; - - // Test each pool individually first - let mut periods = Vec::new(); - for (pool_name, start_ts, start_sats, start_tokens, end_ts, end_sats, end_tokens) in - &pools_data - { - let start = PoolSnapshot::dummy(*start_ts, *start_sats, *start_tokens); - let end = PoolSnapshot::dummy(*end_ts, *end_sats, *end_tokens); - - let period = PoolPeriod::new(start, end).unwrap(); - let duration_days = (*end_ts - *start_ts) as f64 / 86400.0; - - match period.yield_and_apy(&None) { - Ok((pool_yield, apy)) => { - println!( - "Pool {} ({:.1} days): yield={}, apy={}", - pool_name, duration_days, pool_yield, apy - ); - periods.push(period); - } - Err(e) => { - panic!( - "Pool {} ({:.1} days) FAILED: {} (start: sats={}, tokens={}, end: sats={}, tokens={})", - pool_name, duration_days, e, start_sats, start_tokens, end_sats, end_tokens - ); - } - } - } - - // Now test aggregation - this is where the real error occurs - // BUG: The aggregation fails because pool 0b36662c has: - // - yield = 52.5% (from full period) - // - but duration_days uses period_start, making it ~10.9 days - // - years_elapsed = 365.25 / 10.9 = 33.4 - // - APY = (1.525^33.4 - 1) * 100 = OVERFLOW! - // - // This test should pass once the bug is fixed. - let period_start = Some(1766414517u64); // NOW - 30 days - let result = APYAggregator::aggregate_apy(periods.into_iter(), period_start); - - assert!( - result.is_ok(), - "BUG: Aggregation should not overflow - got error: {:?}", - result.err() - ); - let apy = result.unwrap(); - println!("Aggregated APY: {}", apy); - } - - #[test] - fn test_apy_short_period_returns_zero_apy() { - // Periods shorter than 6 hours should return zero APY (to avoid powd blowups) - let start = PoolSnapshot::dummy(1727201259, 1648241, 116674414); - let end = PoolSnapshot::dummy(1727201259 + 3600, 1659000, 115000000); // 1 hour later - - let period = PoolPeriod::new(start, end).unwrap(); - let (pool_yield, pool_apy) = period.yield_and_apy(&None).unwrap(); - - // Pool yield should be calculated - assert!(!pool_yield.is_zero() || pool_yield.is_zero()); // yield is calculated - - // APY should be zero for short periods - assert!( - pool_apy.is_zero(), - "APY should be zero for periods < 6 hours, got: {}", - pool_apy - ); - } - #[test] fn test_apy_timestamp_order_validation() { // Test that start timestamp > end timestamp is rejected