From 6faea82e728369eaf5ba2d9b4f3cfb97c50c4b00 Mon Sep 17 00:00:00 2001 From: Andrea Franz Date: Thu, 20 Aug 2026 13:12:48 +0000 Subject: [PATCH] feat(stablecoin): wire poke guest entries + e2e tests closes #172 --- artifacts/stablecoin-idl.json | 108 +++++++ .../integration_tests/tests/stablecoin.rs | 284 +++++++++++++++++- .../methods/guest/src/bin/stablecoin.rs | 110 +++++++ 3 files changed, 491 insertions(+), 11 deletions(-) diff --git a/artifacts/stablecoin-idl.json b/artifacts/stablecoin-idl.json index 7c8b3ca..edcd7be 100644 --- a/artifacts/stablecoin-idl.json +++ b/artifacts/stablecoin-idl.json @@ -99,6 +99,114 @@ } ] }, + { + "name": "accrue_stability_fee", + "accounts": [ + { + "name": "caller", + "writable": false, + "signer": true, + "init": false + }, + { + "name": "protocol_parameters", + "writable": false, + "signer": false, + "init": false + }, + { + "name": "stability_fee_accumulator", + "writable": true, + "signer": false, + "init": false + }, + { + "name": "clock", + "writable": false, + "signer": false, + "init": false + } + ], + "args": [] + }, + { + "name": "update_redemption_rate", + "accounts": [ + { + "name": "caller", + "writable": false, + "signer": true, + "init": false + }, + { + "name": "protocol_parameters", + "writable": false, + "signer": false, + "init": false + }, + { + "name": "redemption_price_state", + "writable": true, + "signer": false, + "init": false + }, + { + "name": "market_price_oracle", + "writable": false, + "signer": false, + "init": false + }, + { + "name": "clock", + "writable": false, + "signer": false, + "init": false + } + ], + "args": [] + }, + { + "name": "refresh_globals", + "accounts": [ + { + "name": "caller", + "writable": false, + "signer": true, + "init": false + }, + { + "name": "protocol_parameters", + "writable": false, + "signer": false, + "init": false + }, + { + "name": "stability_fee_accumulator", + "writable": true, + "signer": false, + "init": false + }, + { + "name": "redemption_price_state", + "writable": true, + "signer": false, + "init": false + }, + { + "name": "market_price_oracle", + "writable": false, + "signer": false, + "init": false + }, + { + "name": "clock", + "writable": false, + "signer": false, + "init": false + } + ], + "args": [] + }, { "name": "open_position", "accounts": [ diff --git a/programs/integration_tests/tests/stablecoin.rs b/programs/integration_tests/tests/stablecoin.rs index 6614a4f..d9b0ccc 100644 --- a/programs/integration_tests/tests/stablecoin.rs +++ b/programs/integration_tests/tests/stablecoin.rs @@ -1,5 +1,6 @@ use clock_core::{ClockAccountData, CLOCK_01_PROGRAM_ACCOUNT_ID}; use nssa::{ + error::LeeError, program_deployment_transaction::{self, ProgramDeploymentTransaction}, public_transaction, PrivateKey, PublicKey, PublicTransaction, V03State, }; @@ -201,14 +202,30 @@ impl Accounts { } fn oracle_init(base_asset: AccountId, quote_asset: AccountId) -> Account { + Self::oracle_with( + base_asset, + quote_asset, + stablecoin_core::math::FIXED_POINT_ONE / 2, + 0, + ) + } + + /// An oracle observation at an explicit price and timestamp — the poke tests + /// need both to control the controller's error term and the freshness gate. + fn oracle_with( + base_asset: AccountId, + quote_asset: AccountId, + price: u128, + timestamp: u64, + ) -> Account { Account { program_owner: [9u32; 8], balance: 0_u128, data: Data::from(&twap_oracle_core::OraclePriceAccount { base_asset, quote_asset, - price: stablecoin_core::math::FIXED_POINT_ONE / 2, - timestamp: 0, + price, + timestamp, source_id: Ids::oracle(), confidence_interval: 0, }), @@ -463,14 +480,30 @@ fn stablecoin_repay_debt_burns_stablecoins_and_decreases_debt() { } } -#[test] -fn stablecoin_initialize_program_creates_globals_and_stablecoin_definition() { +/// Protocol parameters the initialized-protocol helper installs. Kept as +/// constants so the poke tests can reason about the interval / staleness gates. +mod protocol_config { + use stablecoin_core::math::FIXED_POINT_ONE; + + /// ~5% annual, expressed per millisecond. + pub(super) const STABILITY_FEE_PER_MILLISECOND: u128 = FIXED_POINT_ONE + 1_500_000_000_000_000; + pub(super) const MINIMUM_MILLISECONDS_BETWEEN_RATE_UPDATES: u64 = 300_000; + pub(super) const MAXIMUM_ORACLE_PRICE_AGE_MILLISECONDS: u64 = 900_000; + pub(super) const INITIAL_REDEMPTION_PRICE: u128 = FIXED_POINT_ONE / 2; +} + +/// Deploys both programs and runs `InitializeProgram` at `now`, leaving a fully +/// bootstrapped protocol. Shared by the init test and all three poke tests. +/// +/// `controller_proportional_gain` is a parameter because the poke tests need a +/// live controller (a zero gain pins the redemption rate at `FIXED_POINT_ONE`, +/// which would make the update assertions vacuous). +fn initialize_protocol(now: u64, controller_proportional_gain: i128) -> V03State { use stablecoin_core::math::FIXED_POINT_ONE; // `V03State::new()` no longer auto-creates the clock account; seed CLOCK_01 with this // timestamp, which initialize_program reads as `now` to anchor the accumulator and // redemption-price state. - let now: u64 = 1_700_000_000; let mut state = V03State::new(); seed_clock(&mut state, now); deploy_programs(&mut state); @@ -490,13 +523,15 @@ fn stablecoin_initialize_program_creates_globals_and_stablecoin_definition() { let instruction = stablecoin_core::Instruction::InitializeProgram { freeze_authority_account_id: Ids::freeze_authority(), - initial_stability_fee_per_millisecond: FIXED_POINT_ONE + 1_500_000_000_000_000, - initial_controller_proportional_gain: 0, + initial_stability_fee_per_millisecond: protocol_config::STABILITY_FEE_PER_MILLISECOND, + initial_controller_proportional_gain: controller_proportional_gain, initial_controller_integral_gain: 0, initial_minimum_collateralization_ratio: FIXED_POINT_ONE * 3 / 2, - minimum_milliseconds_between_rate_updates: 300_000, - maximum_oracle_price_age_milliseconds: 900_000, - initial_redemption_price: FIXED_POINT_ONE / 2, + minimum_milliseconds_between_rate_updates: + protocol_config::MINIMUM_MILLISECONDS_BETWEEN_RATE_UPDATES, + maximum_oracle_price_age_milliseconds: + protocol_config::MAXIMUM_ORACLE_PRICE_AGE_MILLISECONDS, + initial_redemption_price: protocol_config::INITIAL_REDEMPTION_PRICE, stablecoin_name: String::from("test-stable"), }; @@ -516,13 +551,68 @@ fn stablecoin_initialize_program_creates_globals_and_stablecoin_definition() { vec![current_nonce(&state, Ids::admin())], instruction, ) - .unwrap(); + .expect("valid initialize_program message"); let witness_set = public_transaction::WitnessSet::for_message(&message, &[&Keys::admin()]); let tx = PublicTransaction::new(message, witness_set); state .transition_from_public_transaction(&tx, 1, now) .expect("initialize_program must succeed"); + state +} + +/// Submits a no-parameter poke signed by the admin (pokes are permissionless — +/// the admin key is just a convenient signer) and advances the clock to `now` +/// first, so the guest reads the intended timestamp. +fn submit_poke( + state: &mut V03State, + now: u64, + block_id: u64, + instruction: stablecoin_core::Instruction, + accounts: Vec, +) -> Result<(), LeeError> { + seed_clock(state, now); + let mut account_ids = vec![Ids::admin()]; + account_ids.extend(accounts); + let message = public_transaction::Message::try_new( + Ids::stablecoin_program(), + account_ids, + vec![current_nonce(state, Ids::admin())], + instruction, + ) + .expect("valid poke message"); + let witness_set = public_transaction::WitnessSet::for_message(&message, &[&Keys::admin()]); + let tx = PublicTransaction::new(message, witness_set); + state.transition_from_public_transaction(&tx, block_id, now) +} + +fn read_accumulator(state: &V03State) -> stablecoin_core::StabilityFeeAccumulator { + stablecoin_core::StabilityFeeAccumulator::try_from( + &state + .get_account_by_id(compute_stability_fee_accumulator_pda( + Ids::stablecoin_program(), + )) + .data, + ) + .expect("valid StabilityFeeAccumulator") +} + +fn read_redemption_price_state(state: &V03State) -> stablecoin_core::RedemptionPriceState { + stablecoin_core::RedemptionPriceState::try_from( + &state + .get_account_by_id(compute_redemption_price_state_pda(Ids::stablecoin_program())) + .data, + ) + .expect("valid RedemptionPriceState") +} + +#[test] +fn stablecoin_initialize_program_creates_globals_and_stablecoin_definition() { + use stablecoin_core::math::FIXED_POINT_ONE; + + let now: u64 = 1_700_000_000; + let state = initialize_protocol(now, 0); + // ProtocolParameters claimed with the expected handles. let pp = stablecoin_core::ProtocolParameters::try_from( &state @@ -610,3 +700,175 @@ fn stablecoin_initialize_program_creates_globals_and_stablecoin_definition() { } } } + +#[test] +fn stablecoin_accrue_stability_fee_advances_accumulator() { + use stablecoin_core::math::FIXED_POINT_ONE; + + let start: u64 = 1_700_000_000_000; + let mut state = initialize_protocol(start, 0); + + let before = read_accumulator(&state); + assert_eq!(before.accumulated_rate_at_last_accrual, FIXED_POINT_ONE); + assert_eq!(before.last_accrued_at, start); + + // Accrual has no throttle, so any positive delta works; one hour, in ms. + let now = start + 3_600_000; + submit_poke( + &mut state, + now, + 2, + stablecoin_core::Instruction::AccrueStabilityFee, + vec![ + compute_protocol_parameters_pda(Ids::stablecoin_program()), + compute_stability_fee_accumulator_pda(Ids::stablecoin_program()), + CLOCK_01_PROGRAM_ACCOUNT_ID, + ], + ) + .expect("accrue_stability_fee must succeed"); + + let after = read_accumulator(&state); + assert_eq!( + after.accumulated_rate_at_last_accrual, + stablecoin_core::math::compute_current_accumulated_rate( + FIXED_POINT_ONE, + protocol_config::STABILITY_FEE_PER_MILLISECOND, + start, + now, + ), + ); + assert!(after.accumulated_rate_at_last_accrual > FIXED_POINT_ONE); + assert_eq!(after.last_accrued_at, now); +} + +#[test] +fn stablecoin_update_redemption_rate_drifts_redemption_price() { + use stablecoin_core::math::FIXED_POINT_ONE; + + let start: u64 = 1_700_000_000_000; + // A live proportional gain, otherwise the rate would stay pinned at 1.0. + let mut state = initialize_protocol( + start, + i128::try_from(FIXED_POINT_ONE).expect("FIXED_POINT_ONE fits i128"), + ); + + // Ten minutes later — past the 300_000 ms minimum interval. + let now = start + 600_000; + + // A fresh observation well below the 0.5 redemption target, so + // error = redemption − market > 0. + state.force_insert_account( + Ids::oracle(), + Accounts::oracle_with( + Ids::stablecoin_definition_pda(), + Ids::collateral_definition(), + FIXED_POINT_ONE / 4, + now, + ), + ); + + submit_poke( + &mut state, + now, + 2, + stablecoin_core::Instruction::UpdateRedemptionRate, + vec![ + compute_protocol_parameters_pda(Ids::stablecoin_program()), + compute_redemption_price_state_pda(Ids::stablecoin_program()), + Ids::oracle(), + CLOCK_01_PROGRAM_ACCOUNT_ID, + ], + ) + .expect("update_redemption_rate must succeed"); + + let after = read_redemption_price_state(&state); + // Positive error with a positive Kp drives the rate ABOVE 1.0: the redemption + // price rises, pulling the market up toward it (negative feedback, no negation). + assert!(after.redemption_rate_per_millisecond > FIXED_POINT_ONE); + // The rate change is capped per update by RATE_DELTA_CLAMP. + assert_eq!( + after.redemption_rate_per_millisecond, + FIXED_POINT_ONE + + u128::try_from(stablecoin_core::RATE_DELTA_CLAMP) + .expect("RATE_DELTA_CLAMP is positive"), + ); + // Re-anchored at the price projected from the OLD rate (exactly 1.0, so the + // anchor is unchanged) and stamped with now. + assert_eq!( + after.redemption_price_at_last_update, + protocol_config::INITIAL_REDEMPTION_PRICE + ); + assert_eq!(after.last_updated_at, now); +} + +#[test] +fn stablecoin_refresh_globals_advances_both_then_fee_only_when_oracle_stale() { + use stablecoin_core::math::FIXED_POINT_ONE; + + let start: u64 = 1_700_000_000_000; + let mut state = initialize_protocol( + start, + i128::try_from(FIXED_POINT_ONE).expect("FIXED_POINT_ONE fits i128"), + ); + + let refresh_accounts = || { + vec![ + compute_protocol_parameters_pda(Ids::stablecoin_program()), + compute_stability_fee_accumulator_pda(Ids::stablecoin_program()), + compute_redemption_price_state_pda(Ids::stablecoin_program()), + Ids::oracle(), + CLOCK_01_PROGRAM_ACCOUNT_ID, + ] + }; + + // --- Pass 1: fresh oracle, interval due => BOTH halves run. --- + let first = start + 600_000; + state.force_insert_account( + Ids::oracle(), + Accounts::oracle_with( + Ids::stablecoin_definition_pda(), + Ids::collateral_definition(), + FIXED_POINT_ONE / 4, + first, + ), + ); + + submit_poke( + &mut state, + first, + 2, + stablecoin_core::Instruction::RefreshGlobals, + refresh_accounts(), + ) + .expect("refresh_globals must advance both halves"); + + let accumulator_after_first = read_accumulator(&state); + let redemption_after_first = read_redemption_price_state(&state); + assert_eq!(accumulator_after_first.last_accrued_at, first); + assert!(accumulator_after_first.accumulated_rate_at_last_accrual > FIXED_POINT_ONE); + assert!(redemption_after_first.redemption_rate_per_millisecond > FIXED_POINT_ONE); + assert_eq!(redemption_after_first.last_updated_at, first); + + // --- Pass 2: the oracle observation is left where it was and the clock jumps + // well past the max age => only the fee half runs, and the call still succeeds. --- + let second = first + 2_000_000; // > the 900_000 ms maximum oracle age + submit_poke( + &mut state, + second, + 3, + stablecoin_core::Instruction::RefreshGlobals, + refresh_accounts(), + ) + .expect("refresh_globals must still succeed with a stale oracle"); + + let accumulator_after_second = read_accumulator(&state); + let redemption_after_second = read_redemption_price_state(&state); + // Fee half ran again. + assert_eq!(accumulator_after_second.last_accrued_at, second); + assert!( + accumulator_after_second.accumulated_rate_at_last_accrual + > accumulator_after_first.accumulated_rate_at_last_accrual + ); + // Redemption half skipped without panicking — byte-identical to pass 1. + assert_eq!(redemption_after_second, redemption_after_first); +} diff --git a/programs/stablecoin/methods/guest/src/bin/stablecoin.rs b/programs/stablecoin/methods/guest/src/bin/stablecoin.rs index c4b47ed..1164e40 100644 --- a/programs/stablecoin/methods/guest/src/bin/stablecoin.rs +++ b/programs/stablecoin/methods/guest/src/bin/stablecoin.rs @@ -83,6 +83,116 @@ mod stablecoin { )) } + /// Permissionless poke: advance the stability-fee accumulator (spec §10.2; + /// host fn `stablecoin_program::accrue_stability_fee`). + /// + /// Wall-clock time is read from the system `CLOCK_01` account passed as the + /// 4th input — the pinned `ProgramContext` exposes no clock. + /// + /// # Errors + /// Returns the host program's panic-converted error if any precondition + /// fails — see the host fn for the full list. + #[instruction] + pub fn accrue_stability_fee( + ctx: ProgramContext, + #[account(signer)] + caller: AccountWithMetadata, + protocol_parameters: AccountWithMetadata, + #[account(mut)] + stability_fee_accumulator: AccountWithMetadata, + clock: AccountWithMetadata, + ) -> SpelResult { + let (post_states, chained_calls) = + stablecoin_program::accrue_stability_fee::accrue_stability_fee( + caller, + protocol_parameters, + stability_fee_accumulator, + clock, + ctx.self_program_id, + ); + Ok(spel_framework::SpelOutput::execute( + post_states, + chained_calls, + )) + } + + /// Permissionless poke: run one redemption-rate controller tick and + /// re-anchor the redemption price (spec §10.3; host fn + /// `stablecoin_program::update_redemption_rate`). + /// + /// Strict: panics on a not-yet-due interval or a stale / zero-price oracle. + /// Wall-clock time is read from the system `CLOCK_01` account passed as the + /// 5th input. + /// + /// # Errors + /// Returns the host program's panic-converted error if any precondition + /// fails — see the host fn for the full list. + #[instruction] + pub fn update_redemption_rate( + ctx: ProgramContext, + #[account(signer)] + caller: AccountWithMetadata, + protocol_parameters: AccountWithMetadata, + #[account(mut)] + redemption_price_state: AccountWithMetadata, + market_price_oracle: AccountWithMetadata, + clock: AccountWithMetadata, + ) -> SpelResult { + let (post_states, chained_calls) = + stablecoin_program::update_redemption_rate::update_redemption_rate( + caller, + protocol_parameters, + redemption_price_state, + market_price_oracle, + clock, + ctx.self_program_id, + ); + Ok(spel_framework::SpelOutput::execute( + post_states, + chained_calls, + )) + } + + /// Permissionless combined poke: always accrue the stability fee, and run + /// the redemption-rate tick too when its interval is due and the oracle is + /// fresh — best-effort, skipping rather than panicking (spec §10.3a; host fn + /// `stablecoin_program::refresh_globals`). + /// + /// A LEZ transaction carries exactly one instruction, so this is the only + /// way to advance both globals at once. Wall-clock time is read from the + /// system `CLOCK_01` account passed as the 6th input. + /// + /// # Errors + /// Returns the host program's panic-converted error if any precondition + /// fails — see the host fn for the full list. + #[instruction] + pub fn refresh_globals( + ctx: ProgramContext, + #[account(signer)] + caller: AccountWithMetadata, + protocol_parameters: AccountWithMetadata, + #[account(mut)] + stability_fee_accumulator: AccountWithMetadata, + #[account(mut)] + redemption_price_state: AccountWithMetadata, + market_price_oracle: AccountWithMetadata, + clock: AccountWithMetadata, + ) -> SpelResult { + let (post_states, chained_calls) = stablecoin_program::refresh_globals::refresh_globals( + caller, + protocol_parameters, + stability_fee_accumulator, + redemption_price_state, + market_price_oracle, + clock, + ctx.self_program_id, + ); + Ok(spel_framework::SpelOutput::execute( + post_states, + chained_calls, + )) + } + /// Open a new collateral-only position for the calling owner. /// /// # Errors