diff --git a/docs/stablecoin/README.md b/docs/stablecoin/README.md index fe3f1c1..6906267 100644 --- a/docs/stablecoin/README.md +++ b/docs/stablecoin/README.md @@ -602,7 +602,7 @@ These are properties the protocol maintains across every state-changing instruct | `RATE_DELTA_CLAMP` | `± FIXED_POINT_ONE / 100_000` | Max single-update per-ms rate adjustment (≈±0.001%); rescaled `÷10^3` to ms so a ~1/sec keeper cadence still caps near ±1%/s. Constant in v1. | | `minimum_milliseconds_between_rate_updates` | `1 ≤ x ≤ 86_400_000` | Min 1 ms (no zero-spam), max 1 day (RFP F2 "rate updates within a small number of blocks"). | | `maximum_oracle_price_age_milliseconds` | `1 ≤ x ≤ 86_400_000` | Stale beyond a day is obviously bad; aggressive freshness is a tuning parameter. | -| `MAXIMUM_COMPOUNDING_WINDOW_MILLISECONDS` | TBD — `≥` the expected worst-case keeper-poke gap (candidate range `86_400_000`–`604_800_000`, i.e. 1–7 days) | Hard cap on the `dt` passed to `compound_rate` (§5.3), bounding worst-case `rate ^ dt` regardless of the rate value. Necessary because no rate bound alone makes overflow impossible over an unbounded window. Trade-off: fee accrual and redemption drift pause beyond the window under total keeper neglect. Exact value pending tuning. | +| `MAXIMUM_COMPOUNDING_WINDOW_MILLISECONDS` | `604_800_000` (7 days) | Hard cap on the `dt` passed to `compound_rate` (§5.3), bounding worst-case `rate ^ dt` regardless of the rate value. Necessary because no rate bound alone makes overflow impossible over an unbounded window. Trade-off: fee accrual and redemption drift pause beyond the window under total keeper neglect. Seven days is the top of the range the design considered — it maximises tolerance of keeper neglect while staying far from overflow for any realistic rate (a ~5%/yr fee is `1 + 1.5×10^-12` per ms, giving a `1.0009×` factor over the full window; overflow would need a rate above ~`1 + 4×10^-8` per ms). Constant in v1. | | `initial_redemption_price` | `> 0` | Must be positive; admin chooses an initial value reflecting the launch peg target. | All bounds enforced in `initialize_program` and the corresponding `set_*` instruction. diff --git a/programs/stablecoin/core/src/math.rs b/programs/stablecoin/core/src/math.rs index 720048a..cacba28 100644 --- a/programs/stablecoin/core/src/math.rs +++ b/programs/stablecoin/core/src/math.rs @@ -11,6 +11,15 @@ use alloy_primitives::U256; /// Rate fields store `actual_value * FIXED_POINT_ONE`. pub const FIXED_POINT_ONE: u128 = 10u128.pow(27); +/// Hard cap on the elapsed window (in milliseconds) fed to [`compound_rate`]. +/// +/// Seven days. [`compound_rate`] is not self-bounding: over an unbounded window +/// any rate above [`FIXED_POINT_ONE`] eventually overflows `u128`, so the +/// projection helpers clamp `Δt` to this constant before compounding (spec +/// §5.3, §8). Seven days comfortably covers realistic keeper-poke gaps; beyond +/// it, fee accrual and redemption drift pause until someone pokes. +pub const MAXIMUM_COMPOUNDING_WINDOW_MILLISECONDS: u64 = 604_800_000; + /// `(a * b) / c` computed via `U256` intermediates and rounded toward zero. /// /// # Panics @@ -95,6 +104,51 @@ pub fn compound_rate(per_millisecond_rate: u128, milliseconds_elapsed: u64) -> u result } +/// Project the stability fee accumulator from its persisted anchor to `now`. +/// +/// `current = anchor × compound_rate(rate, min(now − last_accrued_at, +/// MAXIMUM_COMPOUNDING_WINDOW_MILLISECONDS)) / FIXED_POINT_ONE` +/// +/// `Δt` (milliseconds) uses `saturating_sub` so monotonic time is not assumed, +/// then is clamped to [`MAXIMUM_COMPOUNDING_WINDOW_MILLISECONDS`]. That clamp is +/// what prevents [`compound_rate`] from overflowing over an unbounded poke gap — +/// the §8 rate bound alone does not (spec §5.2 / §5.3). +#[must_use] +pub fn compute_current_accumulated_rate( + accumulated_rate_at_last_accrual: u128, + stability_fee_per_millisecond: u128, + last_accrued_at: u64, + now: u64, +) -> u128 { + let elapsed = now + .saturating_sub(last_accrued_at) + .min(MAXIMUM_COMPOUNDING_WINDOW_MILLISECONDS); + let factor = compound_rate(stability_fee_per_millisecond, elapsed); + mul_div(accumulated_rate_at_last_accrual, factor, FIXED_POINT_ONE) +} + +/// Project the redemption price from its persisted anchor to `now`. +/// +/// `current = anchor × compound_rate(rate_per_millisecond, min(now − last_updated_at, +/// MAXIMUM_COMPOUNDING_WINDOW_MILLISECONDS)) / FIXED_POINT_ONE` +/// +/// Same `Δt` clamp rationale as [`compute_current_accumulated_rate`]: +/// `redemption_rate_per_millisecond` can also exceed `1.0`, so the window cap is +/// load-bearing for overflow safety. +#[must_use] +pub fn compute_current_redemption_price( + redemption_price_at_last_update: u128, + redemption_rate_per_millisecond: u128, + last_updated_at: u64, + now: u64, +) -> u128 { + let elapsed = now + .saturating_sub(last_updated_at) + .min(MAXIMUM_COMPOUNDING_WINDOW_MILLISECONDS); + let factor = compound_rate(redemption_rate_per_millisecond, elapsed); + mul_div(redemption_price_at_last_update, factor, FIXED_POINT_ONE) +} + #[cfg(test)] mod tests { use super::*; @@ -210,4 +264,139 @@ mod tests { assert!(result < FIXED_POINT_ONE); assert!(result > 0); } + + #[test] + fn maximum_compounding_window_is_seven_days() { + assert_eq!( + MAXIMUM_COMPOUNDING_WINDOW_MILLISECONDS, + 7 * 24 * 60 * 60 * 1_000 + ); + } + + #[test] + fn current_accumulated_rate_zero_elapsed_returns_anchor() { + let anchor = FIXED_POINT_ONE * 12345 / 10000; // 1.2345 + assert_eq!( + compute_current_accumulated_rate(anchor, FIXED_POINT_ONE + 10u128.pow(20), 100, 100), + anchor, + ); + } + + #[test] + fn current_accumulated_rate_no_drift_returns_anchor() { + let anchor = FIXED_POINT_ONE * 12345 / 10000; + for &(last_accrued_at, now) in &[ + (0u64, 60u64), + (1_000, 86_400), + (1_700_000_000, 1_700_000_000 + 31_536_000), + ] { + assert_eq!( + compute_current_accumulated_rate(anchor, FIXED_POINT_ONE, last_accrued_at, now), + anchor, + "no-drift at (last_accrued_at={last_accrued_at}, now={now}) should return anchor", + ); + } + } + + #[test] + fn current_accumulated_rate_now_before_last_accrued_returns_anchor() { + let anchor = FIXED_POINT_ONE; + // `now < last_accrued_at` saturates the elapsed window to 0, so the + // projection collapses to the anchor. + assert_eq!( + compute_current_accumulated_rate(anchor, FIXED_POINT_ONE * 2, 1_000_000, 100), + anchor, + ); + } + + #[test] + fn current_accumulated_rate_one_millisecond_equals_compound_factor() { + let anchor = FIXED_POINT_ONE; + let rate = FIXED_POINT_ONE + 10u128.pow(20); // 1.0000001 + let projected = compute_current_accumulated_rate(anchor, rate, 0, 1); + // With anchor = FIXED_POINT_ONE, current at 1 ms = anchor * rate / FIXED_POINT_ONE = rate. + assert_eq!(projected, rate); + } + + #[test] + fn current_accumulated_rate_growth_is_monotonic() { + let anchor = FIXED_POINT_ONE; + let rate = FIXED_POINT_ONE + 10u128.pow(20); + let mut prev = anchor; + for millis in 0..30u64 { + let now = compute_current_accumulated_rate(anchor, rate, 0, millis); + assert!(now >= prev, "regression at millis={millis}"); + prev = now; + } + } + + #[test] + fn current_accumulated_rate_clamps_elapsed_to_maximum_window() { + let anchor = FIXED_POINT_ONE; + // A realistic ~5% annual rate: 1 + 1.5e-12 per millisecond. + let rate = FIXED_POINT_ONE + 1_500_000_000_000_000; + let at_window = compute_current_accumulated_rate( + anchor, + rate, + 0, + MAXIMUM_COMPOUNDING_WINDOW_MILLISECONDS, + ); + // Anything past the window projects to exactly the same value — accrual + // pauses rather than compounding an unbounded exponent. + for &now in &[ + MAXIMUM_COMPOUNDING_WINDOW_MILLISECONDS + 1, + MAXIMUM_COMPOUNDING_WINDOW_MILLISECONDS * 1_000, + u64::MAX, + ] { + assert_eq!( + compute_current_accumulated_rate(anchor, rate, 0, now), + at_window, + "elapsed window not clamped at now={now}", + ); + } + assert!(at_window > anchor); + } + + #[test] + fn current_redemption_price_zero_elapsed_returns_anchor() { + let anchor = FIXED_POINT_ONE / 2; + assert_eq!( + compute_current_redemption_price(anchor, FIXED_POINT_ONE + 10u128.pow(20), 100, 100), + anchor, + ); + } + + #[test] + fn current_redemption_price_decay_below_one_shrinks_anchor() { + let anchor = FIXED_POINT_ONE / 2; + let rate = FIXED_POINT_ONE - 10u128.pow(20); // < 1 + let projected = compute_current_redemption_price(anchor, rate, 0, 100); + assert!(projected < anchor); + assert!(projected > 0); + } + + #[test] + fn current_redemption_price_growth_above_one_grows_anchor() { + let anchor = FIXED_POINT_ONE / 2; + let rate = FIXED_POINT_ONE + 10u128.pow(20); // > 1 + let projected = compute_current_redemption_price(anchor, rate, 0, 100); + assert!(projected > anchor); + } + + #[test] + fn current_redemption_price_clamps_elapsed_to_maximum_window() { + let anchor = FIXED_POINT_ONE; + let rate = FIXED_POINT_ONE - 1_500_000_000_000_000; // < 1, decaying + let at_window = compute_current_redemption_price( + anchor, + rate, + 0, + MAXIMUM_COMPOUNDING_WINDOW_MILLISECONDS, + ); + assert_eq!( + compute_current_redemption_price(anchor, rate, 0, u64::MAX), + at_window, + ); + assert!(at_window < anchor); + } }