From 950a5b834e0a9eaf7fa78ddd517838d856071b93 Mon Sep 17 00:00:00 2001 From: Andrea Franz Date: Thu, 20 Aug 2026 12:55:48 +0000 Subject: [PATCH] feat(stablecoin): implement accrue_stability_fee host function closes #169 --- .../stablecoin/src/accrue_stability_fee.rs | 374 ++++++++++++++++++ programs/stablecoin/src/lib.rs | 6 + programs/stablecoin/src/test_support.rs | 218 ++++++++++ 3 files changed, 598 insertions(+) create mode 100644 programs/stablecoin/src/accrue_stability_fee.rs create mode 100644 programs/stablecoin/src/test_support.rs diff --git a/programs/stablecoin/src/accrue_stability_fee.rs b/programs/stablecoin/src/accrue_stability_fee.rs new file mode 100644 index 0000000..b08b014 --- /dev/null +++ b/programs/stablecoin/src/accrue_stability_fee.rs @@ -0,0 +1,374 @@ +//! Host-side implementation of `Instruction::AccrueStabilityFee` (spec §10.2). +//! +//! Wall-clock time comes from the system `CLOCK_01` account, same as +//! [`crate::initialize_program`] — the pinned `spel-framework`'s +//! `ProgramContext` exposes no clock. + +use clock_core::{ClockAccountData, CLOCK_01_PROGRAM_ACCOUNT_ID}; +use nssa_core::{ + account::{Account, AccountWithMetadata, Data}, + program::{AccountPostState, ChainedCall, ProgramId}, +}; +use stablecoin_core::{ + compute_stability_fee_accumulator_pda, math::compute_current_accumulated_rate, + ProtocolParameters, StabilityFeeAccumulator, +}; + +/// Advance the global stability-fee accumulator to the clock's timestamp. +/// +/// Permissionless and idempotent: there is NO minimum-interval throttle (spec +/// §10.2, §5.3, §6.1). The read-side projection keeps every position current +/// regardless of cadence, so a redundant call is a harmless no-op that just +/// re-stamps `last_accrued_at`. Never blocked by the frozen flag. +/// +/// See spec §10.2 for the full account contract and panic conditions. +#[allow(clippy::needless_pass_by_value)] +pub fn accrue_stability_fee( + caller: AccountWithMetadata, + protocol_parameters: AccountWithMetadata, + stability_fee_accumulator: AccountWithMetadata, + clock: AccountWithMetadata, + stablecoin_program_id: ProgramId, +) -> (Vec, Vec) { + assert!(caller.is_authorized, "Caller authorization is missing"); + + let (params, accumulator) = decode_fee_accrual_inputs( + &protocol_parameters, + &stability_fee_accumulator, + stablecoin_program_id, + ); + let now = read_clock(&clock); + + let accumulator_post = + advance_fee_accumulator(&stability_fee_accumulator, ¶ms, &accumulator, now); + + let post_states = vec![ + AccountPostState::new(caller.account), + AccountPostState::new(protocol_parameters.account), + AccountPostState::new(accumulator_post), + AccountPostState::new(clock.account), + ]; + + (post_states, vec![]) +} + +/// Validate and decode the two accounts the fee half needs. +/// +/// Shared with [`crate::refresh_globals`] so the combined poke can never drift +/// from the standalone one. +pub(crate) fn decode_fee_accrual_inputs( + protocol_parameters: &AccountWithMetadata, + stability_fee_accumulator: &AccountWithMetadata, + stablecoin_program_id: ProgramId, +) -> (ProtocolParameters, StabilityFeeAccumulator) { + assert_ne!( + protocol_parameters.account, + Account::default(), + "ProtocolParameters account must be initialized" + ); + assert_eq!( + protocol_parameters.account.program_owner, stablecoin_program_id, + "ProtocolParameters not owned by this stablecoin program" + ); + assert_ne!( + stability_fee_accumulator.account, + Account::default(), + "StabilityFeeAccumulator account must be initialized" + ); + assert_eq!( + stability_fee_accumulator.account.program_owner, stablecoin_program_id, + "StabilityFeeAccumulator not owned by this stablecoin program" + ); + assert_eq!( + stability_fee_accumulator.account_id, + compute_stability_fee_accumulator_pda(stablecoin_program_id), + "StabilityFeeAccumulator account ID does not match expected PDA derivation" + ); + + let params = ProtocolParameters::try_from(&protocol_parameters.account.data) + .expect("ProtocolParameters must decode"); + let accumulator = StabilityFeeAccumulator::try_from(&stability_fee_accumulator.account.data) + .expect("StabilityFeeAccumulator must decode"); + + (params, accumulator) +} + +/// Project the accumulator to `now` and return the updated account. +/// +/// Shared with [`crate::refresh_globals`] — the fee half is identical in both. +pub(crate) fn advance_fee_accumulator( + stability_fee_accumulator: &AccountWithMetadata, + params: &ProtocolParameters, + accumulator: &StabilityFeeAccumulator, + now: u64, +) -> Account { + let updated = StabilityFeeAccumulator { + accumulated_rate_at_last_accrual: compute_current_accumulated_rate( + accumulator.accumulated_rate_at_last_accrual, + params.stability_fee_per_millisecond, + accumulator.last_accrued_at, + now, + ), + last_accrued_at: now, + }; + + let mut accumulator_post = stability_fee_accumulator.account.clone(); + accumulator_post.data = Data::from(&updated); + accumulator_post +} + +/// Read the millisecond wall-clock timestamp from the system `CLOCK_01` account. +/// +/// Shared by all three pokes. +pub(crate) fn read_clock(clock: &AccountWithMetadata) -> u64 { + assert_eq!( + clock.account_id, CLOCK_01_PROGRAM_ACCOUNT_ID, + "Clock account must be the system CLOCK_01 account" + ); + assert_ne!( + clock.account, + Account::default(), + "Clock account must be initialized" + ); + ClockAccountData::from_bytes(clock.account.data.as_ref()).timestamp +} + +#[cfg(test)] +#[allow( + clippy::indexing_slicing, + clippy::panic, + clippy::unwrap_used, + reason = "tests deliberately panic on bad state via assert!/#[should_panic] and index fixed-size vectors" +)] +mod tests { + use nssa_core::account::{AccountId, Nonce}; + use stablecoin_core::math::FIXED_POINT_ONE; + + use super::*; + use crate::test_support::{ + accumulator_account, caller_account, clock_account, protocol_parameters_account, + uninitialized, ParameterOverrides, ACCUMULATOR_ANCHOR, NOW, STABLECOIN_PROGRAM_ID, T0, + TEST_STABILITY_FEE_PER_MILLISECOND, + }; + + fn invoke() -> (Vec, Vec) { + accrue_stability_fee( + caller_account(), + protocol_parameters_account(ParameterOverrides::default()), + accumulator_account(ACCUMULATOR_ANCHOR, T0), + clock_account(NOW), + STABLECOIN_PROGRAM_ID, + ) + } + + #[test] + fn happy_path_returns_four_post_states_and_no_chained_calls() { + let (post_states, chained_calls) = invoke(); + assert_eq!(post_states.len(), 4); + assert!(chained_calls.is_empty()); + } + + #[test] + fn happy_path_advances_accumulator_by_the_projection() { + let (post_states, _) = invoke(); + let decoded = + StabilityFeeAccumulator::try_from(&post_states[2].account().data).expect("decode"); + let expected = compute_current_accumulated_rate( + ACCUMULATOR_ANCHOR, + TEST_STABILITY_FEE_PER_MILLISECOND, + T0, + NOW, + ); + assert_eq!(decoded.accumulated_rate_at_last_accrual, expected); + assert!(decoded.accumulated_rate_at_last_accrual > ACCUMULATOR_ANCHOR); + assert_eq!(decoded.last_accrued_at, NOW); + } + + #[test] + fn happy_path_claims_no_pda_and_echoes_the_clock() { + // The accumulator PDA was claimed at initialize_program time; this poke + // only rewrites its data. + let (post_states, _) = invoke(); + assert_eq!(post_states[2].required_claim(), None); + assert_eq!(post_states[3].required_claim(), None); + assert_eq!(post_states[3].account(), &clock_account(NOW).account); + } + + #[test] + fn no_throttle_zero_elapsed_is_a_noop_restamp() { + // There is NO minimum-interval throttle: calling with + // `last_accrued_at == now` does not panic. Zero elapsed time collapses to + // the compound_rate identity, so the anchor is unchanged. + let (post_states, _) = accrue_stability_fee( + caller_account(), + protocol_parameters_account(ParameterOverrides::default()), + accumulator_account(ACCUMULATOR_ANCHOR, NOW), + clock_account(NOW), + STABLECOIN_PROGRAM_ID, + ); + let decoded = StabilityFeeAccumulator::try_from(&post_states[2].account().data).unwrap(); + assert_eq!(decoded.accumulated_rate_at_last_accrual, ACCUMULATOR_ANCHOR); + assert_eq!(decoded.last_accrued_at, NOW); + } + + #[test] + fn accrual_is_allowed_while_frozen() { + // Pokes are never blocked by the frozen flag (spec §10.2). + let (post_states, _) = accrue_stability_fee( + caller_account(), + protocol_parameters_account(ParameterOverrides { + is_frozen: true, + ..ParameterOverrides::default() + }), + accumulator_account(ACCUMULATOR_ANCHOR, T0), + clock_account(NOW), + STABLECOIN_PROGRAM_ID, + ); + let decoded = StabilityFeeAccumulator::try_from(&post_states[2].account().data).unwrap(); + assert_eq!(decoded.last_accrued_at, NOW); + } + + #[test] + #[should_panic(expected = "Caller authorization is missing")] + fn requires_caller_authorization() { + let mut caller = caller_account(); + caller.is_authorized = false; + let _ = accrue_stability_fee( + caller, + protocol_parameters_account(ParameterOverrides::default()), + accumulator_account(ACCUMULATOR_ANCHOR, T0), + clock_account(NOW), + STABLECOIN_PROGRAM_ID, + ); + } + + #[test] + #[should_panic(expected = "ProtocolParameters account must be initialized")] + fn rejects_uninitialized_protocol_parameters() { + let _ = accrue_stability_fee( + caller_account(), + uninitialized(stablecoin_core::compute_protocol_parameters_pda( + STABLECOIN_PROGRAM_ID, + )), + accumulator_account(ACCUMULATOR_ANCHOR, T0), + clock_account(NOW), + STABLECOIN_PROGRAM_ID, + ); + } + + #[test] + #[should_panic(expected = "ProtocolParameters not owned by this stablecoin program")] + fn rejects_foreign_owned_protocol_parameters() { + let mut parameters = protocol_parameters_account(ParameterOverrides::default()); + parameters.account.program_owner = [9u32; 8]; + let _ = accrue_stability_fee( + caller_account(), + parameters, + accumulator_account(ACCUMULATOR_ANCHOR, T0), + clock_account(NOW), + STABLECOIN_PROGRAM_ID, + ); + } + + #[test] + #[should_panic(expected = "StabilityFeeAccumulator account must be initialized")] + fn rejects_uninitialized_accumulator() { + let _ = accrue_stability_fee( + caller_account(), + protocol_parameters_account(ParameterOverrides::default()), + uninitialized(compute_stability_fee_accumulator_pda(STABLECOIN_PROGRAM_ID)), + clock_account(NOW), + STABLECOIN_PROGRAM_ID, + ); + } + + #[test] + #[should_panic(expected = "StabilityFeeAccumulator not owned by this stablecoin program")] + fn rejects_foreign_owned_accumulator() { + let mut accumulator = accumulator_account(ACCUMULATOR_ANCHOR, T0); + accumulator.account.program_owner = [9u32; 8]; + let _ = accrue_stability_fee( + caller_account(), + protocol_parameters_account(ParameterOverrides::default()), + accumulator, + clock_account(NOW), + STABLECOIN_PROGRAM_ID, + ); + } + + #[test] + #[should_panic( + expected = "StabilityFeeAccumulator account ID does not match expected PDA derivation" + )] + fn rejects_wrong_accumulator_pda() { + let mut accumulator = accumulator_account(ACCUMULATOR_ANCHOR, T0); + accumulator.account_id = AccountId::new([0xDE; 32]); + let _ = accrue_stability_fee( + caller_account(), + protocol_parameters_account(ParameterOverrides::default()), + accumulator, + clock_account(NOW), + STABLECOIN_PROGRAM_ID, + ); + } + + #[test] + #[should_panic(expected = "Clock account must be the system CLOCK_01 account")] + fn rejects_wrong_clock_account() { + let mut clock = clock_account(NOW); + clock.account_id = AccountId::new([0xC1; 32]); + let _ = accrue_stability_fee( + caller_account(), + protocol_parameters_account(ParameterOverrides::default()), + accumulator_account(ACCUMULATOR_ANCHOR, T0), + clock, + STABLECOIN_PROGRAM_ID, + ); + } + + #[test] + #[should_panic(expected = "Clock account must be initialized")] + fn rejects_uninitialized_clock_account() { + let clock = AccountWithMetadata { + account: Account::default(), + is_authorized: false, + account_id: CLOCK_01_PROGRAM_ACCOUNT_ID, + }; + let _ = accrue_stability_fee( + caller_account(), + protocol_parameters_account(ParameterOverrides::default()), + accumulator_account(ACCUMULATOR_ANCHOR, T0), + clock, + STABLECOIN_PROGRAM_ID, + ); + } + + #[test] + fn a_zero_fee_rate_leaves_the_anchor_untouched() { + // FIXED_POINT_ONE is "no drift": the accumulator only re-stamps. + let (post_states, _) = accrue_stability_fee( + caller_account(), + protocol_parameters_account(ParameterOverrides { + stability_fee_per_millisecond: FIXED_POINT_ONE, + ..ParameterOverrides::default() + }), + accumulator_account(ACCUMULATOR_ANCHOR, T0), + clock_account(NOW), + STABLECOIN_PROGRAM_ID, + ); + let decoded = StabilityFeeAccumulator::try_from(&post_states[2].account().data).unwrap(); + assert_eq!(decoded.accumulated_rate_at_last_accrual, ACCUMULATOR_ANCHOR); + assert_eq!(decoded.last_accrued_at, NOW); + } + + #[test] + fn accumulator_post_state_preserves_owner_and_nonce() { + let (post_states, _) = invoke(); + let original = accumulator_account(ACCUMULATOR_ANCHOR, T0); + assert_eq!( + post_states[2].account().program_owner, + original.account.program_owner + ); + assert_eq!(post_states[2].account().nonce, Nonce(0)); + } +} diff --git a/programs/stablecoin/src/lib.rs b/programs/stablecoin/src/lib.rs index dc4388a..6247bcc 100644 --- a/programs/stablecoin/src/lib.rs +++ b/programs/stablecoin/src/lib.rs @@ -2,6 +2,9 @@ pub use stablecoin_core as core; +/// Permissionless poke: advance the global stability-fee accumulator. +pub mod accrue_stability_fee; + /// Bootstrap the protocol: create the global PDAs and the stablecoin definition. pub mod initialize_program; @@ -14,5 +17,8 @@ pub mod repay_debt; /// Withdraw collateral from an existing position back to a user-controlled holding. pub mod withdraw_collateral; +#[cfg(test)] +mod test_support; + #[cfg(test)] mod tests; diff --git a/programs/stablecoin/src/test_support.rs b/programs/stablecoin/src/test_support.rs new file mode 100644 index 0000000..af7b30d --- /dev/null +++ b/programs/stablecoin/src/test_support.rs @@ -0,0 +1,218 @@ +//! Shared account builders for the poke host-function unit tests. +//! +//! The three pokes (`accrue_stability_fee`, `update_redemption_rate`, +//! `refresh_globals`) validate overlapping account sets, so their tests would +//! otherwise repeat the same fixtures three times. + +use clock_core::{ClockAccountData, CLOCK_01_PROGRAM_ACCOUNT_ID}; +use nssa_core::{ + account::{Account, AccountId, AccountWithMetadata, Data, Nonce}, + program::ProgramId, +}; +use stablecoin_core::{ + compute_protocol_parameters_pda, compute_redemption_price_state_pda, + compute_stability_fee_accumulator_pda, math::FIXED_POINT_ONE, ProtocolParameters, + RedemptionPriceState, StabilityFeeAccumulator, +}; +use twap_oracle_core::OraclePriceAccount; + +pub(crate) const STABLECOIN_PROGRAM_ID: ProgramId = [3u32; 8]; +pub(crate) const ORACLE_PROGRAM_ID: ProgramId = [4u32; 8]; +pub(crate) const CLOCK_PROGRAM_ID: ProgramId = [5u32; 8]; + +/// Timestamps are Unix milliseconds, matching the `CLOCK_01` account. +pub(crate) const T0: u64 = 1_700_000_000_000; +/// Ten minutes after `T0` — past the 300_000 ms rate-update interval below. +pub(crate) const NOW: u64 = T0 + 600_000; + +/// ~5% annual in per-millisecond fixed-point, the same realistic rate the +/// `initialize_program` tests use. +pub(crate) const TEST_STABILITY_FEE_PER_MILLISECOND: u128 = FIXED_POINT_ONE + 1_500_000_000_000_000; +pub(crate) const TEST_MINIMUM_MILLISECONDS_BETWEEN_RATE_UPDATES: u64 = 300_000; +pub(crate) const TEST_MAXIMUM_ORACLE_PRICE_AGE_MILLISECONDS: u64 = 900_000; + +/// Starting accumulator anchor: `1.0`. +pub(crate) const ACCUMULATOR_ANCHOR: u128 = FIXED_POINT_ONE; +/// Starting redemption price: `0.5` collateral per stablecoin. +pub(crate) const REDEMPTION_PRICE_ANCHOR: u128 = FIXED_POINT_ONE / 2; +/// A market price below the redemption anchor, so `error > 0` and a positive +/// `Kp` drives the rate above `FIXED_POINT_ONE`. +pub(crate) const MARKET_PRICE_BELOW_ANCHOR: u128 = FIXED_POINT_ONE / 4; + +/// Knobs the poke tests actually vary. Everything else is fixed. +pub(crate) struct ParameterOverrides { + pub(crate) stability_fee_per_millisecond: u128, + pub(crate) controller_proportional_gain: i128, + pub(crate) controller_integral_gain: i128, + pub(crate) is_frozen: bool, +} + +impl Default for ParameterOverrides { + fn default() -> Self { + Self { + stability_fee_per_millisecond: TEST_STABILITY_FEE_PER_MILLISECOND, + controller_proportional_gain: FIXED_POINT_ONE as i128, + controller_integral_gain: 0, + is_frozen: false, + } + } +} + +pub(crate) fn caller_id() -> AccountId { + AccountId::new([0xCA; 32]) +} +pub(crate) fn admin_id() -> AccountId { + AccountId::new([0xA0; 32]) +} +pub(crate) fn freeze_authority_id() -> AccountId { + AccountId::new([0xFE; 32]) +} +pub(crate) fn stablecoin_definition_id() -> AccountId { + AccountId::new([0x10; 32]) +} +pub(crate) fn collateral_definition_id() -> AccountId { + AccountId::new([0x20; 32]) +} +pub(crate) fn oracle_id() -> AccountId { + AccountId::new([0x30; 32]) +} +pub(crate) fn oracle_source_id() -> AccountId { + AccountId::new([0x31; 32]) +} + +pub(crate) fn protocol_parameters_id() -> AccountId { + compute_protocol_parameters_pda(STABLECOIN_PROGRAM_ID) +} +pub(crate) fn accumulator_id() -> AccountId { + compute_stability_fee_accumulator_pda(STABLECOIN_PROGRAM_ID) +} +pub(crate) fn redemption_price_state_id() -> AccountId { + compute_redemption_price_state_pda(STABLECOIN_PROGRAM_ID) +} + +/// An authorized caller. Pokes are permissionless, so any signer works. +pub(crate) fn caller_account() -> AccountWithMetadata { + AccountWithMetadata { + account: Account::default(), + is_authorized: true, + account_id: caller_id(), + } +} + +/// An uninitialized account at `account_id` — the shape every "must be +/// initialized" assertion rejects. +pub(crate) fn uninitialized(account_id: AccountId) -> AccountWithMetadata { + AccountWithMetadata { + account: Account::default(), + is_authorized: false, + account_id, + } +} + +pub(crate) fn protocol_parameters_account(overrides: ParameterOverrides) -> AccountWithMetadata { + AccountWithMetadata { + account: Account { + program_owner: STABLECOIN_PROGRAM_ID, + balance: 0, + data: Data::from(&ProtocolParameters { + admin_account_id: admin_id(), + freeze_authority_account_id: freeze_authority_id(), + stablecoin_definition_id: stablecoin_definition_id(), + collateral_definition_id: collateral_definition_id(), + market_price_oracle_id: oracle_id(), + stability_fee_per_millisecond: overrides.stability_fee_per_millisecond, + controller_proportional_gain: overrides.controller_proportional_gain, + controller_integral_gain: overrides.controller_integral_gain, + minimum_collateralization_ratio: FIXED_POINT_ONE * 3 / 2, + minimum_milliseconds_between_rate_updates: + TEST_MINIMUM_MILLISECONDS_BETWEEN_RATE_UPDATES, + maximum_oracle_price_age_milliseconds: TEST_MAXIMUM_ORACLE_PRICE_AGE_MILLISECONDS, + is_frozen: overrides.is_frozen, + }), + nonce: Nonce(0), + }, + is_authorized: false, + account_id: protocol_parameters_id(), + } +} + +pub(crate) fn accumulator_account(anchor: u128, last_accrued_at: u64) -> AccountWithMetadata { + AccountWithMetadata { + account: Account { + program_owner: STABLECOIN_PROGRAM_ID, + balance: 0, + data: Data::from(&StabilityFeeAccumulator { + accumulated_rate_at_last_accrual: anchor, + last_accrued_at, + }), + nonce: Nonce(0), + }, + is_authorized: false, + account_id: accumulator_id(), + } +} + +pub(crate) fn redemption_price_state_account(last_updated_at: u64) -> AccountWithMetadata { + redemption_price_state_account_with_integral_term(last_updated_at, 0) +} + +pub(crate) fn redemption_price_state_account_with_integral_term( + last_updated_at: u64, + controller_integral_term: i128, +) -> AccountWithMetadata { + AccountWithMetadata { + account: Account { + program_owner: STABLECOIN_PROGRAM_ID, + balance: 0, + data: Data::from(&RedemptionPriceState { + redemption_price_at_last_update: REDEMPTION_PRICE_ANCHOR, + redemption_rate_per_millisecond: FIXED_POINT_ONE, + controller_integral_term, + last_updated_at, + }), + nonce: Nonce(0), + }, + is_authorized: false, + account_id: redemption_price_state_id(), + } +} + +pub(crate) fn oracle_account(timestamp: u64, price: u128) -> AccountWithMetadata { + AccountWithMetadata { + account: Account { + program_owner: ORACLE_PROGRAM_ID, + balance: 0, + data: Data::from(&OraclePriceAccount { + base_asset: stablecoin_definition_id(), + quote_asset: collateral_definition_id(), + price, + timestamp, + source_id: oracle_source_id(), + confidence_interval: 0, + }), + nonce: Nonce(0), + }, + is_authorized: false, + account_id: oracle_id(), + } +} + +pub(crate) fn clock_account(timestamp: u64) -> AccountWithMetadata { + AccountWithMetadata { + account: Account { + program_owner: CLOCK_PROGRAM_ID, + balance: 0, + data: Data::try_from( + ClockAccountData { + block_id: 0, + timestamp, + } + .to_bytes(), + ) + .expect("clock data fits"), + nonce: Nonce(0), + }, + is_authorized: false, + account_id: CLOCK_01_PROGRAM_ACCOUNT_ID, + } +}