From 5d18524fa3fe2b09c703d55d4c9da73af031ede6 Mon Sep 17 00:00:00 2001 From: Jakob Notland Date: Mon, 16 Mar 2026 14:44:22 +0100 Subject: [PATCH 1/2] Checkpoint? --- src/db/cauldron/pool.rs | 28 ++++++- src/rpc/apy/poolperiod.rs | 152 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 178 insertions(+), 2 deletions(-) diff --git a/src/db/cauldron/pool.rs b/src/db/cauldron/pool.rs index 96b3992..5cfd741 100644 --- a/src/db/cauldron/pool.rs +++ b/src/db/cauldron/pool.rs @@ -331,6 +331,7 @@ async fn get_nearest_entries( SELECT creation_utxo FROM pool WHERE {extra_filters} + AND withdrawn_in_utxo IS NULL ) GROUP BY phe.pool HAVING MAX({ts}) @@ -344,6 +345,7 @@ async fn get_nearest_entries( SELECT creation_utxo FROM pool WHERE {extra_filters} + AND withdrawn_in_utxo IS NULL ) GROUP BY phe.pool HAVING MIN({ts}) @@ -492,9 +494,10 @@ pub async fn get_pool_period_snapshot( let mut pools_end = get_nearest_entries(pool, end, token_id, owner_pkh, SnapshotSelection::UseAfter).await?; + let window_end = end as u64; let mut pools: Vec<(PoolSnapshot, PoolSnapshot)> = Vec::default(); for (start_pool_id, start_pool) in pools_start { - let end_pool = match pools_end.remove(&start_pool_id) { + let mut end_pool = match pools_end.remove(&start_pool_id) { Some(end) => end, None => { warn!("Found no 'end pool' for {start_pool_id}"); @@ -502,6 +505,21 @@ pub async fn get_pool_period_snapshot( } }; + // Clamp the end snapshot timestamp to the window boundary. + // For dormant pools, the nearest entry before `end` may have a timestamp far + // in the past (e.g. the pool's only trade was hours after creation). Without + // clamping, yield would be annualised over that short active window rather than + // the full dormancy period, grossly inflating the reported APY. + // + // Guard: only clamp when the end snapshot is actually a newer state than the start. + // If both snapshots point to the same pre-window trade (pool had no activity + // during the window), their timestamps are equal and duration stays zero → + // the pool is correctly excluded. Without this guard, clamping would give those + // pools a non-zero duration and a zero yield, diluting the AAPY of active tokens. + if end_pool.timestamp < window_end && end_pool.timestamp > start_pool.timestamp { + end_pool.timestamp = window_end; + } + let duration = end_pool.timestamp.saturating_sub(start_pool.timestamp); if !duration.is_zero() { pools.push((start_pool, end_pool)) @@ -606,9 +624,10 @@ pub async fn get_pool_period_snapshot_by_pool_ids( get_nearest_entries_by_pool_ids(pool, end, &pool_id_blobs, SnapshotSelection::UseAfter) .await?; + let window_end = end as u64; let mut pools: Vec<(PoolSnapshot, PoolSnapshot)> = Vec::default(); for (start_pool_id, start_pool) in pools_start { - let end_pool = match pools_end.remove(&start_pool_id) { + let mut end_pool = match pools_end.remove(&start_pool_id) { Some(end) => end, None => { warn!("Found no 'end pool' for {start_pool_id}"); @@ -616,6 +635,11 @@ pub async fn get_pool_period_snapshot_by_pool_ids( } }; + // Clamp end snapshot to window boundary — see comment in get_pool_period_snapshot. + if end_pool.timestamp < window_end && end_pool.timestamp > start_pool.timestamp { + end_pool.timestamp = window_end; + } + let duration = end_pool.timestamp.saturating_sub(start_pool.timestamp); if !duration.is_zero() { pools.push((start_pool, end_pool)) diff --git a/src/rpc/apy/poolperiod.rs b/src/rpc/apy/poolperiod.rs index c703546..8724dd7 100644 --- a/src/rpc/apy/poolperiod.rs +++ b/src/rpc/apy/poolperiod.rs @@ -501,6 +501,158 @@ mod tests { ); } + #[test] + fn test_dormant_pool_apy_diluted_by_full_window() { + // Regression for: dormant pools reporting inflated APY because the end snapshot + // timestamp was the last trade (hours after creation) instead of the window end. + // + // Scenario matching the issue example: + // - Pool created ~2026-03-03, one trade ~2h later, dormant since. + // - Query window: start=2026-03-03T00:00:00, end=2026-03-13T00:00:00 (10 days). + // - Before fix: duration ≈ 2h → huge annualised APY (~67%). + // - After fix: end.timestamp clamped to window_end → duration ≈ 10 days → ~0.04%. + // + // This test simulates the post-fix behaviour: end snapshot timestamp = window_end. + use crate::rpc::apy::apyaggregator::APYAggregator; + + let creation_ts: u64 = 1741046400; // 2026-03-04 00:00:00 UTC + let trade_ts: u64 = creation_ts + 7200; // 2h after creation + let window_end: u64 = creation_ts + 10 * 86400; // 10 days later + + // One fee-earning trade: trader sends 1000 sats, pool gains sats and loses tokens. + // K_start = 1_000_000 * 500_000 = 500_000_000_000 + // K_end = 1_001_000 * 499_501 = 500_000_501_000 (tiny fee growth) + let start = PoolSnapshot::dummy(creation_ts, 1_000_000, 500_000); + let end_unclamped = PoolSnapshot::dummy(trade_ts, 1_001_000, 499_501); + + // Before fix: end.timestamp = trade_ts (2h after creation) + let period_short = PoolPeriod::new(start.clone(), end_unclamped).unwrap(); + let apy_inflated = + APYAggregator::aggregate_apy(std::iter::once(period_short), Some(creation_ts)) + .unwrap(); + + // After fix: end.timestamp = window_end (10 days after creation) + let end_clamped = PoolSnapshot { + timestamp: window_end, + ..PoolSnapshot::dummy(trade_ts, 1_001_000, 499_501) + }; + let period_full = PoolPeriod::new(start, end_clamped).unwrap(); + let apy_correct = + APYAggregator::aggregate_apy(std::iter::once(period_full), Some(creation_ts)) + .unwrap(); + + // The inflated APY should be dramatically higher than the corrected one. + // Rough expectation: inflated ≈ 200x the correct value given 10 days vs 2 hours. + assert!( + apy_inflated > apy_correct * dec!(100), + "expected inflated APY ({apy_inflated}) >> correct APY ({apy_correct})" + ); + // Corrected APY should be very small (much less than 1%) + assert!( + apy_correct < dec!(1), + "expected corrected APY < 1%, got {apy_correct}" + ); + } + + #[test] + fn test_pre_window_dormant_pool_excluded_not_diluting_active_pool() { + // A pool whose last trade was BEFORE the window start should be excluded (duration=0). + // The guard `end.timestamp > start.timestamp` ensures the clamp doesn't fire + // when both snapshots resolve to the same pre-window entry. + // Without the guard, such pools gain a non-zero duration with zero yield and + // dilute the AAPY of genuinely active tokens. + use crate::rpc::apy::apyaggregator::APYAggregator; + + let window_start: u64 = 1_741_046_400; // 2026-03-04 + let window_end: u64 = window_start + 30 * 86400; + + // Active pool: real fee income throughout the window. + let active_start = PoolSnapshot::dummy(window_start - 3600, 1_000_000, 500_000); + let active_end = PoolSnapshot::dummy(window_end - 3600, 1_001_000, 499_501); + let active = + PoolPeriod::new(active_start, active_end).unwrap(); + let apy_active_only = + APYAggregator::aggregate_apy(std::iter::once(active.clone()), Some(window_start)) + .unwrap(); + + // Pre-window-dormant pool: last trade 45 days ago, same state for both snapshots. + // The clamp guard should prevent this from contributing. + // Simulate: both start and end snapshots point to same pre-window entry + // (identical timestamp), which is what get_pool_period_snapshot would return. + let dormant_ts = window_start - 45 * 86400; + let dormant_start = PoolSnapshot::dummy(dormant_ts, 2_000_000, 1_000_000); + let dormant_end = PoolSnapshot::dummy(dormant_ts, 2_000_000, 1_000_000); // same state + + // duration = 0 → PoolPeriod::new is still valid (start == end allowed), but + // the aggregator skips zero-duration periods. + let dormant = PoolPeriod::new(dormant_start, dormant_end).unwrap(); + let apy_with_dormant = APYAggregator::aggregate_apy( + vec![active, dormant].into_iter(), + Some(window_start), + ) + .unwrap(); + + // The pre-window dormant pool contributes nothing; APY should be unchanged. + assert_eq!( + apy_active_only, apy_with_dormant, + "pre-window dormant pool must not dilute APY: {apy_active_only} vs {apy_with_dormant}" + ); + } + + #[test] + fn test_partially_active_pool_trailing_silence_included_in_duration() { + // Pool traded actively during the window but stopped 5 days before window end. + // Without the clamp the trailing 5 silent days are missing from duration, + // making the annualisation exponent too large and inflating APY. + // After the clamp (end.timestamp = window_end), those days are included + // and the APY is lower. + // + // This covers the common real-world case where a pool is not fully dormant + // but simply had its last trade a few days before the query time. + use crate::rpc::apy::apyaggregator::APYAggregator; + + let window_start: u64 = 1_740_000_000; + let window_end: u64 = window_start + 30 * 86400; // 30-day window + let last_trade_ts: u64 = window_end - 5 * 86400; // last trade 5 days before end + + // Pool existed before window; start snapshot is from 10 days before window_start. + let start = PoolSnapshot::dummy(window_start - 10 * 86400, 1_000_000, 500_000); + // One fee-earning trade within the window (K grows slightly). + let last_trade_snap = PoolSnapshot::dummy(last_trade_ts, 1_001_000, 499_501); + + // Without fix: end.timestamp = last_trade_ts (5 days short of window_end) + let period_unclamped = + PoolPeriod::new(start.clone(), last_trade_snap.clone()).unwrap(); + let apy_unclamped = APYAggregator::aggregate_apy( + std::iter::once(period_unclamped), + Some(window_start), + ) + .unwrap(); + + // With fix: end.timestamp = window_end (5 extra days included) + let end_clamped = PoolSnapshot { + timestamp: window_end, + ..last_trade_snap + }; + let period_clamped = PoolPeriod::new(start, end_clamped).unwrap(); + let apy_clamped = + APYAggregator::aggregate_apy(std::iter::once(period_clamped), Some(window_start)) + .unwrap(); + + // Longer duration → lower APY for the same yield + assert!( + apy_clamped < apy_unclamped, + "clamped APY ({apy_clamped}) should be lower than unclamped ({apy_unclamped})" + ); + // Both should be positive — there was real fee income + assert!(apy_clamped > dec!(0), "APY should be positive, got {apy_clamped}"); + // The difference should be meaningful (5/30 ≈ 17% longer duration) + assert!( + apy_unclamped > apy_clamped * dec!(1.1), + "expected unclamped APY to be at least 10% higher than clamped" + ); + } + #[test] fn test_apy_timestamp_order_validation() { // Test that start timestamp > end timestamp is rejected From b46271d6bd010a6713df6e503fe19d527cfe92d3 Mon Sep 17 00:00:00 2001 From: Jakob Notland Date: Tue, 17 Mar 2026 08:53:55 +0100 Subject: [PATCH 2/2] Format --- src/rpc/apy/poolperiod.rs | 33 ++++++++++++++------------------- 1 file changed, 14 insertions(+), 19 deletions(-) diff --git a/src/rpc/apy/poolperiod.rs b/src/rpc/apy/poolperiod.rs index 8724dd7..24b88fd 100644 --- a/src/rpc/apy/poolperiod.rs +++ b/src/rpc/apy/poolperiod.rs @@ -528,8 +528,7 @@ mod tests { // Before fix: end.timestamp = trade_ts (2h after creation) let period_short = PoolPeriod::new(start.clone(), end_unclamped).unwrap(); let apy_inflated = - APYAggregator::aggregate_apy(std::iter::once(period_short), Some(creation_ts)) - .unwrap(); + APYAggregator::aggregate_apy(std::iter::once(period_short), Some(creation_ts)).unwrap(); // After fix: end.timestamp = window_end (10 days after creation) let end_clamped = PoolSnapshot { @@ -538,8 +537,7 @@ mod tests { }; let period_full = PoolPeriod::new(start, end_clamped).unwrap(); let apy_correct = - APYAggregator::aggregate_apy(std::iter::once(period_full), Some(creation_ts)) - .unwrap(); + APYAggregator::aggregate_apy(std::iter::once(period_full), Some(creation_ts)).unwrap(); // The inflated APY should be dramatically higher than the corrected one. // Rough expectation: inflated ≈ 200x the correct value given 10 days vs 2 hours. @@ -569,8 +567,7 @@ mod tests { // Active pool: real fee income throughout the window. let active_start = PoolSnapshot::dummy(window_start - 3600, 1_000_000, 500_000); let active_end = PoolSnapshot::dummy(window_end - 3600, 1_001_000, 499_501); - let active = - PoolPeriod::new(active_start, active_end).unwrap(); + let active = PoolPeriod::new(active_start, active_end).unwrap(); let apy_active_only = APYAggregator::aggregate_apy(std::iter::once(active.clone()), Some(window_start)) .unwrap(); @@ -586,11 +583,9 @@ mod tests { // duration = 0 → PoolPeriod::new is still valid (start == end allowed), but // the aggregator skips zero-duration periods. let dormant = PoolPeriod::new(dormant_start, dormant_end).unwrap(); - let apy_with_dormant = APYAggregator::aggregate_apy( - vec![active, dormant].into_iter(), - Some(window_start), - ) - .unwrap(); + let apy_with_dormant = + APYAggregator::aggregate_apy(vec![active, dormant].into_iter(), Some(window_start)) + .unwrap(); // The pre-window dormant pool contributes nothing; APY should be unchanged. assert_eq!( @@ -621,13 +616,10 @@ mod tests { let last_trade_snap = PoolSnapshot::dummy(last_trade_ts, 1_001_000, 499_501); // Without fix: end.timestamp = last_trade_ts (5 days short of window_end) - let period_unclamped = - PoolPeriod::new(start.clone(), last_trade_snap.clone()).unwrap(); - let apy_unclamped = APYAggregator::aggregate_apy( - std::iter::once(period_unclamped), - Some(window_start), - ) - .unwrap(); + let period_unclamped = PoolPeriod::new(start.clone(), last_trade_snap.clone()).unwrap(); + let apy_unclamped = + APYAggregator::aggregate_apy(std::iter::once(period_unclamped), Some(window_start)) + .unwrap(); // With fix: end.timestamp = window_end (5 extra days included) let end_clamped = PoolSnapshot { @@ -645,7 +637,10 @@ mod tests { "clamped APY ({apy_clamped}) should be lower than unclamped ({apy_unclamped})" ); // Both should be positive — there was real fee income - assert!(apy_clamped > dec!(0), "APY should be positive, got {apy_clamped}"); + assert!( + apy_clamped > dec!(0), + "APY should be positive, got {apy_clamped}" + ); // The difference should be meaningful (5/30 ≈ 17% longer duration) assert!( apy_unclamped > apy_clamped * dec!(1.1),