From e044bc9c700173749339f7d433d0bd152f02ebc7 Mon Sep 17 00:00:00 2001 From: Andrea Franz Date: Mon, 7 Sep 2026 08:34:30 +0000 Subject: [PATCH] feat(stablecoin): add generate_debt instruction closes #178 --- artifacts/stablecoin-idl.json | 65 +++++ programs/stablecoin/core/src/lib.rs | 24 ++ .../methods/guest/src/bin/stablecoin.rs | 50 ++++ programs/stablecoin/src/checks.rs | 28 ++ programs/stablecoin/src/generate_debt.rs | 206 +++++++++++++++ programs/stablecoin/src/lib.rs | 3 + programs/stablecoin/src/tests.rs | 248 +++++++++++++++++- .../stablecoin/src/withdraw_collateral.rs | 30 +-- 8 files changed, 626 insertions(+), 28 deletions(-) create mode 100644 programs/stablecoin/src/generate_debt.rs diff --git a/artifacts/stablecoin-idl.json b/artifacts/stablecoin-idl.json index 2c453488..1d53250c 100644 --- a/artifacts/stablecoin-idl.json +++ b/artifacts/stablecoin-idl.json @@ -305,6 +305,71 @@ } ] }, + { + "name": "generate_debt", + "accounts": [ + { + "name": "owner", + "writable": false, + "signer": true, + "init": false + }, + { + "name": "position", + "writable": true, + "signer": false, + "init": false + }, + { + "name": "stablecoin_definition", + "writable": true, + "signer": false, + "init": false + }, + { + "name": "user_stablecoin_holding", + "writable": true, + "signer": false, + "init": false + }, + { + "name": "stability_fee_accumulator", + "writable": false, + "signer": false, + "init": false + }, + { + "name": "redemption_price_state", + "writable": false, + "signer": false, + "init": false + }, + { + "name": "market_price_oracle", + "writable": false, + "signer": false, + "init": false + }, + { + "name": "protocol_parameters", + "writable": false, + "signer": false, + "init": false + }, + { + "name": "clock", + "writable": false, + "signer": false, + "init": false + } + ], + "args": [ + { + "name": "amount", + "type": "u128" + } + ] + }, { "name": "withdraw_collateral", "accounts": [ diff --git a/programs/stablecoin/core/src/lib.rs b/programs/stablecoin/core/src/lib.rs index 4c1bc34d..b1d412d5 100644 --- a/programs/stablecoin/core/src/lib.rs +++ b/programs/stablecoin/core/src/lib.rs @@ -185,6 +185,30 @@ pub enum Instruction { /// Collateral tokens to move from the user's holding into the vault. amount: u128, }, + /// Mint stablecoins against an existing position, increasing its debt. + /// + /// Blocked while frozen. The §6.2 collateralization invariant is checked + /// after the mint, and the normalized-debt delta is rounded **up** (§6.3). + /// + /// Required accounts (9), in order: + /// 1. `owner` — authorized. + /// 2. `position` — initialized, writable, owned by `self_program_id`; at its `(owner, + /// position_nonce)` PDA. + /// 3. `stablecoin_definition` — initialized, writable via the chained `Token::Mint`; must + /// equal `protocol_parameters.stablecoin_definition_id`. + /// 4. `user_stablecoin_holding` — initialized mint destination; NOT required to be authorized. + /// Same Token Program and definition as `stablecoin_definition`. + /// 5. `stability_fee_accumulator` — initialized, read-only; at its canonical PDA. + /// 6. `redemption_price_state` — initialized, read-only; at its canonical PDA. + /// 7. `market_price_oracle` — initialized, read-only; must equal + /// `protocol_parameters.market_price_oracle_id`. Liveness gate only — its price is not + /// consumed. + /// 8. `protocol_parameters` — initialized, read-only; at its canonical PDA. + /// 9. `clock` — the system `CLOCK_01` account; read-only. + GenerateDebt { + /// Stablecoin atomic units to mint to `user_stablecoin_holding`. + amount: u128, + }, /// Withdraw `amount` collateral tokens from a position back to a user-controlled holding. /// /// Blocked while the protocol is frozen. The §6.2 collateralization diff --git a/programs/stablecoin/methods/guest/src/bin/stablecoin.rs b/programs/stablecoin/methods/guest/src/bin/stablecoin.rs index ff555437..0481cb56 100644 --- a/programs/stablecoin/methods/guest/src/bin/stablecoin.rs +++ b/programs/stablecoin/methods/guest/src/bin/stablecoin.rs @@ -281,6 +281,56 @@ mod stablecoin { )) } + /// Mint stablecoins against an existing position (spec §10.7; host fn + /// `stablecoin_program::generate_debt`). + /// + /// Blocked while frozen. The oracle is a liveness gate only. Wall-clock time + /// comes from the system `CLOCK_01` account passed as the 9th input. + /// + /// # Errors + /// Returns the host program's panic-converted error if any precondition + /// fails — see the host fn for the full list. + #[instruction] + #[allow( + clippy::too_many_arguments, + reason = "the nine account inputs mirror the spec §10.7 ABI" + )] + pub fn generate_debt( + ctx: ProgramContext, + #[account(signer)] + owner: AccountWithMetadata, + #[account(mut)] + position: AccountWithMetadata, + #[account(mut)] + stablecoin_definition: AccountWithMetadata, + #[account(mut)] + user_stablecoin_holding: AccountWithMetadata, + stability_fee_accumulator: AccountWithMetadata, + redemption_price_state: AccountWithMetadata, + market_price_oracle: AccountWithMetadata, + protocol_parameters: AccountWithMetadata, + clock: AccountWithMetadata, + amount: u128, + ) -> SpelResult { + let (post_states, chained_calls) = stablecoin_program::generate_debt::generate_debt( + owner, + position, + stablecoin_definition, + user_stablecoin_holding, + stability_fee_accumulator, + redemption_price_state, + market_price_oracle, + protocol_parameters, + clock, + ctx.self_program_id, + amount, + ); + Ok(spel_framework::SpelOutput::execute( + post_states, + chained_calls, + )) + } + /// Withdraw `amount` collateral tokens from an existing position back to a /// user-controlled holding. /// diff --git a/programs/stablecoin/src/checks.rs b/programs/stablecoin/src/checks.rs index e0b4babc..a85eaf53 100644 --- a/programs/stablecoin/src/checks.rs +++ b/programs/stablecoin/src/checks.rs @@ -1,6 +1,10 @@ //! Shared validation helpers reused across the position-lifecycle instructions. use alloy_primitives::U256; +use lee_core::{ + account::{Account, AccountId, AccountWithMetadata, Data}, + program::ProgramId, +}; use stablecoin_core::{math::FIXED_POINT_ONE, Position}; /// Assert that `position` satisfies the collateralization invariant from spec §6.2: @@ -62,6 +66,30 @@ pub fn assert_position_is_collateralized( ); } +/// Validate a read-only global: initialized, program-owned, and at its canonical +/// PDA. Returns its `Data` for the caller to decode. +pub(crate) fn decode_global( + account: &AccountWithMetadata, + expected_id: AccountId, + stablecoin_program_id: ProgramId, + label: &str, +) -> Data { + assert_ne!( + account.account, + Account::default(), + "{label} account must be initialized" + ); + assert_eq!( + account.account.program_owner, stablecoin_program_id, + "{label} account must be owned by the stablecoin program" + ); + assert_eq!( + account.account_id, expected_id, + "{label} account ID does not match expected PDA derivation" + ); + account.account.data.clone() +} + #[cfg(test)] #[allow( clippy::arithmetic_side_effects, diff --git a/programs/stablecoin/src/generate_debt.rs b/programs/stablecoin/src/generate_debt.rs new file mode 100644 index 00000000..40dc9f50 --- /dev/null +++ b/programs/stablecoin/src/generate_debt.rs @@ -0,0 +1,206 @@ +use lee_core::{ + account::{Account, AccountWithMetadata, Data}, + program::{AccountPostState, ChainedCall, ProgramId}, +}; +use stablecoin_core::{ + compute_protocol_parameters_pda, compute_redemption_price_state_pda, + compute_stability_fee_accumulator_pda, compute_stablecoin_definition_pda_seed, + math::{ + compute_current_accumulated_rate, compute_current_redemption_price, mul_div_ceil, + FIXED_POINT_ONE, + }, + verify_position_and_get_seed, Position, ProtocolParameters, RedemptionPriceState, + StabilityFeeAccumulator, +}; +use token_core::TokenHolding; + +/// Mint `amount` stablecoins against `position`, increasing its debt (spec §10.7). +/// +/// Emits a chained `Token::Mint` authorized by the stablecoin definition's PDA +/// seed — `initialize_program` set the definition as its own mint authority. +/// The position's `normalized_debt_amount` grows by +/// `⌈amount × FIXED_POINT_ONE / current_accumulator⌉`, rounded **up** per §6.3 so +/// the borrower's nominal debt grows by at least `amount`. +/// +/// The §6.2 collateralization invariant is checked *after* the mint, against the +/// accumulator and redemption price projected forward to the clock timestamp. +/// The oracle is read for its staleness gate only; its price is not used. +/// +/// # Panics +/// - `owner` is not authorized. +/// - `position` is uninitialized, not owned by `stablecoin_program_id`, does not decode, or is not +/// at its `(owner, position_nonce)` PDA. +/// - `protocol_parameters`, `stability_fee_accumulator` or `redemption_price_state` is +/// uninitialized, wrongly owned, not at its canonical PDA, or does not decode. +/// - `protocol_parameters.is_frozen` is set. +/// - `stablecoin_definition.account_id` does not match +/// `protocol_parameters.stablecoin_definition_id`, or it is uninitialized. +/// - `market_price_oracle` does not match `protocol_parameters.market_price_oracle_id`, or its +/// observation is older than `maximum_oracle_price_age_milliseconds`. +/// - `user_stablecoin_holding` is uninitialized, owned by a different Token Program than the +/// definition, or holds a different definition. +/// - `clock` is not the initialized system `CLOCK_01` account. +/// - The debt addition overflows, or §6.2 fails post-mint. +#[allow( + clippy::too_many_arguments, + reason = "the nine account inputs mirror the spec §10.7 ABI" +)] +pub fn generate_debt( + owner: AccountWithMetadata, + position: AccountWithMetadata, + stablecoin_definition: AccountWithMetadata, + user_stablecoin_holding: AccountWithMetadata, + stability_fee_accumulator: AccountWithMetadata, + redemption_price_state: AccountWithMetadata, + market_price_oracle: AccountWithMetadata, + protocol_parameters: AccountWithMetadata, + clock: AccountWithMetadata, + stablecoin_program_id: ProgramId, + amount: u128, +) -> (Vec, Vec) { + assert!(owner.is_authorized, "Owner authorization is missing"); + + assert_ne!( + position.account, + Account::default(), + "Position account must be initialized" + ); + assert_eq!( + position.account.program_owner, stablecoin_program_id, + "Position is not owned by this stablecoin program" + ); + let position_data = Position::try_from(&position.account.data) + .expect("Position account must hold valid Position state"); + let _position_seed = verify_position_and_get_seed( + &position, + &owner, + position_data.position_nonce, + stablecoin_program_id, + ); + assert_eq!( + position_data.owner_account_id, owner.account_id, + "Position owner_account_id does not match the owner account" + ); + + let parameters = ProtocolParameters::try_from(&crate::checks::decode_global( + &protocol_parameters, + compute_protocol_parameters_pda(stablecoin_program_id), + stablecoin_program_id, + "ProtocolParameters", + )) + .expect("ProtocolParameters must decode"); + assert!(!parameters.is_frozen, "Protocol is frozen"); + + let accumulator = StabilityFeeAccumulator::try_from(&crate::checks::decode_global( + &stability_fee_accumulator, + compute_stability_fee_accumulator_pda(stablecoin_program_id), + stablecoin_program_id, + "StabilityFeeAccumulator", + )) + .expect("StabilityFeeAccumulator must decode"); + + let redemption = RedemptionPriceState::try_from(&crate::checks::decode_global( + &redemption_price_state, + compute_redemption_price_state_pda(stablecoin_program_id), + stablecoin_program_id, + "RedemptionPriceState", + )) + .expect("RedemptionPriceState must decode"); + + let now = crate::accrue_stability_fee::read_clock(&clock); + + // The oracle is a liveness gate only — spec §10.7 never consumes its price. + let oracle = crate::update_redemption_rate::decode_oracle(&market_price_oracle, ¶meters); + assert!( + now.saturating_sub(oracle.timestamp) <= parameters.maximum_oracle_price_age_milliseconds, + "Market price oracle observation is stale" + ); + + assert_eq!( + stablecoin_definition.account_id, parameters.stablecoin_definition_id, + "Stablecoin definition does not match the one bound at initialize_program" + ); + assert_ne!( + stablecoin_definition.account, + Account::default(), + "Stablecoin definition account must be initialized" + ); + + assert_ne!( + user_stablecoin_holding.account, + Account::default(), + "User stablecoin holding must be initialized" + ); + let token_program_id = stablecoin_definition.account.program_owner; + assert_eq!( + user_stablecoin_holding.account.program_owner, token_program_id, + "User stablecoin holding must be owned by the same Token Program as the definition" + ); + let user_holding = TokenHolding::try_from(&user_stablecoin_holding.account.data) + .expect("User stablecoin holding must hold a valid TokenHolding"); + assert_eq!( + user_holding.definition_id(), + stablecoin_definition.account_id, + "User stablecoin holding does not match the stablecoin definition" + ); + + let current_accumulator = compute_current_accumulated_rate( + accumulator.accumulated_rate_at_last_accrual, + parameters.stability_fee_per_millisecond, + accumulator.last_accrued_at, + now, + ); + // Round UP (§6.3): the borrower receives exactly `amount`, so nominal debt + // must grow by at least `amount`. The remainder stays with the protocol. + let debt_delta = mul_div_ceil(amount, FIXED_POINT_ONE, current_accumulator); + let new_debt = position_data + .normalized_debt_amount + .checked_add(debt_delta) + .expect("Position normalized_debt_amount overflow"); + + let updated_position = Position { + normalized_debt_amount: new_debt, + ..position_data + }; + crate::checks::assert_position_is_collateralized( + &updated_position, + current_accumulator, + compute_current_redemption_price( + redemption.redemption_price_at_last_update, + redemption.redemption_rate_per_millisecond, + redemption.last_updated_at, + now, + ), + parameters.minimum_collateralization_ratio, + ); + + let mut position_post = position.account.clone(); + position_post.data = Data::from(&updated_position); + + let post_states = vec![ + AccountPostState::new(owner.account), + AccountPostState::new(position_post), + AccountPostState::new(stablecoin_definition.account.clone()), + AccountPostState::new(user_stablecoin_holding.account.clone()), + AccountPostState::new(stability_fee_accumulator.account), + AccountPostState::new(redemption_price_state.account), + AccountPostState::new(market_price_oracle.account), + AccountPostState::new(protocol_parameters.account), + AccountPostState::new(clock.account), + ]; + + // `initialize_program` made the definition its own mint authority, so the + // chained call authorizes it with the definition's PDA seed. + let mut definition_authorized = stablecoin_definition.clone(); + definition_authorized.is_authorized = true; + let mint_call = ChainedCall::new( + token_program_id, + vec![definition_authorized, user_stablecoin_holding], + &token_core::Instruction::Mint { + amount_to_mint: amount, + }, + ) + .with_pda_seeds(vec![compute_stablecoin_definition_pda_seed()]); + + (post_states, vec![mint_call]) +} diff --git a/programs/stablecoin/src/lib.rs b/programs/stablecoin/src/lib.rs index 2bcdf504..27148c6d 100644 --- a/programs/stablecoin/src/lib.rs +++ b/programs/stablecoin/src/lib.rs @@ -11,6 +11,9 @@ pub mod checks; /// Deposit additional collateral into an existing position. pub mod deposit_collateral; +/// Mint stablecoins against a position, increasing its debt. +pub mod generate_debt; + /// Bootstrap the protocol: create the global PDAs and the stablecoin definition. pub mod initialize_program; diff --git a/programs/stablecoin/src/tests.rs b/programs/stablecoin/src/tests.rs index d442b8e5..1f96d3d3 100644 --- a/programs/stablecoin/src/tests.rs +++ b/programs/stablecoin/src/tests.rs @@ -85,7 +85,7 @@ fn protocol_parameters_account_for( freeze_authority_account_id: AccountId::new([0xFEu8; 32]), stablecoin_definition_id: stablecoin_definition_id(), collateral_definition_id, - market_price_oracle_id: AccountId::new([0xB0u8; 32]), + market_price_oracle_id: crate::test_support::oracle_id(), stability_fee_per_millisecond: FIXED_POINT_ONE, controller_proportional_gain: 0, controller_integral_gain: 0, @@ -942,6 +942,252 @@ fn deposit_collateral_rejects_overflow() { ); } +// --- generate_debt (spec §10.7) --- +// +// accumulator 1.0, redemption price 0.5, ratio 1.5x → required collateral is +// 0.75 x nominal debt. Oracle is fresh and used only as a liveness gate. + +const ORACLE_PRICE: u128 = FIXED_POINT_ONE / 4; + +#[allow(clippy::too_many_arguments, reason = "mirrors the host fn ABI")] +fn generate( + position: AccountWithMetadata, + accumulator: AccountWithMetadata, + oracle: AccountWithMetadata, + parameters: AccountWithMetadata, + amount: u128, +) -> (Vec, Vec) { + crate::generate_debt::generate_debt( + owner_account(), + position, + stablecoin_definition_account(), + user_stablecoin_holding_account(0), + accumulator, + crate::test_support::redemption_price_state_account(NOW), + oracle, + parameters, + clock_account(NOW), + STABLECOIN_PROGRAM_ID, + amount, + ) +} + +fn fresh_oracle() -> AccountWithMetadata { + crate::test_support::oracle_account(NOW, ORACLE_PRICE) +} + +fn unit_accumulator() -> AccountWithMetadata { + crate::test_support::accumulator_account(FIXED_POINT_ONE, NOW) +} + +#[test] +fn generate_debt_mints_and_increases_normalized_debt() { + let (post_states, chained_calls) = generate( + init_position_account(1_000, 0), + unit_accumulator(), + fresh_oracle(), + protocol_parameters_account(false), + 100, + ); + + assert_eq!(post_states.len(), 9); + let position = Position::try_from(&post_states[1].account().data).expect("valid Position"); + // accumulator is exactly 1.0, so the delta equals the minted amount. + assert_eq!(position.normalized_debt_amount, 100); + assert_eq!(position.collateral_amount, 1_000); + + assert_eq!(chained_calls.len(), 1); + let mut definition_authorized = stablecoin_definition_account(); + definition_authorized.is_authorized = true; + let expected = ChainedCall::new( + TOKEN_PROGRAM_ID, + vec![definition_authorized, user_stablecoin_holding_account(0)], + &token_core::Instruction::Mint { + amount_to_mint: 100, + }, + ) + .with_pda_seeds(vec![ + stablecoin_core::compute_stablecoin_definition_pda_seed(), + ]); + assert_eq!(chained_calls[0], expected); +} + +#[test] +fn generate_debt_rounds_the_normalized_delta_up() { + // accumulator 3.0 → 100 / 3 = 33.33…, and §6.3 rounds UP so the borrower's + // nominal debt grows by at least the amount minted. + let (post_states, _) = generate( + init_position_account(1_000, 0), + crate::test_support::accumulator_account(FIXED_POINT_ONE * 3, NOW), + fresh_oracle(), + protocol_parameters_account(false), + 100, + ); + + let position = Position::try_from(&post_states[1].account().data).expect("valid Position"); + assert_eq!(position.normalized_debt_amount, 34); +} + +#[test] +fn generate_debt_echoes_the_read_only_globals() { + let (post_states, _) = generate( + init_position_account(1_000, 0), + unit_accumulator(), + fresh_oracle(), + protocol_parameters_account(false), + 100, + ); + + assert_eq!(*post_states[6].account(), fresh_oracle().account); + assert_eq!(*post_states[8].account(), clock_account(NOW).account); +} + +#[test] +#[should_panic(expected = "Position is undercollateralized")] +fn generate_debt_fails_when_the_mint_would_undercollateralize() { + // 100 collateral supports at most 133 nominal debt at 0.75x; 200 is over. + generate( + init_position_account(100, 0), + unit_accumulator(), + fresh_oracle(), + protocol_parameters_account(false), + 200, + ); +} + +#[test] +fn generate_debt_at_the_exact_ratio_boundary_succeeds() { + // debt 100 requires exactly 75 collateral. + let (post_states, _) = generate( + init_position_account(75, 0), + unit_accumulator(), + fresh_oracle(), + protocol_parameters_account(false), + 100, + ); + + let position = Position::try_from(&post_states[1].account().data).expect("valid Position"); + assert_eq!(position.normalized_debt_amount, 100); +} + +#[test] +#[should_panic(expected = "Protocol is frozen")] +fn generate_debt_rejects_when_frozen() { + generate( + init_position_account(1_000, 0), + unit_accumulator(), + fresh_oracle(), + protocol_parameters_account(true), + 100, + ); +} + +#[test] +#[should_panic(expected = "Market price oracle observation is stale")] +fn generate_debt_rejects_a_stale_oracle() { + let stale = NOW - 86_400_001; + generate( + init_position_account(1_000, 0), + unit_accumulator(), + crate::test_support::oracle_account(stale, ORACLE_PRICE), + protocol_parameters_account(false), + 100, + ); +} + +#[test] +#[should_panic( + expected = "Market price oracle account_id does not match ProtocolParameters.market_price_oracle_id" +)] +fn generate_debt_rejects_an_unbound_oracle() { + let mut oracle = fresh_oracle(); + oracle.account_id = AccountId::new([0x99u8; 32]); + generate( + init_position_account(1_000, 0), + unit_accumulator(), + oracle, + protocol_parameters_account(false), + 100, + ); +} + +#[test] +#[should_panic(expected = "Owner authorization is missing")] +fn generate_debt_requires_owner_authorization() { + let mut owner = owner_account(); + owner.is_authorized = false; + crate::generate_debt::generate_debt( + owner, + init_position_account(1_000, 0), + stablecoin_definition_account(), + user_stablecoin_holding_account(0), + unit_accumulator(), + crate::test_support::redemption_price_state_account(NOW), + fresh_oracle(), + protocol_parameters_account(false), + clock_account(NOW), + STABLECOIN_PROGRAM_ID, + 100, + ); +} + +#[test] +#[should_panic(expected = "Position account must be initialized")] +fn generate_debt_rejects_uninitialized_position() { + generate( + uninit_position_account(), + unit_accumulator(), + fresh_oracle(), + protocol_parameters_account(false), + 100, + ); +} + +#[test] +#[should_panic( + expected = "Stablecoin definition does not match the one bound at initialize_program" +)] +fn generate_debt_rejects_an_unbound_stablecoin_definition() { + let mut definition = stablecoin_definition_account(); + definition.account_id = AccountId::new([0x88u8; 32]); + crate::generate_debt::generate_debt( + owner_account(), + init_position_account(1_000, 0), + definition, + user_stablecoin_holding_account(0), + unit_accumulator(), + crate::test_support::redemption_price_state_account(NOW), + fresh_oracle(), + protocol_parameters_account(false), + clock_account(NOW), + STABLECOIN_PROGRAM_ID, + 100, + ); +} + +#[test] +#[should_panic(expected = "User stablecoin holding does not match the stablecoin definition")] +fn generate_debt_rejects_holding_for_another_definition() { + let holding = token_holding_account( + user_stablecoin_holding_id(), + AccountId::new([0x21u8; 32]), + 0, + ); + crate::generate_debt::generate_debt( + owner_account(), + init_position_account(1_000, 0), + stablecoin_definition_account(), + holding, + unit_accumulator(), + crate::test_support::redemption_price_state_account(NOW), + fresh_oracle(), + protocol_parameters_account(false), + clock_account(NOW), + STABLECOIN_PROGRAM_ID, + 100, + ); +} + #[test] fn position_pda_is_deterministic_and_owner_and_nonce_specific() { let id_a = compute_position_pda(STABLECOIN_PROGRAM_ID, owner_id(), TEST_POSITION_NONCE); diff --git a/programs/stablecoin/src/withdraw_collateral.rs b/programs/stablecoin/src/withdraw_collateral.rs index a391a9fb..19dadcbb 100644 --- a/programs/stablecoin/src/withdraw_collateral.rs +++ b/programs/stablecoin/src/withdraw_collateral.rs @@ -52,7 +52,7 @@ pub fn withdraw_collateral( stablecoin_program_id: ProgramId, amount: u128, ) -> (Vec, Vec) { - let parameters = decode_global( + let parameters = crate::checks::decode_global( &protocol_parameters, compute_protocol_parameters_pda(stablecoin_program_id), stablecoin_program_id, @@ -62,7 +62,7 @@ pub fn withdraw_collateral( ProtocolParameters::try_from(¶meters).expect("ProtocolParameters must decode"); assert!(!parameters.is_frozen, "Protocol is frozen"); - let accumulator_data = decode_global( + let accumulator_data = crate::checks::decode_global( &stability_fee_accumulator, compute_stability_fee_accumulator_pda(stablecoin_program_id), stablecoin_program_id, @@ -71,7 +71,7 @@ pub fn withdraw_collateral( let accumulator = StabilityFeeAccumulator::try_from(&accumulator_data) .expect("StabilityFeeAccumulator must decode"); - let redemption_data = decode_global( + let redemption_data = crate::checks::decode_global( &redemption_price_state, compute_redemption_price_state_pda(stablecoin_program_id), stablecoin_program_id, @@ -201,27 +201,3 @@ pub fn withdraw_collateral( (post_states, vec![transfer_call]) } - -/// Validate a read-only global: initialized, program-owned, and at its canonical -/// PDA. Returns its `Data` for the caller to decode. -fn decode_global( - account: &AccountWithMetadata, - expected_id: lee_core::account::AccountId, - stablecoin_program_id: ProgramId, - label: &str, -) -> Data { - assert_ne!( - account.account, - Account::default(), - "{label} account must be initialized" - ); - assert_eq!( - account.account.program_owner, stablecoin_program_id, - "{label} account must be owned by the stablecoin program" - ); - assert_eq!( - account.account_id, expected_id, - "{label} account ID does not match expected PDA derivation" - ); - account.account.data.clone() -}