Commit alternative solution for pool inflation

This commit is contained in:
jakobsn 2026-03-11 09:31:21 +00:00
parent 87ba024a04
commit 381cb94295
5 changed files with 429 additions and 235 deletions

View file

@ -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<u8>],
min_start_ts: i64,
end_ts: i64,
) -> Result<HashMap<String, Vec<InjectionRecord>>> {
if pool_id_blobs.is_empty() {
return Ok(HashMap::new());
}
let placeholders = (1..=pool_id_blobs.len())
.map(|i| format!("?{i}"))
.collect::<Vec<_>>()
.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<String, Vec<InjectionRecord>> = HashMap::new();
for row in rows {
let pool_blob: Vec<u8> = row.get(0);
let pool_id = blob_to_display_hex::<PoolID>(&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,

View file

@ -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<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)| {
@ -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::<Vec<_>>()
})
.unwrap_or_default();
split_at_injections(s, e, injections)
})
.collect::<Result<Vec<_>>>()?;
if periods.is_empty() {

View file

@ -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<I>(pools: I, period_start: Option<u64>) -> Result<Decimal>
where
I: Iterator<Item = PoolPeriod>,
{
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"))
}
}

View file

@ -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<Vec<u8>> = pool_snapshots
.iter()
.filter_map(|(s, _)| display_hex_to_blob::<PoolID>(&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<Vec<PoolPeriod>> = 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::<Vec<_>>()
})
.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)?;

View file

@ -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<Result<PoolPeriod>> {
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<u64>) -> Result<Decimal> {
// 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<Decimal> {
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<u64>) -> 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<PoolPeriod> = split_at_injections(start, end, vec![])
.into_iter()
.collect::<anyhow::Result<Vec<_>>>()
.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<PoolPeriod> = split_at_injections(start, end, vec![&inj])
.into_iter()
.collect::<anyhow::Result<Vec<_>>>()
.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<PoolPeriod> = split_at_injections(start, end, vec![&inj1, &inj2, &inj3])
.into_iter()
.collect::<anyhow::Result<Vec<_>>>()
.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<PoolPeriod> = split_at_injections(start, end, vec![&inj])
.into_iter()
.collect::<anyhow::Result<Vec<_>>>()
.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<PoolPeriod> = 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