From 234c049040899253aa44cd1ecea06b887dc8ee45 Mon Sep 17 00:00:00 2001 From: Andrea Franz Date: Tue, 1 Sep 2026 10:23:34 +0000 Subject: [PATCH 1/3] feat(stablecoin): add deposit_collateral instruction closes #176 --- artifacts/stablecoin-idl.json | 41 +++ .../integration_tests/tests/stablecoin.rs | 57 +++- programs/stablecoin/core/src/lib.rs | 20 ++ .../methods/guest/src/bin/stablecoin.rs | 38 +++ programs/stablecoin/src/deposit_collateral.rs | 131 +++++++++ programs/stablecoin/src/lib.rs | 3 + programs/stablecoin/src/tests.rs | 271 ++++++++++++++++++ 7 files changed, 550 insertions(+), 11 deletions(-) create mode 100644 programs/stablecoin/src/deposit_collateral.rs diff --git a/artifacts/stablecoin-idl.json b/artifacts/stablecoin-idl.json index fd0d43e1..3d47b3e4 100644 --- a/artifacts/stablecoin-idl.json +++ b/artifacts/stablecoin-idl.json @@ -264,6 +264,47 @@ } ] }, + { + "name": "deposit_collateral", + "accounts": [ + { + "name": "owner", + "writable": false, + "signer": true, + "init": false + }, + { + "name": "position", + "writable": true, + "signer": false, + "init": false + }, + { + "name": "vault", + "writable": true, + "signer": false, + "init": false + }, + { + "name": "user_collateral_holding", + "writable": true, + "signer": true, + "init": false + }, + { + "name": "protocol_parameters", + "writable": false, + "signer": false, + "init": false + } + ], + "args": [ + { + "name": "amount", + "type": "u128" + } + ] + }, { "name": "withdraw_collateral", "accounts": [ diff --git a/programs/integration_tests/tests/stablecoin.rs b/programs/integration_tests/tests/stablecoin.rs index bc7ee7a7..eecbf881 100644 --- a/programs/integration_tests/tests/stablecoin.rs +++ b/programs/integration_tests/tests/stablecoin.rs @@ -111,6 +111,10 @@ impl Balances { 500_000 } + fn collateral_top_up() -> u128 { + 100_000 + } + fn collateral_withdraw() -> u128 { 200_000 } @@ -425,6 +429,44 @@ fn stablecoin_open_position_then_withdraw_collateral() { Balances::user_holding_init() - Balances::collateral_deposit(), ); + // Top the position up with more collateral from the same user holding. + let deposit = stablecoin_core::Instruction::DepositCollateral { + amount: Balances::collateral_top_up(), + }; + let message = public_transaction::Message::try_new( + Ids::stablecoin_program(), + vec![ + Ids::owner(), + Ids::position(), + Ids::vault(), + Ids::user_holding(), + compute_protocol_parameters_pda(Ids::stablecoin_program()), + ], + vec![ + current_nonce(&state, Ids::owner()), + current_nonce(&state, Ids::user_holding()), + ], + deposit, + ) + .expect("valid deposit_collateral message"); + let witness_set = public_transaction::WitnessSet::for_message( + &message, + &[&Keys::owner(), &Keys::user_holding()], + ); + let tx = PublicTransaction::new(message, witness_set); + state + .transition_from_public_transaction(&tx, 0, 0) + .expect("deposit_collateral must succeed"); + + let after_top_up = Balances::collateral_deposit() + Balances::collateral_top_up(); + assert_position(&state, after_top_up); + assert_fungible_balance(&state, Ids::vault(), after_top_up); + assert_fungible_balance( + &state, + Ids::user_holding(), + Balances::user_holding_init() - after_top_up, + ); + // Withdraw part of the collateral back to the same user holding. let withdraw = stablecoin_core::Instruction::WithdrawCollateral { amount: Balances::collateral_withdraw(), @@ -447,20 +489,13 @@ fn stablecoin_open_position_then_withdraw_collateral() { .transition_from_public_transaction(&tx, 0, 0) .expect("withdraw_collateral must succeed"); - assert_position( - &state, - Balances::collateral_deposit() - Balances::collateral_withdraw(), - ); - assert_fungible_balance( - &state, - Ids::vault(), - Balances::collateral_deposit() - Balances::collateral_withdraw(), - ); + let after_withdraw = after_top_up - Balances::collateral_withdraw(); + assert_position(&state, after_withdraw); + assert_fungible_balance(&state, Ids::vault(), after_withdraw); assert_fungible_balance( &state, Ids::user_holding(), - Balances::user_holding_init() - Balances::collateral_deposit() - + Balances::collateral_withdraw(), + Balances::user_holding_init() - after_withdraw, ); } diff --git a/programs/stablecoin/core/src/lib.rs b/programs/stablecoin/core/src/lib.rs index 45a3c996..ea0c6b13 100644 --- a/programs/stablecoin/core/src/lib.rs +++ b/programs/stablecoin/core/src/lib.rs @@ -164,6 +164,26 @@ pub enum Instruction { /// Collateral tokens to move into the position vault at open time. initial_collateral_amount: u128, }, + /// Deposit additional collateral into an existing position. + /// + /// Allowed while the protocol is frozen — a deposit can only improve the + /// position's collateralization, so §7 keeps it available in emergencies. + /// No collateralization check for the same reason. + /// + /// Required accounts (5), in order: + /// 1. `owner` — authorized; must match `Position.owner_account_id`. + /// 2. `position` — initialized, writable, owned by this program; address must match + /// `compute_position_pda(self_program_id, owner, position_nonce)`. + /// 3. `vault` — initialized, writable; must equal `Position.vault_account_id`. + /// 4. `user_collateral_holding` — authorized, initialized; owned by the same Token Program as + /// the vault, with `TokenHolding.definition_id` equal to + /// `ProtocolParameters.collateral_definition_id`. + /// 5. `protocol_parameters` — initialized, read-only; supplies the collateral definition id. + /// `is_frozen` is deliberately not read. + DepositCollateral { + /// Collateral tokens to move from the user's holding into the vault. + amount: u128, + }, /// Withdraw `amount` collateral tokens from a position back to a user-controlled holding. /// /// Required accounts (4): diff --git a/programs/stablecoin/methods/guest/src/bin/stablecoin.rs b/programs/stablecoin/methods/guest/src/bin/stablecoin.rs index 15c9b0d3..ba8292d0 100644 --- a/programs/stablecoin/methods/guest/src/bin/stablecoin.rs +++ b/programs/stablecoin/methods/guest/src/bin/stablecoin.rs @@ -243,6 +243,44 @@ mod stablecoin { )) } + /// Deposit additional collateral into an existing position (spec §10.5; + /// host fn `stablecoin_program::deposit_collateral`). + /// + /// Allowed while the protocol is frozen, and runs no collateralization + /// check — a deposit can only improve the position. + /// + /// # Errors + /// Returns the host program's panic-converted error if any precondition + /// fails — see the host fn for the full list. + #[instruction] + pub fn deposit_collateral( + ctx: ProgramContext, + #[account(signer)] + owner: AccountWithMetadata, + #[account(mut)] + position: AccountWithMetadata, + #[account(mut)] + vault: AccountWithMetadata, + #[account(mut, signer)] + user_collateral_holding: AccountWithMetadata, + protocol_parameters: AccountWithMetadata, + amount: u128, + ) -> SpelResult { + let (post_states, chained_calls) = stablecoin_program::deposit_collateral::deposit_collateral( + owner, + position, + vault, + user_collateral_holding, + protocol_parameters, + 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/deposit_collateral.rs b/programs/stablecoin/src/deposit_collateral.rs new file mode 100644 index 00000000..dcda5b1e --- /dev/null +++ b/programs/stablecoin/src/deposit_collateral.rs @@ -0,0 +1,131 @@ +use lee_core::{ + account::{Account, AccountWithMetadata, Data}, + program::{AccountPostState, ChainedCall, ProgramId}, +}; +use stablecoin_core::{verify_position_and_get_seed, Position, ProtocolParameters}; +use token_core::TokenHolding; + +/// Deposit `amount` additional collateral tokens into an existing `position`'s vault. +/// +/// Increases `Position.collateral_amount` by `amount` and emits a single chained +/// `Token::Transfer` from the user's authorized holding into the vault. No PDA +/// seed is attached: the sender is the user's own holding, which the transaction's +/// witness set authorizes. +/// +/// Deliberately allowed while the protocol is frozen, and deliberately skips the +/// §6.2 collateralization check — a deposit can only improve the position, so +/// spec §7 keeps this path open in emergencies. +/// +/// # Panics +/// - `owner` or `user_collateral_holding` is not authorized. +/// - `position` is uninitialized, not owned by `stablecoin_program_id`, does not decode as a +/// [`Position`], or sits at an address that does not match +/// `compute_position_pda(stablecoin_program_id, owner, Position.position_nonce)`. +/// - `Position.owner_account_id` does not match `owner`. +/// - `vault` does not match `Position.vault_account_id`. +/// - `protocol_parameters` is uninitialized, not owned by `stablecoin_program_id`, or does not +/// decode. +/// - `user_collateral_holding` is owned by a different Token Program than the vault, or its +/// `definition_id` is not `ProtocolParameters.collateral_definition_id`. +/// - `Position.collateral_amount + amount` overflows. +pub fn deposit_collateral( + owner: AccountWithMetadata, + position: AccountWithMetadata, + vault: AccountWithMetadata, + user_collateral_holding: AccountWithMetadata, + protocol_parameters: AccountWithMetadata, + stablecoin_program_id: ProgramId, + amount: u128, +) -> (Vec, Vec) { + assert!(owner.is_authorized, "Owner authorization is missing"); + assert!( + user_collateral_holding.is_authorized, + "User collateral holding 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"); + // Binds the position to this owner. The seed is unused downstream — the + // position was already PDA-claimed by `open_position`. + 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" + ); + assert_eq!( + position_data.vault_account_id, vault.account_id, + "Position vault_account_id does not match the vault account" + ); + + assert_ne!( + protocol_parameters.account, + Account::default(), + "ProtocolParameters account must be initialized" + ); + assert_eq!( + protocol_parameters.account.program_owner, stablecoin_program_id, + "ProtocolParameters account must be owned by the stablecoin program" + ); + let parameters = ProtocolParameters::try_from(&protocol_parameters.account.data) + .expect("ProtocolParameters must decode"); + // `is_frozen` is deliberately not read: a deposit only improves the + // position's collateralization, so spec §7 keeps it available when frozen. + + let token_program_id = vault.account.program_owner; + assert_eq!( + user_collateral_holding.account.program_owner, token_program_id, + "User collateral holding must be owned by the same Token Program as the vault" + ); + let user_holding = TokenHolding::try_from(&user_collateral_holding.account.data) + .expect("User holding must be a valid TokenHolding"); + assert_eq!( + user_holding.definition_id(), + parameters.collateral_definition_id, + "User collateral holding does not match the protocol's collateral definition" + ); + + let new_collateral = position_data + .collateral_amount + .checked_add(amount) + .expect("Position collateral_amount overflow"); + + let mut position_post = position.account.clone(); + position_post.data = Data::from(&Position { + collateral_amount: new_collateral, + ..position_data + }); + + let post_states = vec![ + AccountPostState::new(owner.account), + AccountPostState::new(position_post), + AccountPostState::new(vault.account.clone()), + AccountPostState::new(user_collateral_holding.account.clone()), + AccountPostState::new(protocol_parameters.account), + ]; + + // No PDA seed: the sender is the user's own holding, authorized by the + // transaction's witness set. The receiving vault needs no authorization. + let transfer_call = ChainedCall::new( + token_program_id, + vec![user_collateral_holding, vault], + &token_core::Instruction::Transfer { + amount_to_transfer: amount, + }, + ); + + (post_states, vec![transfer_call]) +} diff --git a/programs/stablecoin/src/lib.rs b/programs/stablecoin/src/lib.rs index c2474370..2bcdf504 100644 --- a/programs/stablecoin/src/lib.rs +++ b/programs/stablecoin/src/lib.rs @@ -8,6 +8,9 @@ pub mod accrue_stability_fee; /// Shared validation helpers reused across the position-lifecycle instructions. pub mod checks; +/// Deposit additional collateral into an existing position. +pub mod deposit_collateral; + /// 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 b5aca4a4..f61ce204 100644 --- a/programs/stablecoin/src/tests.rs +++ b/programs/stablecoin/src/tests.rs @@ -627,6 +627,277 @@ fn open_position_echoes_protocol_parameters_and_clock_unchanged() { assert_eq!(*post_states[6].account(), clock.account); } +// --- deposit_collateral (spec §10.5) --- + +const DEPOSIT_AMOUNT: u128 = 250; + +fn deposit( + owner: AccountWithMetadata, + position: AccountWithMetadata, + vault: AccountWithMetadata, + holding: AccountWithMetadata, + parameters: AccountWithMetadata, + amount: u128, +) -> (Vec, Vec) { + crate::deposit_collateral::deposit_collateral( + owner, + position, + vault, + holding, + parameters, + STABLECOIN_PROGRAM_ID, + amount, + ) +} + +#[test] +fn deposit_collateral_adds_to_position_and_emits_transfer() { + let starting_collateral = 500; + let (post_states, chained_calls) = deposit( + owner_account(), + init_position_account(starting_collateral, 0), + init_vault_account(), + user_holding_account(1_000), + protocol_parameters_account(false), + DEPOSIT_AMOUNT, + ); + + assert_eq!(post_states.len(), 5); + + let position = Position::try_from(&post_states[1].account().data).expect("valid Position"); + assert_eq!( + position.collateral_amount, + starting_collateral + DEPOSIT_AMOUNT + ); + // Everything except the collateral is untouched. + assert_eq!(position.normalized_debt_amount, 0); + assert_eq!(position.owner_account_id, owner_id()); + assert_eq!(position.vault_account_id, vault_id()); + + assert_eq!(chained_calls.len(), 1); + let expected = ChainedCall::new( + TOKEN_PROGRAM_ID, + vec![user_holding_account(1_000), init_vault_account()], + &token_core::Instruction::Transfer { + amount_to_transfer: DEPOSIT_AMOUNT, + }, + ); + assert_eq!(chained_calls[0], expected); +} + +#[test] +fn deposit_collateral_works_when_frozen() { + // Opposite of open_position: a deposit only improves collateralization, so + // spec §7 keeps it available while frozen. + let (post_states, chained_calls) = deposit( + owner_account(), + init_position_account(500, 0), + init_vault_account(), + user_holding_account(1_000), + protocol_parameters_account(true), + DEPOSIT_AMOUNT, + ); + + assert_eq!(post_states.len(), 5); + assert_eq!(chained_calls.len(), 1); + let position = Position::try_from(&post_states[1].account().data).expect("valid Position"); + assert_eq!(position.collateral_amount, 500 + DEPOSIT_AMOUNT); +} + +#[test] +fn deposit_collateral_allows_zero_amount() { + let (post_states, chained_calls) = deposit( + owner_account(), + init_position_account(500, 0), + init_vault_account(), + user_holding_account(1_000), + protocol_parameters_account(false), + 0, + ); + + assert_eq!(post_states.len(), 5); + assert_eq!(chained_calls.len(), 1); + let position = Position::try_from(&post_states[1].account().data).expect("valid Position"); + assert_eq!(position.collateral_amount, 500); +} + +#[test] +fn deposit_collateral_leaves_debt_untouched() { + // Deposits never touch normalized_debt_amount — accrual is a read-side + // projection, so no accumulator is consulted here. + let (post_states, _) = deposit( + owner_account(), + init_position_account(500, 42), + init_vault_account(), + user_holding_account(1_000), + protocol_parameters_account(false), + DEPOSIT_AMOUNT, + ); + + let position = Position::try_from(&post_states[1].account().data).expect("valid Position"); + assert_eq!(position.normalized_debt_amount, 42); + assert_eq!(position.collateral_amount, 500 + DEPOSIT_AMOUNT); +} + +#[test] +#[should_panic(expected = "Owner authorization is missing")] +fn deposit_collateral_requires_owner_authorization() { + let mut owner = owner_account(); + owner.is_authorized = false; + deposit( + owner, + init_position_account(500, 0), + init_vault_account(), + user_holding_account(1_000), + protocol_parameters_account(false), + DEPOSIT_AMOUNT, + ); +} + +#[test] +#[should_panic(expected = "User collateral holding authorization is missing")] +fn deposit_collateral_requires_user_holding_authorization() { + let mut holding = user_holding_account(1_000); + holding.is_authorized = false; + deposit( + owner_account(), + init_position_account(500, 0), + init_vault_account(), + holding, + protocol_parameters_account(false), + DEPOSIT_AMOUNT, + ); +} + +#[test] +#[should_panic(expected = "Position account must be initialized")] +fn deposit_collateral_rejects_uninitialized_position() { + deposit( + owner_account(), + uninit_position_account(), + init_vault_account(), + user_holding_account(1_000), + protocol_parameters_account(false), + DEPOSIT_AMOUNT, + ); +} + +#[test] +#[should_panic(expected = "Position is not owned by this stablecoin program")] +fn deposit_collateral_rejects_position_owned_by_other_program() { + let mut position = init_position_account(500, 0); + position.account.program_owner = [9u32; 8]; + deposit( + owner_account(), + position, + init_vault_account(), + user_holding_account(1_000), + protocol_parameters_account(false), + DEPOSIT_AMOUNT, + ); +} + +#[test] +#[should_panic(expected = "Position account ID does not match expected derivation")] +fn deposit_collateral_rejects_wrong_position_address() { + let mut position = init_position_account(500, 0); + position.account_id = AccountId::new([0x77u8; 32]); + deposit( + owner_account(), + position, + init_vault_account(), + user_holding_account(1_000), + protocol_parameters_account(false), + DEPOSIT_AMOUNT, + ); +} + +#[test] +#[should_panic(expected = "ProtocolParameters account must be initialized")] +fn deposit_collateral_rejects_uninitialized_protocol_parameters() { + deposit( + owner_account(), + init_position_account(500, 0), + init_vault_account(), + user_holding_account(1_000), + AccountWithMetadata { + account: Account::default(), + is_authorized: false, + account_id: protocol_parameters_id(), + }, + DEPOSIT_AMOUNT, + ); +} + +#[test] +#[should_panic(expected = "Position vault_account_id does not match the vault account")] +fn deposit_collateral_rejects_wrong_vault() { + let mut vault = init_vault_account(); + vault.account_id = AccountId::new([0x88u8; 32]); + deposit( + owner_account(), + init_position_account(500, 0), + vault, + user_holding_account(1_000), + protocol_parameters_account(false), + DEPOSIT_AMOUNT, + ); +} + +#[test] +#[should_panic(expected = "same Token Program as the vault")] +fn deposit_collateral_rejects_holding_with_wrong_token_program() { + let mut holding = user_holding_account(1_000); + holding.account.program_owner = [9u32; 8]; + deposit( + owner_account(), + init_position_account(500, 0), + init_vault_account(), + holding, + protocol_parameters_account(false), + DEPOSIT_AMOUNT, + ); +} + +#[test] +#[should_panic(expected = "does not match the protocol's collateral definition")] +fn deposit_collateral_rejects_holding_for_other_definition() { + let holding = AccountWithMetadata { + account: Account { + program_owner: TOKEN_PROGRAM_ID, + balance: 0, + data: Data::from(&TokenHolding::Fungible { + definition_id: AccountId::new([0x21u8; 32]), + balance: 1_000, + }), + nonce: Nonce(0), + }, + is_authorized: true, + account_id: user_holding_id(), + }; + deposit( + owner_account(), + init_position_account(500, 0), + init_vault_account(), + holding, + protocol_parameters_account(false), + DEPOSIT_AMOUNT, + ); +} + +#[test] +#[should_panic(expected = "Position collateral_amount overflow")] +fn deposit_collateral_rejects_overflow() { + deposit( + owner_account(), + init_position_account(u128::MAX, 0), + init_vault_account(), + user_holding_account(1_000), + protocol_parameters_account(false), + 1, + ); +} + #[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); From efcbbdebcebcb9f674948030f81191f605d651ac Mon Sep 17 00:00:00 2001 From: Andrea Franz Date: Tue, 1 Sep 2026 10:40:34 +0000 Subject: [PATCH 2/3] fix(stablecoin): pin protocol_parameters to its canonical PDA in deposit_collateral --- programs/stablecoin/core/src/lib.rs | 5 +++-- programs/stablecoin/src/deposit_collateral.rs | 12 +++++++++++- programs/stablecoin/src/tests.rs | 15 +++++++++++++++ 3 files changed, 29 insertions(+), 3 deletions(-) diff --git a/programs/stablecoin/core/src/lib.rs b/programs/stablecoin/core/src/lib.rs index ea0c6b13..3efe4f4d 100644 --- a/programs/stablecoin/core/src/lib.rs +++ b/programs/stablecoin/core/src/lib.rs @@ -178,8 +178,9 @@ pub enum Instruction { /// 4. `user_collateral_holding` — authorized, initialized; owned by the same Token Program as /// the vault, with `TokenHolding.definition_id` equal to /// `ProtocolParameters.collateral_definition_id`. - /// 5. `protocol_parameters` — initialized, read-only; supplies the collateral definition id. - /// `is_frozen` is deliberately not read. + /// 5. `protocol_parameters` — initialized, read-only; must sit at + /// `compute_protocol_parameters_pda(self_program_id)`. Supplies the collateral definition + /// id. `is_frozen` is deliberately not read. DepositCollateral { /// Collateral tokens to move from the user's holding into the vault. amount: u128, diff --git a/programs/stablecoin/src/deposit_collateral.rs b/programs/stablecoin/src/deposit_collateral.rs index dcda5b1e..0641d75d 100644 --- a/programs/stablecoin/src/deposit_collateral.rs +++ b/programs/stablecoin/src/deposit_collateral.rs @@ -2,7 +2,9 @@ use lee_core::{ account::{Account, AccountWithMetadata, Data}, program::{AccountPostState, ChainedCall, ProgramId}, }; -use stablecoin_core::{verify_position_and_get_seed, Position, ProtocolParameters}; +use stablecoin_core::{ + compute_protocol_parameters_pda, verify_position_and_get_seed, Position, ProtocolParameters, +}; use token_core::TokenHolding; /// Deposit `amount` additional collateral tokens into an existing `position`'s vault. @@ -80,6 +82,14 @@ pub fn deposit_collateral( protocol_parameters.account.program_owner, stablecoin_program_id, "ProtocolParameters account must be owned by the stablecoin program" ); + // Pin the address: ownership plus a successful decode would otherwise let + // any stablecoin-owned account that decodes as ProtocolParameters stand in + // for the global config and redirect the collateral-definition binding. + assert_eq!( + protocol_parameters.account_id, + compute_protocol_parameters_pda(stablecoin_program_id), + "ProtocolParameters account ID does not match expected PDA derivation" + ); let parameters = ProtocolParameters::try_from(&protocol_parameters.account.data) .expect("ProtocolParameters must decode"); // `is_frozen` is deliberately not read: a deposit only improves the diff --git a/programs/stablecoin/src/tests.rs b/programs/stablecoin/src/tests.rs index f61ce204..49e9c4f7 100644 --- a/programs/stablecoin/src/tests.rs +++ b/programs/stablecoin/src/tests.rs @@ -829,6 +829,21 @@ fn deposit_collateral_rejects_uninitialized_protocol_parameters() { ); } +#[test] +#[should_panic(expected = "ProtocolParameters account ID does not match expected PDA derivation")] +fn deposit_collateral_rejects_protocol_parameters_at_wrong_address() { + let mut parameters = protocol_parameters_account(false); + parameters.account_id = AccountId::new([0xC0u8; 32]); + deposit( + owner_account(), + init_position_account(500, 0), + init_vault_account(), + user_holding_account(1_000), + parameters, + DEPOSIT_AMOUNT, + ); +} + #[test] #[should_panic(expected = "Position vault_account_id does not match the vault account")] fn deposit_collateral_rejects_wrong_vault() { From b13e825132e30f6148321b0a01560ad642a0a0ed Mon Sep 17 00:00:00 2001 From: Andrea Franz Date: Wed, 2 Sep 2026 12:06:47 +0000 Subject: [PATCH 3/3] fix(stablecoin): validate the vault holding in deposit_collateral --- programs/stablecoin/src/deposit_collateral.rs | 20 ++++++++++++- programs/stablecoin/src/tests.rs | 29 +++++++++++++++++++ 2 files changed, 48 insertions(+), 1 deletion(-) diff --git a/programs/stablecoin/src/deposit_collateral.rs b/programs/stablecoin/src/deposit_collateral.rs index 0641d75d..e8ee1de1 100644 --- a/programs/stablecoin/src/deposit_collateral.rs +++ b/programs/stablecoin/src/deposit_collateral.rs @@ -24,7 +24,8 @@ use token_core::TokenHolding; /// [`Position`], or sits at an address that does not match /// `compute_position_pda(stablecoin_program_id, owner, Position.position_nonce)`. /// - `Position.owner_account_id` does not match `owner`. -/// - `vault` does not match `Position.vault_account_id`. +/// - `vault` does not match `Position.vault_account_id`, is uninitialized, does not decode as a +/// [`TokenHolding`], or holds a token other than the protocol's collateral definition. /// - `protocol_parameters` is uninitialized, not owned by `stablecoin_program_id`, or does not /// decode. /// - `user_collateral_holding` is owned by a different Token Program than the vault, or its @@ -95,6 +96,23 @@ pub fn deposit_collateral( // `is_frozen` is deliberately not read: a deposit only improves the // position's collateralization, so spec §7 keeps it available when frozen. + // Validate the vault before trusting its `program_owner` to route the chained + // call. Without this the transfer would be aimed at whatever program owns the + // account and left to fail downstream, and a vault holding some other token + // would silently mis-bank the deposit. + assert_ne!( + vault.account, + Account::default(), + "Vault account must be initialized" + ); + let vault_holding = TokenHolding::try_from(&vault.account.data) + .expect("Vault account must hold a valid TokenHolding"); + assert_eq!( + vault_holding.definition_id(), + parameters.collateral_definition_id, + "Vault holding does not match the protocol's collateral definition" + ); + let token_program_id = vault.account.program_owner; assert_eq!( user_collateral_holding.account.program_owner, token_program_id, diff --git a/programs/stablecoin/src/tests.rs b/programs/stablecoin/src/tests.rs index 49e9c4f7..08554165 100644 --- a/programs/stablecoin/src/tests.rs +++ b/programs/stablecoin/src/tests.rs @@ -844,6 +844,35 @@ fn deposit_collateral_rejects_protocol_parameters_at_wrong_address() { ); } +#[test] +#[should_panic(expected = "Vault account must be initialized")] +fn deposit_collateral_rejects_uninitialized_vault() { + deposit( + owner_account(), + init_position_account(500, 0), + uninit_vault_account(), + user_holding_account(1_000), + protocol_parameters_account(false), + DEPOSIT_AMOUNT, + ); +} + +#[test] +#[should_panic(expected = "Vault holding does not match the protocol's collateral definition")] +fn deposit_collateral_rejects_vault_holding_for_other_definition() { + // The vault is at the right address but holds a different token, so moving + // the user's collateral into it would silently mis-bank the deposit. + let vault = token_holding_account(vault_id(), AccountId::new([0x21u8; 32]), 0); + deposit( + owner_account(), + init_position_account(500, 0), + vault, + user_holding_account(1_000), + protocol_parameters_account(false), + DEPOSIT_AMOUNT, + ); +} + #[test] #[should_panic(expected = "Position vault_account_id does not match the vault account")] fn deposit_collateral_rejects_wrong_vault() {