From 17d163443e83d2c2c2825c3db1201075dd8f6e9e Mon Sep 17 00:00:00 2001 From: Andrea Franz Date: Wed, 2 Sep 2026 11:57:12 +0000 Subject: [PATCH] feat(stablecoin): rebuild withdraw_collateral with the collateralization check closes #177 --- artifacts/stablecoin-idl.json | 26 ++- .../integration_tests/tests/stablecoin.rs | 38 +++ programs/stablecoin/core/src/lib.rs | 30 ++- .../methods/guest/src/bin/stablecoin.rs | 16 +- programs/stablecoin/src/tests.rs | 216 ++++++++++++++++-- .../stablecoin/src/withdraw_collateral.rs | 128 +++++++++-- 6 files changed, 402 insertions(+), 52 deletions(-) diff --git a/artifacts/stablecoin-idl.json b/artifacts/stablecoin-idl.json index 3d47b3e4..2c453488 100644 --- a/artifacts/stablecoin-idl.json +++ b/artifacts/stablecoin-idl.json @@ -327,10 +327,34 @@ "init": false }, { - "name": "destination", + "name": "user_collateral_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": "protocol_parameters", + "writable": false, + "signer": false, + "init": false + }, + { + "name": "clock", + "writable": false, + "signer": false, + "init": false } ], "args": [ diff --git a/programs/integration_tests/tests/stablecoin.rs b/programs/integration_tests/tests/stablecoin.rs index eecbf881..7fde05c5 100644 --- a/programs/integration_tests/tests/stablecoin.rs +++ b/programs/integration_tests/tests/stablecoin.rs @@ -232,6 +232,32 @@ impl Accounts { } } + fn stability_fee_accumulator_init() -> Account { + Account { + program_owner: Ids::stablecoin_program(), + balance: 0, + data: Data::from(&stablecoin_core::StabilityFeeAccumulator { + accumulated_rate_at_last_accrual: stablecoin_core::math::FIXED_POINT_ONE, + last_accrued_at: OPEN_POSITION_NOW, + }), + nonce: Nonce(0), + } + } + + fn redemption_price_state_init() -> Account { + Account { + program_owner: Ids::stablecoin_program(), + balance: 0, + data: Data::from(&stablecoin_core::RedemptionPriceState { + redemption_price_at_last_update: protocol_config::INITIAL_REDEMPTION_PRICE, + redemption_rate_per_millisecond: stablecoin_core::math::FIXED_POINT_ONE, + controller_integral_term: 0, + last_updated_at: OPEN_POSITION_NOW, + }), + nonce: Nonce(0), + } + } + fn oracle_init(base_asset: AccountId, quote_asset: AccountId) -> Account { Self::oracle_with( base_asset, @@ -331,6 +357,14 @@ fn state_for_stablecoin_tests() -> V03State { compute_protocol_parameters_pda(Ids::stablecoin_program()), Accounts::protocol_parameters_init(), ); + state.force_insert_account( + compute_stability_fee_accumulator_pda(Ids::stablecoin_program()), + Accounts::stability_fee_accumulator_init(), + ); + state.force_insert_account( + compute_redemption_price_state_pda(Ids::stablecoin_program()), + Accounts::redemption_price_state_init(), + ); seed_clock(&mut state, OPEN_POSITION_NOW); state } @@ -478,6 +512,10 @@ fn stablecoin_open_position_then_withdraw_collateral() { Ids::position(), Ids::vault(), Ids::user_holding(), + compute_stability_fee_accumulator_pda(Ids::stablecoin_program()), + compute_redemption_price_state_pda(Ids::stablecoin_program()), + compute_protocol_parameters_pda(Ids::stablecoin_program()), + CLOCK_01_PROGRAM_ACCOUNT_ID, ], vec![current_nonce(&state, Ids::owner())], withdraw, diff --git a/programs/stablecoin/core/src/lib.rs b/programs/stablecoin/core/src/lib.rs index 3efe4f4d..4c1bc34d 100644 --- a/programs/stablecoin/core/src/lib.rs +++ b/programs/stablecoin/core/src/lib.rs @@ -187,20 +187,28 @@ pub enum Instruction { }, /// Withdraw `amount` collateral tokens from a position back to a user-controlled holding. /// - /// Required accounts (4): - /// - Owner account (authorized) - /// - Position account (initialized, owned by `self_program_id`) - /// - Position vault token holding (address must match - /// `compute_position_vault_pda(self_program_id, position_id)`) - /// - Destination user collateral holding (initialized, owned by the vault's Token Program, - /// `TokenHolding.definition_id` matches the vault holding's definition) + /// Blocked while the protocol is frozen. The §6.2 collateralization + /// invariant is checked *after* the decrement, against debt and redemption + /// price both projected forward to the clock timestamp (§5.3). + /// + /// Required accounts (8), in order: + /// 1. `owner` — authorized. + /// 2. `position` — initialized, writable, owned by `self_program_id`; address must match + /// `compute_position_pda(self_program_id, owner, position_nonce)`. + /// 3. `vault` — initialized, writable; must equal `Position.vault_account_id`. Authorized in + /// the chained call via its PDA seed. + /// 4. `user_collateral_holding` — initialized destination; NOT required to be authorized. Same + /// Token Program and definition as the vault holding. + /// 5. `stability_fee_accumulator` — initialized, read-only; at its canonical PDA. Projected to + /// `now` for the position's nominal debt. + /// 6. `redemption_price_state` — initialized, read-only; at its canonical PDA. Projected to + /// `now` for the current redemption price. + /// 7. `protocol_parameters` — initialized, read-only; at its canonical PDA. Supplies the + /// minimum collateralization ratio and the freeze flag. + /// 8. `clock` — the system `CLOCK_01` account; read-only. /// /// `token_program_id` is derived from `vault.account.program_owner`; /// the collateral definition is read from the PDA-verified vault holding. - /// - /// **Note:** until issues #97/#95 land, this instruction hard-asserts - /// `Position.normalized_debt_amount == 0` instead of accruing fees and - /// checking the collateralization ratio. WithdrawCollateral { /// Amount of collateral tokens to move from the vault back to `destination`. amount: u128, diff --git a/programs/stablecoin/methods/guest/src/bin/stablecoin.rs b/programs/stablecoin/methods/guest/src/bin/stablecoin.rs index ba8292d0..ff555437 100644 --- a/programs/stablecoin/methods/guest/src/bin/stablecoin.rs +++ b/programs/stablecoin/methods/guest/src/bin/stablecoin.rs @@ -290,6 +290,10 @@ mod stablecoin { /// [`stablecoin_program::withdraw_collateral::withdraw_collateral`] for the /// full list). #[instruction] + #[allow( + clippy::too_many_arguments, + reason = "the eight account inputs mirror the spec §10.6 ABI" + )] pub fn withdraw_collateral( ctx: ProgramContext, #[account(signer)] @@ -299,7 +303,11 @@ mod stablecoin { #[account(mut)] vault: AccountWithMetadata, #[account(mut)] - destination: AccountWithMetadata, + user_collateral_holding: AccountWithMetadata, + stability_fee_accumulator: AccountWithMetadata, + redemption_price_state: AccountWithMetadata, + protocol_parameters: AccountWithMetadata, + clock: AccountWithMetadata, amount: u128, ) -> SpelResult { let (post_states, chained_calls) = @@ -307,7 +315,11 @@ mod stablecoin { owner, position, vault, - destination, + user_collateral_holding, + stability_fee_accumulator, + redemption_price_state, + protocol_parameters, + clock, ctx.self_program_id, amount, ); diff --git a/programs/stablecoin/src/tests.rs b/programs/stablecoin/src/tests.rs index 08554165..d442b8e5 100644 --- a/programs/stablecoin/src/tests.rs +++ b/programs/stablecoin/src/tests.rs @@ -969,6 +969,143 @@ fn position_pda_and_vault_pda_do_not_collide() { assert_ne!(position, vault); } +// --- withdraw_collateral: fee-aware rebuild (spec §10.6) --- +// +// Fixtures pin accumulator = 1.0 and redemption price = 0.5 with no drift, and +// the ratio is 1.5x, so required collateral = normalized_debt * 0.5 * 1.5. + +fn withdraw( + position: AccountWithMetadata, + parameters: AccountWithMetadata, + amount: u128, +) -> (Vec, Vec) { + crate::withdraw_collateral::withdraw_collateral( + owner_account(), + position, + init_vault_account(), + destination_holding_account(), + crate::test_support::accumulator_account(FIXED_POINT_ONE, NOW), + crate::test_support::redemption_price_state_account(NOW), + parameters, + clock_account(NOW), + STABLECOIN_PROGRAM_ID, + amount, + ) +} + +#[test] +fn withdraw_collateral_echoes_the_four_read_only_globals() { + let (post_states, chained_calls) = withdraw( + init_position_account(1_000, 0), + protocol_parameters_account(false), + 100, + ); + + assert_eq!(post_states.len(), 8); + assert_eq!(chained_calls.len(), 1); + assert_eq!( + *post_states[7].account(), + clock_account(NOW).account, + "clock must be echoed unchanged" + ); +} + +#[test] +fn withdraw_collateral_with_debt_succeeds_when_collateralization_holds() { + // debt 100 needs 75 collateral; 900 remain after the withdrawal. + let (post_states, _) = withdraw( + init_position_account(1_000, 100), + protocol_parameters_account(false), + 100, + ); + + let position = Position::try_from(&post_states[1].account().data).expect("valid Position"); + assert_eq!(position.collateral_amount, 900); + assert_eq!(position.normalized_debt_amount, 100); +} + +#[test] +fn withdraw_collateral_at_exact_ratio_boundary_succeeds() { + // debt 100 → required exactly 75; withdrawing 25 from 100 leaves exactly 75. + let (post_states, _) = withdraw( + init_position_account(100, 100), + protocol_parameters_account(false), + 25, + ); + + let position = Position::try_from(&post_states[1].account().data).expect("valid Position"); + assert_eq!(position.collateral_amount, 75); +} + +#[test] +#[should_panic(expected = "Position is undercollateralized")] +fn withdraw_collateral_fails_one_unit_below_the_ratio() { + // One more unit than the boundary case leaves 74 against a 75 requirement. + withdraw( + init_position_account(100, 100), + protocol_parameters_account(false), + 26, + ); +} + +#[test] +#[should_panic(expected = "Protocol is frozen")] +fn withdraw_collateral_rejects_when_frozen() { + withdraw( + init_position_account(1_000, 0), + protocol_parameters_account(true), + 100, + ); +} + +#[test] +#[should_panic(expected = "ProtocolParameters account must be initialized")] +fn withdraw_collateral_rejects_uninitialized_protocol_parameters() { + withdraw( + init_position_account(1_000, 0), + AccountWithMetadata { + account: Account::default(), + is_authorized: false, + account_id: protocol_parameters_id(), + }, + 100, + ); +} + +#[test] +#[should_panic(expected = "StabilityFeeAccumulator account must be initialized")] +fn withdraw_collateral_rejects_uninitialized_accumulator() { + crate::withdraw_collateral::withdraw_collateral( + owner_account(), + init_position_account(1_000, 0), + init_vault_account(), + destination_holding_account(), + crate::test_support::uninitialized(crate::test_support::accumulator_id()), + crate::test_support::redemption_price_state_account(NOW), + protocol_parameters_account(false), + clock_account(NOW), + STABLECOIN_PROGRAM_ID, + 100, + ); +} + +#[test] +#[should_panic(expected = "RedemptionPriceState account must be initialized")] +fn withdraw_collateral_rejects_uninitialized_redemption_price_state() { + crate::withdraw_collateral::withdraw_collateral( + owner_account(), + init_position_account(1_000, 0), + init_vault_account(), + destination_holding_account(), + crate::test_support::accumulator_account(FIXED_POINT_ONE, NOW), + crate::test_support::uninitialized(crate::test_support::redemption_price_state_id()), + protocol_parameters_account(false), + clock_account(NOW), + STABLECOIN_PROGRAM_ID, + 100, + ); +} + #[test] fn withdraw_collateral_updates_position_and_emits_transfer() { let initial_collateral: u128 = 500; @@ -978,11 +1115,15 @@ fn withdraw_collateral_updates_position_and_emits_transfer() { init_position_account(initial_collateral, 0), init_vault_account(), destination_holding_account(), + crate::test_support::accumulator_account(FIXED_POINT_ONE, NOW), + crate::test_support::redemption_price_state_account(NOW), + protocol_parameters_account(false), + clock_account(NOW), STABLECOIN_PROGRAM_ID, amount, ); - assert_eq!(post_states.len(), 4); + assert_eq!(post_states.len(), 8); // Position post-state: plain `new`, holds the decremented Position. let position_post = &post_states[1]; @@ -1031,6 +1172,10 @@ fn withdraw_collateral_allows_full_drain() { init_position_account(amount, 0), init_vault_account(), destination_holding_account(), + crate::test_support::accumulator_account(FIXED_POINT_ONE, NOW), + crate::test_support::redemption_price_state_account(NOW), + protocol_parameters_account(false), + clock_account(NOW), STABLECOIN_PROGRAM_ID, amount, ); @@ -1047,6 +1192,10 @@ fn withdraw_collateral_allows_zero_amount() { init_position_account(initial, 0), init_vault_account(), destination_holding_account(), + crate::test_support::accumulator_account(FIXED_POINT_ONE, NOW), + crate::test_support::redemption_price_state_account(NOW), + protocol_parameters_account(false), + clock_account(NOW), STABLECOIN_PROGRAM_ID, 0, ); @@ -1076,6 +1225,10 @@ fn withdraw_collateral_requires_owner_authorization() { init_position_account(500, 0), init_vault_account(), destination_holding_account(), + crate::test_support::accumulator_account(FIXED_POINT_ONE, NOW), + crate::test_support::redemption_price_state_account(NOW), + protocol_parameters_account(false), + clock_account(NOW), STABLECOIN_PROGRAM_ID, 100, ); @@ -1089,6 +1242,10 @@ fn withdraw_collateral_rejects_uninitialized_position() { uninit_position_account(), init_vault_account(), destination_holding_account(), + crate::test_support::accumulator_account(FIXED_POINT_ONE, NOW), + crate::test_support::redemption_price_state_account(NOW), + protocol_parameters_account(false), + clock_account(NOW), STABLECOIN_PROGRAM_ID, 100, ); @@ -1104,6 +1261,10 @@ fn withdraw_collateral_rejects_position_owned_by_other_program() { position, init_vault_account(), destination_holding_account(), + crate::test_support::accumulator_account(FIXED_POINT_ONE, NOW), + crate::test_support::redemption_price_state_account(NOW), + protocol_parameters_account(false), + clock_account(NOW), STABLECOIN_PROGRAM_ID, 100, ); @@ -1119,6 +1280,10 @@ fn withdraw_collateral_rejects_wrong_position_address() { position, init_vault_account(), destination_holding_account(), + crate::test_support::accumulator_account(FIXED_POINT_ONE, NOW), + crate::test_support::redemption_price_state_account(NOW), + protocol_parameters_account(false), + clock_account(NOW), STABLECOIN_PROGRAM_ID, 100, ); @@ -1134,6 +1299,10 @@ fn withdraw_collateral_rejects_wrong_vault_address() { init_position_account(500, 0), vault, destination_holding_account(), + crate::test_support::accumulator_account(FIXED_POINT_ONE, NOW), + crate::test_support::redemption_price_state_account(NOW), + protocol_parameters_account(false), + clock_account(NOW), STABLECOIN_PROGRAM_ID, 100, ); @@ -1141,7 +1310,7 @@ fn withdraw_collateral_rejects_wrong_vault_address() { #[test] #[should_panic( - expected = "Destination token definition does not match the position's collateral definition" + expected = "User collateral holding definition does not match the position's collateral definition" )] fn withdraw_collateral_rejects_destination_for_other_definition() { let mut destination = destination_holding_account(); @@ -1154,13 +1323,17 @@ fn withdraw_collateral_rejects_destination_for_other_definition() { init_position_account(500, 0), init_vault_account(), destination, + crate::test_support::accumulator_account(FIXED_POINT_ONE, NOW), + crate::test_support::redemption_price_state_account(NOW), + protocol_parameters_account(false), + clock_account(NOW), STABLECOIN_PROGRAM_ID, 100, ); } #[test] -#[should_panic(expected = "Destination must be initialized")] +#[should_panic(expected = "User collateral holding must be initialized")] fn withdraw_collateral_rejects_uninitialized_destination() { let destination = AccountWithMetadata { account: Account::default(), @@ -1172,13 +1345,19 @@ fn withdraw_collateral_rejects_uninitialized_destination() { init_position_account(500, 0), init_vault_account(), destination, + crate::test_support::accumulator_account(FIXED_POINT_ONE, NOW), + crate::test_support::redemption_price_state_account(NOW), + protocol_parameters_account(false), + clock_account(NOW), STABLECOIN_PROGRAM_ID, 100, ); } #[test] -#[should_panic(expected = "Destination must be owned by the same Token Program as the vault")] +#[should_panic( + expected = "User collateral holding must be owned by the same Token Program as the vault" +)] fn withdraw_collateral_rejects_destination_with_wrong_token_program() { let mut destination = destination_holding_account(); destination.account.program_owner = [9u32; 8]; @@ -1187,19 +1366,10 @@ fn withdraw_collateral_rejects_destination_with_wrong_token_program() { init_position_account(500, 0), init_vault_account(), destination, - STABLECOIN_PROGRAM_ID, - 100, - ); -} - -#[test] -#[should_panic(expected = "withdraw_collateral with debt is not supported yet")] -fn withdraw_collateral_rejects_withdrawal_with_outstanding_debt() { - crate::withdraw_collateral::withdraw_collateral( - owner_account(), - init_position_account(500, 1), - init_vault_account(), - destination_holding_account(), + crate::test_support::accumulator_account(FIXED_POINT_ONE, NOW), + crate::test_support::redemption_price_state_account(NOW), + protocol_parameters_account(false), + clock_account(NOW), STABLECOIN_PROGRAM_ID, 100, ); @@ -1213,6 +1383,10 @@ fn withdraw_collateral_rejects_overdraw() { init_position_account(100, 0), init_vault_account(), destination_holding_account(), + crate::test_support::accumulator_account(FIXED_POINT_ONE, NOW), + crate::test_support::redemption_price_state_account(NOW), + protocol_parameters_account(false), + clock_account(NOW), STABLECOIN_PROGRAM_ID, 200, ); @@ -1477,6 +1651,10 @@ fn withdraw_collateral_rejects_position_with_stale_owner_field() { position_with_mutated_fields(|p| p.owner_account_id = AccountId::new([0xAAu8; 32])), init_vault_account(), destination_holding_account(), + crate::test_support::accumulator_account(FIXED_POINT_ONE, NOW), + crate::test_support::redemption_price_state_account(NOW), + protocol_parameters_account(false), + clock_account(NOW), STABLECOIN_PROGRAM_ID, 100, ); @@ -1490,6 +1668,10 @@ fn withdraw_collateral_rejects_position_with_stale_vault_field() { position_with_mutated_fields(|p| p.vault_account_id = AccountId::new([0xBBu8; 32])), init_vault_account(), destination_holding_account(), + crate::test_support::accumulator_account(FIXED_POINT_ONE, NOW), + crate::test_support::redemption_price_state_account(NOW), + protocol_parameters_account(false), + clock_account(NOW), STABLECOIN_PROGRAM_ID, 100, ); diff --git a/programs/stablecoin/src/withdraw_collateral.rs b/programs/stablecoin/src/withdraw_collateral.rs index d973e696..a391a9fb 100644 --- a/programs/stablecoin/src/withdraw_collateral.rs +++ b/programs/stablecoin/src/withdraw_collateral.rs @@ -2,13 +2,19 @@ use lee_core::{ account::{Account, AccountWithMetadata, Data}, program::{AccountPostState, ChainedCall, ProgramId}, }; -use stablecoin_core::{verify_position_and_get_seed, verify_position_vault_and_get_seed, Position}; +use stablecoin_core::{ + compute_protocol_parameters_pda, compute_redemption_price_state_pda, + compute_stability_fee_accumulator_pda, + math::{compute_current_accumulated_rate, compute_current_redemption_price}, + verify_position_and_get_seed, verify_position_vault_and_get_seed, Position, ProtocolParameters, + RedemptionPriceState, StabilityFeeAccumulator, +}; use token_core::TokenHolding; -/// Withdraw `amount` collateral tokens from `position`'s vault back to `destination`. +/// Withdraw `amount` collateral tokens from `position`'s vault back to `user_collateral_holding`. /// /// Decreases `Position.collateral_amount` by `amount` and emits a single chained -/// `Token::Transfer` from the vault to `destination`, authorized by the vault +/// `Token::Transfer` from the vault to `user_collateral_holding`, authorized by the vault /// PDA seed. The position post-state uses plain [`AccountPostState::new`] — /// the initial PDA claim already happened in /// [`crate::open_position::open_position`]. @@ -25,19 +31,56 @@ use token_core::TokenHolding; /// `compute_position_pda(stablecoin_program_id, owner, Position.position_nonce)`. /// - `vault` sits at an address that does not match /// `compute_position_vault_pda(stablecoin_program_id, position_id)`. -/// - `destination` is uninitialized, owned by a different Token Program than the vault, or holds a -/// [`TokenHolding`] whose `definition_id` does not match the vault holding's collateral -/// definition. +/// - `user_collateral_holding` is uninitialized, owned by a different Token Program than the vault, +/// or holds a [`TokenHolding`] whose `definition_id` does not match the vault holding's +/// collateral definition. /// - `Position.normalized_debt_amount` is non-zero. /// - `amount > Position.collateral_amount`. +#[allow( + clippy::too_many_arguments, + reason = "the eight account inputs mirror the spec §10.6 ABI; a param struct would obscure it" +)] pub fn withdraw_collateral( owner: AccountWithMetadata, position: AccountWithMetadata, vault: AccountWithMetadata, - destination: AccountWithMetadata, + user_collateral_holding: AccountWithMetadata, + stability_fee_accumulator: AccountWithMetadata, + redemption_price_state: AccountWithMetadata, + protocol_parameters: AccountWithMetadata, + clock: AccountWithMetadata, stablecoin_program_id: ProgramId, amount: u128, ) -> (Vec, Vec) { + let parameters = decode_global( + &protocol_parameters, + compute_protocol_parameters_pda(stablecoin_program_id), + stablecoin_program_id, + "ProtocolParameters", + ); + let parameters = + ProtocolParameters::try_from(¶meters).expect("ProtocolParameters must decode"); + assert!(!parameters.is_frozen, "Protocol is frozen"); + + let accumulator_data = decode_global( + &stability_fee_accumulator, + compute_stability_fee_accumulator_pda(stablecoin_program_id), + stablecoin_program_id, + "StabilityFeeAccumulator", + ); + let accumulator = StabilityFeeAccumulator::try_from(&accumulator_data) + .expect("StabilityFeeAccumulator must decode"); + + let redemption_data = decode_global( + &redemption_price_state, + compute_redemption_price_state_pda(stablecoin_program_id), + stablecoin_program_id, + "RedemptionPriceState", + ); + let redemption = + RedemptionPriceState::try_from(&redemption_data).expect("RedemptionPriceState must decode"); + + let now = crate::accrue_stability_fee::read_clock(&clock); assert!(owner.is_authorized, "Owner authorization is missing"); assert_ne!( position.account, @@ -83,26 +126,22 @@ pub fn withdraw_collateral( let token_program_id = vault.account.program_owner; assert_ne!( - destination.account, + user_collateral_holding.account, Account::default(), - "Destination must be initialized" + "User collateral holding must be initialized" ); assert_eq!( - destination.account.program_owner, token_program_id, - "Destination must be owned by the same Token Program as the vault" + user_collateral_holding.account.program_owner, token_program_id, + "User collateral holding must be owned by the same Token Program as the vault" ); - let destination_holding = TokenHolding::try_from(&destination.account.data) - .expect("Destination account must hold a valid TokenHolding"); + let user_holding = TokenHolding::try_from(&user_collateral_holding.account.data) + .expect("User collateral holding must hold a valid TokenHolding"); assert_eq!( - destination_holding.definition_id(), + user_holding.definition_id(), collateral_definition_id, - "Destination token definition does not match the position's collateral definition" + "User collateral holding definition does not match the position's collateral definition" ); - assert_eq!( - position_data.normalized_debt_amount, 0, - "withdraw_collateral with debt is not supported yet — fee accrual + collateralization check land in #173" - ); let new_collateral = position_data .collateral_amount .checked_sub(amount) @@ -116,6 +155,25 @@ pub fn withdraw_collateral( normalized_debt_amount: position_data.normalized_debt_amount, opened_at: position_data.opened_at, }; + // Spec §6.2 is enforced *after* the decrement, against debt and redemption + // price both projected forward to `now` (§5.3). + crate::checks::assert_position_is_collateralized( + &updated_position, + compute_current_accumulated_rate( + accumulator.accumulated_rate_at_last_accrual, + parameters.stability_fee_per_millisecond, + accumulator.last_accrued_at, + now, + ), + 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); @@ -123,14 +181,18 @@ pub fn withdraw_collateral( AccountPostState::new(owner.account), AccountPostState::new(position_post), AccountPostState::new(vault.account.clone()), - AccountPostState::new(destination.account.clone()), + AccountPostState::new(user_collateral_holding.account.clone()), + AccountPostState::new(stability_fee_accumulator.account), + AccountPostState::new(redemption_price_state.account), + AccountPostState::new(protocol_parameters.account), + AccountPostState::new(clock.account), ]; let mut vault_authorized = vault.clone(); vault_authorized.is_authorized = true; let transfer_call = ChainedCall::new( token_program_id, - vec![vault_authorized, destination], + vec![vault_authorized, user_collateral_holding], &token_core::Instruction::Transfer { amount_to_transfer: amount, }, @@ -139,3 +201,27 @@ 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() +}