diff --git a/programs/stablecoin/src/lib.rs b/programs/stablecoin/src/lib.rs index 5b43454..58e4c72 100644 --- a/programs/stablecoin/src/lib.rs +++ b/programs/stablecoin/src/lib.rs @@ -11,6 +11,9 @@ pub mod initialize_program; /// Open a new collateral-only position for a calling owner. pub mod open_position; +/// Permissionless combined poke: advance both globals, best-effort. +pub mod refresh_globals; + /// Repay outstanding stablecoin debt against an existing position. pub mod repay_debt; diff --git a/programs/stablecoin/src/refresh_globals.rs b/programs/stablecoin/src/refresh_globals.rs new file mode 100644 index 0000000..7289463 --- /dev/null +++ b/programs/stablecoin/src/refresh_globals.rs @@ -0,0 +1,387 @@ +//! Host-side implementation of `Instruction::RefreshGlobals` (spec §10.3a). +//! +//! Best-effort combined poke: ALWAYS advances the stability-fee accumulator +//! (§10.2); advances the redemption price (§10.3 / §6.4) ONLY when its interval +//! is due and the oracle is fresh and non-zero, skipping rather than panicking +//! otherwise. A LEZ transaction carries exactly one instruction, so this is the +//! only way to advance both globals in a single transaction. + +use nssa_core::{ + account::AccountWithMetadata, + program::{AccountPostState, ChainedCall, ProgramId}, +}; + +use crate::{ + accrue_stability_fee::{advance_fee_accumulator, decode_fee_accrual_inputs, read_clock}, + update_redemption_rate::{advance_redemption_price, decode_oracle, decode_redemption_inputs}, +}; + +/// Advance both globals in one instruction, best-effort. +/// +/// Every piece of math here is the shared helper the standalone pokes call, so +/// the combined path can never drift from them. +/// +/// Panics ONLY on caller authorization, an uninitialized / foreign-owned / +/// wrong-PDA global, an oracle id mismatch, or a wrong clock account. A +/// not-yet-due interval and a stale or zero-price oracle are SOFT — they skip +/// the redemption half instead of failing the transaction. Never blocked by the +/// frozen flag. +/// +/// See spec §10.3a for the full account contract. +#[allow(clippy::needless_pass_by_value)] +pub fn refresh_globals( + caller: AccountWithMetadata, + protocol_parameters: AccountWithMetadata, + stability_fee_accumulator: AccountWithMetadata, + redemption_price_state: AccountWithMetadata, + market_price_oracle: 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 (_, redemption) = decode_redemption_inputs( + &protocol_parameters, + &redemption_price_state, + stablecoin_program_id, + ); + let oracle = decode_oracle(&market_price_oracle, ¶ms); + let now = read_clock(&clock); + + // Fee half — always runs. It has no throttle and needs no oracle, so it + // makes progress even during an oracle outage. + let accumulator_post = + advance_fee_accumulator(&stability_fee_accumulator, ¶ms, &accumulator, now); + + // Redemption half — three soft gates. Failing any one of them leaves the + // redemption state untouched rather than panicking (the asymmetry with + // `update_redemption_rate` is intentional; spec §10.3a). + let interval_due = now.saturating_sub(redemption.last_updated_at) + >= params.minimum_milliseconds_between_rate_updates; + let oracle_fresh = + now.saturating_sub(oracle.timestamp) <= params.maximum_oracle_price_age_milliseconds; + let oracle_price_usable = oracle.price > 0; + + let redemption_post = if interval_due && oracle_fresh && oracle_price_usable { + advance_redemption_price( + &redemption_price_state, + ¶ms, + &redemption, + oracle.price, + now, + ) + } else { + redemption_price_state.account.clone() + }; + + let post_states = vec![ + AccountPostState::new(caller.account), + AccountPostState::new(protocol_parameters.account), + AccountPostState::new(accumulator_post), + AccountPostState::new(redemption_post), + AccountPostState::new(market_price_oracle.account), + AccountPostState::new(clock.account), + ]; + + (post_states, vec![]) +} + +#[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; + use stablecoin_core::{ + compute_redemption_price_state_pda, compute_stability_fee_accumulator_pda, + math::FIXED_POINT_ONE, RedemptionPriceState, StabilityFeeAccumulator, + }; + + use super::*; + use crate::test_support::{ + accumulator_account, caller_account, clock_account, oracle_account, + protocol_parameters_account, redemption_price_state_account, uninitialized, + ParameterOverrides, ACCUMULATOR_ANCHOR, MARKET_PRICE_BELOW_ANCHOR, NOW, + STABLECOIN_PROGRAM_ID, T0, + }; + + fn decode_accumulator(post: &AccountPostState) -> StabilityFeeAccumulator { + StabilityFeeAccumulator::try_from(&post.account().data).expect("decode accumulator") + } + + fn decode_redemption(post: &AccountPostState) -> RedemptionPriceState { + RedemptionPriceState::try_from(&post.account().data).expect("decode redemption state") + } + + /// The default call: interval due, oracle fresh and non-zero. + fn invoke_with( + overrides: ParameterOverrides, + redemption_last_updated_at: u64, + oracle_timestamp: u64, + oracle_price: u128, + ) -> (Vec, Vec) { + refresh_globals( + caller_account(), + protocol_parameters_account(overrides), + accumulator_account(ACCUMULATOR_ANCHOR, T0), + redemption_price_state_account(redemption_last_updated_at), + oracle_account(oracle_timestamp, oracle_price), + clock_account(NOW), + STABLECOIN_PROGRAM_ID, + ) + } + + fn invoke() -> (Vec, Vec) { + invoke_with( + ParameterOverrides::default(), + T0, + NOW, + MARKET_PRICE_BELOW_ANCHOR, + ) + } + + #[test] + fn both_halves_run_when_interval_due_and_oracle_fresh() { + let (post_states, chained_calls) = invoke(); + assert_eq!(post_states.len(), 6); + assert!(chained_calls.is_empty()); + + let accumulator = decode_accumulator(&post_states[2]); + assert!(accumulator.accumulated_rate_at_last_accrual > ACCUMULATOR_ANCHOR); + assert_eq!(accumulator.last_accrued_at, NOW); + + // Redemption > market with a positive Kp, so the rate rises above 1.0. + let redemption = decode_redemption(&post_states[3]); + assert!(redemption.redemption_rate_per_millisecond > FIXED_POINT_ONE); + assert_eq!(redemption.last_updated_at, NOW); + } + + #[test] + fn both_halves_match_the_standalone_pokes_exactly() { + // The combined poke must produce byte-identical state to running the two + // standalone pokes; they share the same helpers, and this pins it. + let (combined, _) = invoke(); + + let (accrue_only, _) = crate::accrue_stability_fee::accrue_stability_fee( + caller_account(), + protocol_parameters_account(ParameterOverrides::default()), + accumulator_account(ACCUMULATOR_ANCHOR, T0), + clock_account(NOW), + STABLECOIN_PROGRAM_ID, + ); + let (update_only, _) = crate::update_redemption_rate::update_redemption_rate( + caller_account(), + protocol_parameters_account(ParameterOverrides::default()), + redemption_price_state_account(T0), + oracle_account(NOW, MARKET_PRICE_BELOW_ANCHOR), + clock_account(NOW), + STABLECOIN_PROGRAM_ID, + ); + + assert_eq!(combined[2].account(), accrue_only[2].account()); + assert_eq!(combined[3].account(), update_only[2].account()); + } + + #[test] + fn redemption_half_skips_when_interval_not_elapsed_but_fee_still_runs() { + // 100_000 ms since the last update < the 300_000 ms minimum. + let recent = NOW - 100_000; + let (post_states, _) = invoke_with( + ParameterOverrides::default(), + recent, + NOW, + MARKET_PRICE_BELOW_ANCHOR, + ); + + assert_eq!(decode_accumulator(&post_states[2]).last_accrued_at, NOW); + + let redemption = decode_redemption(&post_states[3]); + assert_eq!(redemption.redemption_rate_per_millisecond, FIXED_POINT_ONE); + assert_eq!(redemption.last_updated_at, recent); + } + + #[test] + fn redemption_half_skips_when_oracle_is_stale_but_fee_still_runs() { + // 1_000_000 ms old > the 900_000 ms maximum age. + let (post_states, _) = invoke_with( + ParameterOverrides::default(), + T0, + NOW - 1_000_000, + MARKET_PRICE_BELOW_ANCHOR, + ); + + assert_eq!(decode_accumulator(&post_states[2]).last_accrued_at, NOW); + + let redemption = decode_redemption(&post_states[3]); + assert_eq!(redemption.redemption_rate_per_millisecond, FIXED_POINT_ONE); + assert_eq!(redemption.last_updated_at, T0); + } + + #[test] + fn redemption_half_skips_when_oracle_price_is_zero_but_fee_still_runs() { + let (post_states, _) = invoke_with(ParameterOverrides::default(), T0, NOW, 0); + + assert_eq!(decode_accumulator(&post_states[2]).last_accrued_at, NOW); + assert_eq!(decode_redemption(&post_states[3]).last_updated_at, T0); + } + + #[test] + fn a_skipped_redemption_half_leaves_the_account_byte_identical() { + let (post_states, _) = invoke_with(ParameterOverrides::default(), T0, NOW, 0); + assert_eq!( + post_states[3].account(), + &redemption_price_state_account(T0).account + ); + } + + #[test] + fn both_halves_run_while_frozen() { + // Pokes are never blocked by the frozen flag (spec §10.3a); the function + // does not read `is_frozen` at all. + let (post_states, _) = invoke_with( + ParameterOverrides { + is_frozen: true, + ..ParameterOverrides::default() + }, + T0, + NOW, + MARKET_PRICE_BELOW_ANCHOR, + ); + assert_eq!(decode_accumulator(&post_states[2]).last_accrued_at, NOW); + assert_eq!(decode_redemption(&post_states[3]).last_updated_at, NOW); + } + + #[test] + fn echoes_the_oracle_and_clock_unchanged_and_claims_no_pda() { + let (post_states, _) = invoke(); + assert_eq!(post_states[2].required_claim(), None); + assert_eq!(post_states[3].required_claim(), None); + assert_eq!( + post_states[4].account(), + &oracle_account(NOW, MARKET_PRICE_BELOW_ANCHOR).account + ); + assert_eq!(post_states[5].account(), &clock_account(NOW).account); + } + + #[test] + #[should_panic(expected = "Caller authorization is missing")] + fn requires_caller_authorization() { + let mut caller = caller_account(); + caller.is_authorized = false; + let _ = refresh_globals( + caller, + protocol_parameters_account(ParameterOverrides::default()), + accumulator_account(ACCUMULATOR_ANCHOR, T0), + redemption_price_state_account(T0), + oracle_account(NOW, MARKET_PRICE_BELOW_ANCHOR), + clock_account(NOW), + STABLECOIN_PROGRAM_ID, + ); + } + + #[test] + #[should_panic(expected = "ProtocolParameters account must be initialized")] + fn rejects_uninitialized_protocol_parameters() { + let _ = refresh_globals( + caller_account(), + uninitialized(stablecoin_core::compute_protocol_parameters_pda( + STABLECOIN_PROGRAM_ID, + )), + accumulator_account(ACCUMULATOR_ANCHOR, T0), + redemption_price_state_account(T0), + oracle_account(NOW, MARKET_PRICE_BELOW_ANCHOR), + clock_account(NOW), + STABLECOIN_PROGRAM_ID, + ); + } + + #[test] + #[should_panic(expected = "StabilityFeeAccumulator account must be initialized")] + fn rejects_uninitialized_accumulator() { + let _ = refresh_globals( + caller_account(), + protocol_parameters_account(ParameterOverrides::default()), + uninitialized(compute_stability_fee_accumulator_pda(STABLECOIN_PROGRAM_ID)), + redemption_price_state_account(T0), + oracle_account(NOW, MARKET_PRICE_BELOW_ANCHOR), + clock_account(NOW), + STABLECOIN_PROGRAM_ID, + ); + } + + #[test] + #[should_panic(expected = "RedemptionPriceState account must be initialized")] + fn rejects_uninitialized_redemption_price_state() { + let _ = refresh_globals( + caller_account(), + protocol_parameters_account(ParameterOverrides::default()), + accumulator_account(ACCUMULATOR_ANCHOR, T0), + uninitialized(compute_redemption_price_state_pda(STABLECOIN_PROGRAM_ID)), + oracle_account(NOW, MARKET_PRICE_BELOW_ANCHOR), + 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 _ = refresh_globals( + caller_account(), + protocol_parameters_account(ParameterOverrides::default()), + accumulator, + redemption_price_state_account(T0), + oracle_account(NOW, MARKET_PRICE_BELOW_ANCHOR), + clock_account(NOW), + STABLECOIN_PROGRAM_ID, + ); + } + + #[test] + #[should_panic( + expected = "Market price oracle account_id does not match ProtocolParameters.market_price_oracle_id" + )] + fn rejects_oracle_id_mismatch() { + // A wrong oracle account is a caller error, not a transient condition — + // so unlike staleness, this one panics here too. + let mut oracle = oracle_account(NOW, MARKET_PRICE_BELOW_ANCHOR); + oracle.account_id = AccountId::new([0xDE; 32]); + let _ = refresh_globals( + caller_account(), + protocol_parameters_account(ParameterOverrides::default()), + accumulator_account(ACCUMULATOR_ANCHOR, T0), + redemption_price_state_account(T0), + oracle, + 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 _ = refresh_globals( + caller_account(), + protocol_parameters_account(ParameterOverrides::default()), + accumulator_account(ACCUMULATOR_ANCHOR, T0), + redemption_price_state_account(T0), + oracle_account(NOW, MARKET_PRICE_BELOW_ANCHOR), + clock, + STABLECOIN_PROGRAM_ID, + ); + } +}