Checkpoint?

This commit is contained in:
Jakob Notland 2026-03-16 14:44:22 +01:00
parent 9130c43dbf
commit 5d18524fa3
2 changed files with 178 additions and 2 deletions

View file

@ -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))

View file

@ -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