From 8b2006ba44b4fc1ec66f6693918e512e487e85cc Mon Sep 17 00:00:00 2001 From: Ricardo Guilherme Schmidt <3esmit@gmail.com> Date: Wed, 27 May 2026 10:05:49 -0300 Subject: [PATCH 1/3] feat(stablecoin): add collateral deposits --- artifacts/stablecoin-idl.json | 35 +++ .../integration_tests/tests/stablecoin.rs | 61 ++++- programs/stablecoin/core/src/lib.rs | 16 ++ .../methods/guest/src/bin/stablecoin.rs | 31 +++ programs/stablecoin/src/deposit_collateral.rs | 124 +++++++++ programs/stablecoin/src/lib.rs | 3 + programs/stablecoin/src/tests.rs | 247 ++++++++++++++++++ 7 files changed, 513 insertions(+), 4 deletions(-) create mode 100644 programs/stablecoin/src/deposit_collateral.rs diff --git a/artifacts/stablecoin-idl.json b/artifacts/stablecoin-idl.json index 5c601e8..be9859c 100644 --- a/artifacts/stablecoin-idl.json +++ b/artifacts/stablecoin-idl.json @@ -43,6 +43,41 @@ } ] }, + { + "name": "deposit_collateral", + "accounts": [ + { + "name": "owner", + "writable": false, + "signer": false, + "init": false + }, + { + "name": "position", + "writable": false, + "signer": false, + "init": false + }, + { + "name": "vault", + "writable": false, + "signer": false, + "init": false + }, + { + "name": "user_holding", + "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 4044d84..7025d35 100644 --- a/programs/integration_tests/tests/stablecoin.rs +++ b/programs/integration_tests/tests/stablecoin.rs @@ -82,6 +82,10 @@ impl Balances { 200_000 } + fn collateral_extra_deposit() -> u128 { + 100_000 + } + fn stablecoin_supply_init() -> u128 { 1_000 } @@ -247,7 +251,7 @@ fn assert_fungible_balance(state: &V03State, account_id: AccountId, expected_bal } #[test] -fn stablecoin_open_position_then_withdraw_collateral() { +fn stablecoin_open_position_deposit_then_withdraw_collateral() { let mut state = state_for_stablecoin_tests(); // Open the position: deposit collateral from the user's holding into a fresh vault. @@ -287,6 +291,51 @@ fn stablecoin_open_position_then_withdraw_collateral() { Balances::user_holding_init() - Balances::collateral_deposit(), ); + // Deposit more collateral into the existing position. + let deposit = stablecoin_core::Instruction::DepositCollateral { + amount: Balances::collateral_extra_deposit(), + }; + let message = public_transaction::Message::try_new( + Ids::stablecoin_program(), + vec![ + Ids::owner(), + Ids::position(), + Ids::vault(), + Ids::user_holding(), + ], + vec![ + current_nonce(&state, Ids::owner()), + current_nonce(&state, Ids::user_holding()), + ], + deposit, + ) + .unwrap(); + 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"); + + assert_position( + &state, + Balances::collateral_deposit() + Balances::collateral_extra_deposit(), + ); + assert_fungible_balance( + &state, + Ids::vault(), + Balances::collateral_deposit() + Balances::collateral_extra_deposit(), + ); + assert_fungible_balance( + &state, + Ids::user_holding(), + Balances::user_holding_init() + - Balances::collateral_deposit() + - Balances::collateral_extra_deposit(), + ); + // Withdraw part of the collateral back to the same user holding. let withdraw = stablecoin_core::Instruction::WithdrawCollateral { amount: Balances::collateral_withdraw(), @@ -311,17 +360,21 @@ fn stablecoin_open_position_then_withdraw_collateral() { assert_position( &state, - Balances::collateral_deposit() - Balances::collateral_withdraw(), + Balances::collateral_deposit() + Balances::collateral_extra_deposit() + - Balances::collateral_withdraw(), ); assert_fungible_balance( &state, Ids::vault(), - Balances::collateral_deposit() - Balances::collateral_withdraw(), + Balances::collateral_deposit() + Balances::collateral_extra_deposit() + - Balances::collateral_withdraw(), ); assert_fungible_balance( &state, Ids::user_holding(), - Balances::user_holding_init() - Balances::collateral_deposit() + Balances::user_holding_init() + - Balances::collateral_deposit() + - Balances::collateral_extra_deposit() + Balances::collateral_withdraw(), ); } diff --git a/programs/stablecoin/core/src/lib.rs b/programs/stablecoin/core/src/lib.rs index b5f51ac..b80416e 100644 --- a/programs/stablecoin/core/src/lib.rs +++ b/programs/stablecoin/core/src/lib.rs @@ -30,6 +30,22 @@ pub enum Instruction { /// Amount of collateral tokens to deposit into the position vault. collateral_amount: u128, }, + /// Deposit additional collateral tokens into an existing position vault. + /// + /// Required accounts (4): + /// - Owner account (authorized; binds caller-as-owner via position PDA re-derivation) + /// - Position account (initialized, owned by `self_program_id`) + /// - Position vault token holding (address must match + /// `compute_position_vault_pda(self_program_id, position_id)`) + /// - User's source token holding for the collateral (authorized, initialized, owned by the + /// same Token Program as the vault, with `TokenHolding.definition_id == + /// Position.collateral_definition_id`) + /// + /// No collateralization check is needed because this instruction never increases debt. + DepositCollateral { + /// Amount of collateral tokens to deposit into the position 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 f677ca3..5366260 100644 --- a/programs/stablecoin/methods/guest/src/bin/stablecoin.rs +++ b/programs/stablecoin/methods/guest/src/bin/stablecoin.rs @@ -42,6 +42,37 @@ mod stablecoin { )) } + /// Deposit additional collateral tokens into an existing position vault. + /// + /// # Errors + /// Returns the host program's panic-converted error if any precondition + /// fails (see + /// [`stablecoin_program::deposit_collateral::deposit_collateral`] for the + /// full list). + #[instruction] + pub fn deposit_collateral( + ctx: ProgramContext, + owner: AccountWithMetadata, + position: AccountWithMetadata, + vault: AccountWithMetadata, + user_holding: AccountWithMetadata, + amount: u128, + ) -> SpelResult { + let (post_states, chained_calls) = + stablecoin_program::deposit_collateral::deposit_collateral( + owner, + position, + vault, + user_holding, + 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 0000000..946eee3 --- /dev/null +++ b/programs/stablecoin/src/deposit_collateral.rs @@ -0,0 +1,124 @@ +use nssa_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 token_core::TokenHolding; + +/// Deposit `amount` collateral tokens from `user_holding` into `position`'s vault. +/// +/// Increases `Position.collateral_amount` by `amount` and emits a single chained +/// `Token::Transfer` from the user holding to the vault. No collateralization +/// check is required because debt is unchanged. +/// +/// # Panics +/// - `owner` or `user_holding` is not authorized. +/// - `position` is uninitialized, not owned by `stablecoin_program_id`, holds data that does not +/// decode as a [`Position`], or sits at an address that does not match +/// `compute_position_pda(stablecoin_program_id, owner, Position.collateral_definition_id)`. +/// - `vault` is uninitialized, sits at an address that does not match +/// `compute_position_vault_pda(stablecoin_program_id, position_id)`, or holds a [`TokenHolding`] +/// whose `definition_id` does not match the position's collateral definition. +/// - `user_holding` is uninitialized, owned by a different Token Program than the vault, or holds a +/// [`TokenHolding`] whose `definition_id` does not match the position's collateral definition. +/// - `Position.collateral_amount + amount` overflows. +pub fn deposit_collateral( + owner: AccountWithMetadata, + position: AccountWithMetadata, + vault: AccountWithMetadata, + user_holding: AccountWithMetadata, + stablecoin_program_id: ProgramId, + amount: u128, +) -> (Vec, Vec) { + assert!(owner.is_authorized, "Owner authorization is missing"); + assert!( + user_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" + ); + assert_ne!( + vault.account, + Account::default(), + "Vault must be initialized" + ); + assert_ne!( + user_holding.account, + Account::default(), + "User collateral holding must be initialized" + ); + + 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.collateral_definition_id, + stablecoin_program_id, + ); + let _vault_seed = + verify_position_vault_and_get_seed(&vault, position.account_id, stablecoin_program_id); + assert_eq!( + position_data.collateral_vault_id, vault.account_id, + "Position collateral vault does not match provided vault" + ); + + let vault_holding = TokenHolding::try_from(&vault.account.data) + .expect("Vault account must hold a valid TokenHolding"); + assert_eq!( + vault_holding.definition_id(), + position_data.collateral_definition_id, + "Vault token holding is not for the position's collateral definition" + ); + + let token_program_id = vault.account.program_owner; + assert_eq!( + user_holding.account.program_owner, token_program_id, + "User collateral holding must be owned by same Token Program as the vault" + ); + let user_holding_data = TokenHolding::try_from(&user_holding.account.data) + .expect("User collateral holding must hold a valid TokenHolding"); + assert_eq!( + user_holding_data.definition_id(), + position_data.collateral_definition_id, + "User collateral holding does not match the position's collateral definition" + ); + + let new_collateral = position_data + .collateral_amount + .checked_add(amount) + .expect("Deposit amount overflows position collateral"); + + let updated_position = Position { + collateral_vault_id: position_data.collateral_vault_id, + collateral_definition_id: position_data.collateral_definition_id, + collateral_amount: new_collateral, + debt_amount: position_data.debt_amount, + }; + 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(vault.account.clone()), + AccountPostState::new(user_holding.account.clone()), + ]; + + let transfer_call = ChainedCall::new( + token_program_id, + vec![user_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 690024e..93dfa9f 100644 --- a/programs/stablecoin/src/lib.rs +++ b/programs/stablecoin/src/lib.rs @@ -2,6 +2,9 @@ pub use stablecoin_core as core; +/// Deposit additional collateral into an existing position. +pub mod deposit_collateral; + /// Open a new collateral-only position for a calling owner. pub mod open_position; diff --git a/programs/stablecoin/src/tests.rs b/programs/stablecoin/src/tests.rs index 41e0154..6053b50 100644 --- a/programs/stablecoin/src/tests.rs +++ b/programs/stablecoin/src/tests.rs @@ -521,6 +521,253 @@ fn withdraw_collateral_updates_position_and_emits_transfer() { assert_eq!(chained_calls[0], expected_transfer); } +#[test] +fn deposit_collateral_updates_position_and_emits_transfer() { + let initial_collateral: u128 = 500; + let initial_debt: u128 = 300; + let amount: u128 = 200; + let holding_balance: u128 = 1_000; + + let (post_states, chained_calls) = crate::deposit_collateral::deposit_collateral( + owner_account(), + init_position_account(initial_collateral, initial_debt), + init_vault_account(), + user_holding_account(holding_balance), + STABLECOIN_PROGRAM_ID, + amount, + ); + + assert_eq!(post_states.len(), 4); + + let position_post = &post_states[1]; + assert_eq!(position_post.required_claim(), None); + let position = Position::try_from(&position_post.account().data).expect("valid Position"); + assert_eq!( + position, + Position { + collateral_vault_id: vault_id(), + collateral_definition_id: collateral_definition_id(), + collateral_amount: initial_collateral + amount, + debt_amount: initial_debt, + } + ); + assert_eq!(position_post.account().program_owner, STABLECOIN_PROGRAM_ID); + + assert_eq!(post_states[2].account(), &init_vault_account().account); + assert_eq!( + post_states[3].account(), + &user_holding_account(holding_balance).account + ); + + assert_eq!(chained_calls.len(), 1); + let expected_transfer = ChainedCall::new( + TOKEN_PROGRAM_ID, + vec![user_holding_account(holding_balance), init_vault_account()], + &token_core::Instruction::Transfer { + amount_to_transfer: amount, + }, + ); + assert_eq!(chained_calls[0], expected_transfer); +} + +#[test] +fn deposit_collateral_allows_zero_amount() { + let initial: u128 = 500; + let (post_states, chained_calls) = crate::deposit_collateral::deposit_collateral( + owner_account(), + init_position_account(initial, 0), + init_vault_account(), + user_holding_account(1_000), + STABLECOIN_PROGRAM_ID, + 0, + ); + let position = Position::try_from(&post_states[1].account().data).expect("valid Position"); + assert_eq!(position.collateral_amount, initial); + + let expected_transfer = ChainedCall::new( + TOKEN_PROGRAM_ID, + vec![user_holding_account(1_000), init_vault_account()], + &token_core::Instruction::Transfer { + amount_to_transfer: 0, + }, + ); + assert_eq!(chained_calls, vec![expected_transfer]); +} + +#[test] +#[should_panic(expected = "Owner authorization is missing")] +fn deposit_collateral_requires_owner_authorization() { + let mut owner = owner_account(); + owner.is_authorized = false; + crate::deposit_collateral::deposit_collateral( + owner, + init_position_account(500, 0), + init_vault_account(), + user_holding_account(1_000), + STABLECOIN_PROGRAM_ID, + 100, + ); +} + +#[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; + crate::deposit_collateral::deposit_collateral( + owner_account(), + init_position_account(500, 0), + init_vault_account(), + holding, + STABLECOIN_PROGRAM_ID, + 100, + ); +} + +#[test] +#[should_panic(expected = "Position account must be initialized")] +fn deposit_collateral_rejects_uninitialized_position() { + crate::deposit_collateral::deposit_collateral( + owner_account(), + uninit_position_account(), + init_vault_account(), + user_holding_account(1_000), + STABLECOIN_PROGRAM_ID, + 100, + ); +} + +#[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]; + crate::deposit_collateral::deposit_collateral( + owner_account(), + position, + init_vault_account(), + user_holding_account(1_000), + STABLECOIN_PROGRAM_ID, + 100, + ); +} + +#[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([0xFFu8; 32]); + crate::deposit_collateral::deposit_collateral( + owner_account(), + position, + init_vault_account(), + user_holding_account(1_000), + STABLECOIN_PROGRAM_ID, + 100, + ); +} + +#[test] +#[should_panic(expected = "Position vault account ID does not match expected derivation")] +fn deposit_collateral_rejects_wrong_vault_address() { + let mut vault = init_vault_account(); + vault.account_id = AccountId::new([0xEEu8; 32]); + crate::deposit_collateral::deposit_collateral( + owner_account(), + init_position_account(500, 0), + vault, + user_holding_account(1_000), + STABLECOIN_PROGRAM_ID, + 100, + ); +} + +#[test] +#[should_panic(expected = "Vault token holding is not for the position's collateral definition")] +fn deposit_collateral_rejects_vault_for_other_definition() { + let mut vault = init_vault_account(); + vault.account.data = Data::from(&TokenHolding::Fungible { + definition_id: AccountId::new([0x21u8; 32]), + balance: 0, + }); + crate::deposit_collateral::deposit_collateral( + owner_account(), + init_position_account(500, 0), + vault, + user_holding_account(1_000), + STABLECOIN_PROGRAM_ID, + 100, + ); +} + +#[test] +#[should_panic(expected = "User collateral holding must be initialized")] +fn deposit_collateral_rejects_uninitialized_user_holding() { + let holding = AccountWithMetadata { + account: Account::default(), + is_authorized: true, + account_id: user_holding_id(), + }; + crate::deposit_collateral::deposit_collateral( + owner_account(), + init_position_account(500, 0), + init_vault_account(), + holding, + STABLECOIN_PROGRAM_ID, + 100, + ); +} + +#[test] +#[should_panic( + expected = "User collateral holding must be owned by same Token Program as the vault" +)] +fn deposit_collateral_rejects_holding_with_different_token_program() { + let mut holding = user_holding_account(1_000); + holding.account.program_owner = [9u32; 8]; + crate::deposit_collateral::deposit_collateral( + owner_account(), + init_position_account(500, 0), + init_vault_account(), + holding, + STABLECOIN_PROGRAM_ID, + 100, + ); +} + +#[test] +#[should_panic( + expected = "User collateral holding does not match the position's collateral definition" +)] +fn deposit_collateral_rejects_holding_for_other_definition() { + let mut holding = user_holding_account(1_000); + holding.account.data = Data::from(&TokenHolding::Fungible { + definition_id: AccountId::new([0x21u8; 32]), + balance: 1_000, + }); + crate::deposit_collateral::deposit_collateral( + owner_account(), + init_position_account(500, 0), + init_vault_account(), + holding, + STABLECOIN_PROGRAM_ID, + 100, + ); +} + +#[test] +#[should_panic(expected = "Deposit amount overflows position collateral")] +fn deposit_collateral_rejects_collateral_overflow() { + crate::deposit_collateral::deposit_collateral( + owner_account(), + init_position_account(u128::MAX, 0), + init_vault_account(), + user_holding_account(1_000), + STABLECOIN_PROGRAM_ID, + 1, + ); +} + #[test] fn withdraw_collateral_allows_full_drain() { let amount: u128 = 500; From ce61e801f2edb5b914977ef4664163e4069b27ba Mon Sep 17 00:00:00 2001 From: Ricardo Guilherme Schmidt <3esmit@gmail.com> Date: Mon, 1 Jun 2026 11:26:15 -0300 Subject: [PATCH 2/3] fix(stablecoin): harden collateral deposits --- artifacts/stablecoin-idl.json | 12 +- .../integration_tests/tests/stablecoin.rs | 1 + programs/stablecoin/core/src/lib.rs | 32 +- .../methods/guest/src/bin/stablecoin.rs | 6 + programs/stablecoin/src/deposit_collateral.rs | 194 ++++-- programs/stablecoin/src/tests.rs | 578 +++++++++++++----- 6 files changed, 607 insertions(+), 216 deletions(-) diff --git a/artifacts/stablecoin-idl.json b/artifacts/stablecoin-idl.json index be9859c..1fd73cc 100644 --- a/artifacts/stablecoin-idl.json +++ b/artifacts/stablecoin-idl.json @@ -49,23 +49,29 @@ { "name": "owner", "writable": false, - "signer": false, + "signer": true, "init": false }, { "name": "position", - "writable": false, + "writable": true, "signer": false, "init": false }, { "name": "vault", - "writable": false, + "writable": true, "signer": false, "init": false }, { "name": "user_holding", + "writable": true, + "signer": true, + "init": false + }, + { + "name": "token_definition", "writable": false, "signer": false, "init": false diff --git a/programs/integration_tests/tests/stablecoin.rs b/programs/integration_tests/tests/stablecoin.rs index 7025d35..1f7a3b3 100644 --- a/programs/integration_tests/tests/stablecoin.rs +++ b/programs/integration_tests/tests/stablecoin.rs @@ -302,6 +302,7 @@ fn stablecoin_open_position_deposit_then_withdraw_collateral() { Ids::position(), Ids::vault(), Ids::user_holding(), + Ids::collateral_definition(), ], vec![ current_nonce(&state, Ids::owner()), diff --git a/programs/stablecoin/core/src/lib.rs b/programs/stablecoin/core/src/lib.rs index b80416e..4cfc43e 100644 --- a/programs/stablecoin/core/src/lib.rs +++ b/programs/stablecoin/core/src/lib.rs @@ -11,6 +11,11 @@ use spel_framework_macros::account_type; const POSITION_PDA_DOMAIN: [u8; 32] = [0; 32]; const POSITION_VAULT_PDA_DOMAIN: [u8; 32] = [1; 32]; +pub const ERR_POSITION_ACCOUNT_ID_MISMATCH: &str = + "Position account ID does not match expected derivation"; +pub const ERR_POSITION_VAULT_ACCOUNT_ID_MISMATCH: &str = + "Position vault account ID does not match expected derivation"; + /// Stablecoin Program Instruction. #[derive(Debug, Serialize, Deserialize)] pub enum Instruction { @@ -32,14 +37,17 @@ pub enum Instruction { }, /// Deposit additional collateral tokens into an existing position vault. /// - /// Required accounts (4): + /// Required accounts (5): /// - Owner account (authorized; binds caller-as-owner via position PDA re-derivation) /// - Position account (initialized, owned by `self_program_id`) /// - Position vault token holding (address must match /// `compute_position_vault_pda(self_program_id, position_id)`) /// - User's source token holding for the collateral (authorized, initialized, owned by the - /// same Token Program as the vault, with `TokenHolding.definition_id == + /// same Token Program as the token definition, with `TokenHolding.definition_id == /// Position.collateral_definition_id`) + /// - Token definition account for the collateral (matches `Position.collateral_definition_id`; + /// must be fungible, and its `program_owner` determines the Token Program used by the + /// chained `Transfer` call) /// /// No collateralization check is needed because this instruction never increases debt. DepositCollateral { @@ -202,10 +210,12 @@ pub fn verify_position_and_get_seed( ) -> PdaSeed { let seed = compute_position_pda_seed(owner.account_id, collateral_definition_id); let expected_id = AccountId::for_public_pda(&stablecoin_program_id, &seed); - assert_eq!( - position.account_id, expected_id, - "Position account ID does not match expected derivation" - ); + if position.account_id != expected_id { + panic!( + "{ERR_POSITION_ACCOUNT_ID_MISMATCH}: provided {}, expected {}", + position.account_id, expected_id + ); + } seed } @@ -222,9 +232,11 @@ pub fn verify_position_vault_and_get_seed( ) -> PdaSeed { let seed = compute_position_vault_pda_seed(position_id); let expected_id = AccountId::for_public_pda(&stablecoin_program_id, &seed); - assert_eq!( - vault.account_id, expected_id, - "Position vault account ID does not match expected derivation" - ); + if vault.account_id != expected_id { + panic!( + "{ERR_POSITION_VAULT_ACCOUNT_ID_MISMATCH}: provided {}, expected {}", + vault.account_id, expected_id + ); + } seed } diff --git a/programs/stablecoin/methods/guest/src/bin/stablecoin.rs b/programs/stablecoin/methods/guest/src/bin/stablecoin.rs index 5366260..4dc915f 100644 --- a/programs/stablecoin/methods/guest/src/bin/stablecoin.rs +++ b/programs/stablecoin/methods/guest/src/bin/stablecoin.rs @@ -52,10 +52,15 @@ mod stablecoin { #[instruction] pub fn deposit_collateral( ctx: ProgramContext, + #[account(signer)] owner: AccountWithMetadata, + #[account(mut)] position: AccountWithMetadata, + #[account(mut)] vault: AccountWithMetadata, + #[account(mut, signer)] user_holding: AccountWithMetadata, + token_definition: AccountWithMetadata, amount: u128, ) -> SpelResult { let (post_states, chained_calls) = @@ -64,6 +69,7 @@ mod stablecoin { position, vault, user_holding, + token_definition, ctx.self_program_id, amount, ); diff --git a/programs/stablecoin/src/deposit_collateral.rs b/programs/stablecoin/src/deposit_collateral.rs index 946eee3..f4983ba 100644 --- a/programs/stablecoin/src/deposit_collateral.rs +++ b/programs/stablecoin/src/deposit_collateral.rs @@ -1,15 +1,61 @@ use nssa_core::{ account::{Account, AccountWithMetadata, Data}, - program::{AccountPostState, ChainedCall, ProgramId}, + program::{AccountPostState, ChainedCall, ProgramId, DEFAULT_PROGRAM_ID}, }; use stablecoin_core::{verify_position_and_get_seed, verify_position_vault_and_get_seed, Position}; -use token_core::TokenHolding; +use token_core::{TokenDefinition, TokenHolding}; + +pub(crate) const ERR_OWNER_AUTHORIZATION_MISSING: &str = "Owner authorization is missing"; +pub(crate) const ERR_USER_HOLDING_AUTHORIZATION_MISSING: &str = + "User collateral holding authorization is missing"; +pub(crate) const ERR_POSITION_UNINITIALIZED: &str = "Position account must be initialized"; +pub(crate) const ERR_POSITION_WRONG_PROGRAM_OWNER: &str = + "Position is not owned by this stablecoin program"; +pub(crate) const ERR_VAULT_UNINITIALIZED: &str = "Vault must be initialized"; +pub(crate) const ERR_USER_HOLDING_UNINITIALIZED: &str = + "User collateral holding must be initialized"; +pub(crate) const ERR_POSITION_INVALID_STATE: &str = + "Position account must hold valid Position state"; +pub(crate) const ERR_POSITION_VAULT_MISMATCH: &str = + "Position collateral vault does not match provided vault"; +pub(crate) const ERR_TOKEN_DEFINITION_MISMATCH: &str = + "Token definition does not match the position's collateral definition"; +pub(crate) const ERR_TOKEN_DEFINITION_UNINITIALIZED: &str = + "Collateral token definition must be initialized"; +pub(crate) const ERR_TOKEN_DEFINITION_INVALID: &str = + "Collateral token definition must hold a valid TokenDefinition"; +pub(crate) const ERR_TOKEN_DEFINITION_NOT_FUNGIBLE: &str = + "Collateral token definition must be fungible"; +pub(crate) const ERR_TOKEN_PROGRAM_MISMATCH: &str = + "Collateral token definition, position vault, and user collateral holding must be owned by the same Token Program"; +pub(crate) const ERR_VAULT_INVALID_HOLDING: &str = "Vault account must hold a valid TokenHolding"; +pub(crate) const ERR_VAULT_WRONG_DEFINITION: &str = + "Vault token holding is not for the position's collateral definition"; +pub(crate) const ERR_VAULT_NOT_FUNGIBLE: &str = "Position vault must be fungible"; +pub(crate) const ERR_USER_HOLDING_INVALID: &str = + "User collateral holding must hold a valid TokenHolding"; +pub(crate) const ERR_USER_HOLDING_WRONG_DEFINITION: &str = + "User collateral holding does not match the position's collateral definition"; +pub(crate) const ERR_USER_HOLDING_INSUFFICIENT_BALANCE: &str = + "Deposit amount exceeds user collateral balance"; +pub(crate) const ERR_USER_HOLDING_NOT_FUNGIBLE: &str = "User collateral holding must be fungible"; +pub(crate) const ERR_COLLATERAL_OVERFLOW: &str = "Deposit amount overflows position collateral"; + +fn account_is_initialized(account: &Account) -> bool { + // Runtime account claims assign a non-default owner; default-owned accounts are still + // uninitialized for Stablecoin account validation even if other fields are non-default. + account.program_owner != DEFAULT_PROGRAM_ID +} /// Deposit `amount` collateral tokens from `user_holding` into `position`'s vault. /// /// Increases `Position.collateral_amount` by `amount` and emits a single chained -/// `Token::Transfer` from the user holding to the vault. No collateralization -/// check is required because debt is unchanged. +/// [`token_core::Instruction::Transfer`] from the user holding to the vault when `amount` is +/// nonzero. The token program is anchored to the collateral token definition, and the vault and +/// user holding must be owned by that same program. +/// Only the owner alignment state and updated position are returned as stablecoin post-states. +/// Token-account balance post-states are produced by the chained transfer in the token program. +/// No collateralization check is required because debt is unchanged. /// /// # Panics /// - `owner` or `user_holding` is not authorized. @@ -17,84 +63,110 @@ use token_core::TokenHolding; /// decode as a [`Position`], or sits at an address that does not match /// `compute_position_pda(stablecoin_program_id, owner, Position.collateral_definition_id)`. /// - `vault` is uninitialized, sits at an address that does not match -/// `compute_position_vault_pda(stablecoin_program_id, position_id)`, or holds a [`TokenHolding`] -/// whose `definition_id` does not match the position's collateral definition. -/// - `user_holding` is uninitialized, owned by a different Token Program than the vault, or holds a -/// [`TokenHolding`] whose `definition_id` does not match the position's collateral definition. +/// `compute_position_vault_pda(stablecoin_program_id, position_id)`, is not owned by the +/// collateral Token Program, holds a [`TokenHolding`] whose `definition_id` does not match the +/// position's collateral definition, or is not fungible. +/// - `user_holding` is uninitialized, owned by a different Token Program than the collateral +/// definition, or holds a [`TokenHolding`] whose `definition_id` does not match the position's +/// collateral definition, is not fungible, or has less than `amount` balance. +/// - `token_definition` is uninitialized, does not match `Position.collateral_definition_id`, is +/// owned by a different Token Program than the vault, does not hold a valid [`TokenDefinition`], +/// or is not fungible. /// - `Position.collateral_amount + amount` overflows. pub fn deposit_collateral( owner: AccountWithMetadata, position: AccountWithMetadata, vault: AccountWithMetadata, user_holding: AccountWithMetadata, + token_definition: AccountWithMetadata, stablecoin_program_id: ProgramId, amount: u128, ) -> (Vec, Vec) { - assert!(owner.is_authorized, "Owner authorization is missing"); - assert!( - user_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" - ); - assert_ne!( - vault.account, - Account::default(), - "Vault must be initialized" - ); - assert_ne!( - user_holding.account, - Account::default(), - "User collateral holding must be initialized" - ); + if !owner.is_authorized { + panic!("{ERR_OWNER_AUTHORIZATION_MISSING}"); + } + if !user_holding.is_authorized { + panic!("{ERR_USER_HOLDING_AUTHORIZATION_MISSING}"); + } + if !account_is_initialized(&position.account) { + panic!("{ERR_POSITION_UNINITIALIZED}"); + } + if position.account.program_owner != stablecoin_program_id { + panic!("{ERR_POSITION_WRONG_PROGRAM_OWNER}"); + } + if !account_is_initialized(&vault.account) { + panic!("{ERR_VAULT_UNINITIALIZED}"); + } + if !account_is_initialized(&user_holding.account) { + panic!("{ERR_USER_HOLDING_UNINITIALIZED}"); + } 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( + .unwrap_or_else(|error| panic!("{ERR_POSITION_INVALID_STATE}: {error:?}")); + let _ = verify_position_and_get_seed( &position, &owner, position_data.collateral_definition_id, stablecoin_program_id, ); - let _vault_seed = - verify_position_vault_and_get_seed(&vault, position.account_id, stablecoin_program_id); - assert_eq!( - position_data.collateral_vault_id, vault.account_id, - "Position collateral vault does not match provided vault" - ); + let _ = verify_position_vault_and_get_seed(&vault, position.account_id, stablecoin_program_id); + if position_data.collateral_vault_id != vault.account_id { + panic!("{ERR_POSITION_VAULT_MISMATCH}"); + } + + if !account_is_initialized(&token_definition.account) { + panic!("{ERR_TOKEN_DEFINITION_UNINITIALIZED}"); + } + if token_definition.account_id != position_data.collateral_definition_id { + panic!("{ERR_TOKEN_DEFINITION_MISMATCH}"); + } + match TokenDefinition::try_from(&token_definition.account.data) + .unwrap_or_else(|error| panic!("{ERR_TOKEN_DEFINITION_INVALID}: {error:?}")) + { + TokenDefinition::Fungible { .. } => {} + TokenDefinition::NonFungible { .. } => panic!("{ERR_TOKEN_DEFINITION_NOT_FUNGIBLE}"), + } + + let token_program_id = token_definition.account.program_owner; + if vault.account.program_owner != token_program_id { + panic!("{ERR_TOKEN_PROGRAM_MISMATCH}"); + } let vault_holding = TokenHolding::try_from(&vault.account.data) - .expect("Vault account must hold a valid TokenHolding"); - assert_eq!( - vault_holding.definition_id(), - position_data.collateral_definition_id, - "Vault token holding is not for the position's collateral definition" - ); + .unwrap_or_else(|error| panic!("{ERR_VAULT_INVALID_HOLDING}: {error:?}")); + if vault_holding.definition_id() != position_data.collateral_definition_id { + panic!("{ERR_VAULT_WRONG_DEFINITION}"); + } + match vault_holding { + TokenHolding::Fungible { .. } => {} + TokenHolding::NftMaster { .. } | TokenHolding::NftPrintedCopy { .. } => { + panic!("{ERR_VAULT_NOT_FUNGIBLE}"); + } + } - let token_program_id = vault.account.program_owner; - assert_eq!( - user_holding.account.program_owner, token_program_id, - "User collateral holding must be owned by same Token Program as the vault" - ); + if user_holding.account.program_owner != token_program_id { + panic!("{ERR_TOKEN_PROGRAM_MISMATCH}"); + } let user_holding_data = TokenHolding::try_from(&user_holding.account.data) - .expect("User collateral holding must hold a valid TokenHolding"); - assert_eq!( - user_holding_data.definition_id(), - position_data.collateral_definition_id, - "User collateral holding does not match the position's collateral definition" - ); + .unwrap_or_else(|error| panic!("{ERR_USER_HOLDING_INVALID}: {error:?}")); + if user_holding_data.definition_id() != position_data.collateral_definition_id { + panic!("{ERR_USER_HOLDING_WRONG_DEFINITION}"); + } + match user_holding_data { + TokenHolding::Fungible { balance, .. } => { + if balance < amount { + panic!("{ERR_USER_HOLDING_INSUFFICIENT_BALANCE}"); + } + } + TokenHolding::NftMaster { .. } | TokenHolding::NftPrintedCopy { .. } => { + panic!("{ERR_USER_HOLDING_NOT_FUNGIBLE}"); + } + } let new_collateral = position_data .collateral_amount .checked_add(amount) - .expect("Deposit amount overflows position collateral"); + .unwrap_or_else(|| panic!("{ERR_COLLATERAL_OVERFLOW}")); let updated_position = Position { collateral_vault_id: position_data.collateral_vault_id, @@ -108,10 +180,12 @@ pub fn deposit_collateral( let post_states = vec![ AccountPostState::new(owner.account), AccountPostState::new(position_post), - AccountPostState::new(vault.account.clone()), - AccountPostState::new(user_holding.account.clone()), ]; + if amount == 0 { + return (post_states, vec![]); + } + let transfer_call = ChainedCall::new( token_program_id, vec![user_holding, vault], diff --git a/programs/stablecoin/src/tests.rs b/programs/stablecoin/src/tests.rs index 6053b50..60544aa 100644 --- a/programs/stablecoin/src/tests.rs +++ b/programs/stablecoin/src/tests.rs @@ -7,11 +7,12 @@ use nssa_core::{ account::{Account, AccountId, AccountWithMetadata, Data, Nonce}, - program::{ChainedCall, Claim, ProgramId}, + program::{AccountPostState, ChainedCall, Claim, ProgramId}, }; use stablecoin_core::{ compute_position_pda, compute_position_pda_seed, compute_position_vault_pda, - compute_position_vault_pda_seed, Position, + compute_position_vault_pda_seed, Position, ERR_POSITION_ACCOUNT_ID_MISMATCH, + ERR_POSITION_VAULT_ACCOUNT_ID_MISMATCH, }; use token_core::{TokenDefinition, TokenHolding}; @@ -174,6 +175,116 @@ fn user_stablecoin_holding_account(balance: u128) -> AccountWithMetadata { account } +struct DepositCollateralFixture { + owner: AccountWithMetadata, + position: AccountWithMetadata, + vault: AccountWithMetadata, + user_holding: AccountWithMetadata, + token_definition: AccountWithMetadata, + stablecoin_program_id: ProgramId, + amount: u128, +} + +impl DepositCollateralFixture { + fn run(self) -> (Vec, Vec) { + crate::deposit_collateral::deposit_collateral( + self.owner, + self.position, + self.vault, + self.user_holding, + self.token_definition, + self.stablecoin_program_id, + self.amount, + ) + } +} + +fn deposit_fixture() -> DepositCollateralFixture { + DepositCollateralFixture { + owner: owner_account(), + position: init_position_account(500, 0), + vault: init_vault_account(), + user_holding: user_holding_account(1_000), + token_definition: collateral_definition_account(), + stablecoin_program_id: STABLECOIN_PROGRAM_ID, + amount: 100, + } +} + +fn assert_panics_with_message(action: F, expected: &str) +where + F: FnOnce(), +{ + let panic = + std::panic::catch_unwind(std::panic::AssertUnwindSafe(action)).expect_err("expected panic"); + let message = if let Some(message) = panic.downcast_ref::() { + message.as_str() + } else if let Some(message) = panic.downcast_ref::<&str>() { + message + } else { + panic!("panic payload must be a string"); + }; + assert!( + message.contains(expected), + "panic message `{message}` did not contain `{expected}`" + ); +} + +fn assert_deposit_collateral_panics(fixture: DepositCollateralFixture, expected: &str) { + assert_panics_with_message( + || { + fixture.run(); + }, + expected, + ); +} + +fn post_state_matching<'a>( + post_states: &'a [AccountPostState], + post_state_account_ids: &[AccountId], + expected_account_id: AccountId, + label: &str, + mut predicate: impl FnMut(&AccountPostState) -> bool, +) -> &'a AccountPostState { + assert_eq!( + post_states.len(), + post_state_account_ids.len(), + "post-state account id list must match post-state length" + ); + let mut matched_posts = + post_states + .iter() + .zip(post_state_account_ids) + .filter_map(|(post_state, account_id)| { + (*account_id == expected_account_id && predicate(post_state)).then_some(post_state) + }); + let post_state = matched_posts.next().expect(label); + assert!( + matched_posts.next().is_none(), + "expected exactly one {label} post-state" + ); + post_state +} + +fn position_post_state<'a>( + post_states: &'a [AccountPostState], + post_state_account_ids: &[AccountId], + expected_account_id: AccountId, + expected_position: &Position, +) -> &'a AccountPostState { + post_state_matching( + post_states, + post_state_account_ids, + expected_account_id, + "position post-state", + |post_state| { + post_state.account().program_owner == STABLECOIN_PROGRAM_ID + && Position::try_from(&post_state.account().data) + .is_ok_and(|position| &position == expected_position) + }, + ) +} + #[test] fn open_position_claims_pda_and_emits_chained_calls() { let collateral_amount: u128 = 500; @@ -527,42 +638,47 @@ fn deposit_collateral_updates_position_and_emits_transfer() { let initial_debt: u128 = 300; let amount: u128 = 200; let holding_balance: u128 = 1_000; + let position_account = init_position_account(initial_collateral, initial_debt); + let vault = init_vault_account(); + let user_holding = user_holding_account(holding_balance); let (post_states, chained_calls) = crate::deposit_collateral::deposit_collateral( owner_account(), - init_position_account(initial_collateral, initial_debt), - init_vault_account(), - user_holding_account(holding_balance), + position_account.clone(), + vault.clone(), + user_holding.clone(), + collateral_definition_account(), STABLECOIN_PROGRAM_ID, amount, ); - assert_eq!(post_states.len(), 4); + assert_eq!(post_states.len(), 2); + assert!(post_states + .iter() + .all(|post_state| post_state.account().program_owner != TOKEN_PROGRAM_ID)); - let position_post = &post_states[1]; + let expected_position = Position { + collateral_vault_id: vault_id(), + collateral_definition_id: collateral_definition_id(), + collateral_amount: initial_collateral + amount, + debt_amount: initial_debt, + }; + let post_state_account_ids = [owner_id(), position_account.account_id]; + let position_post = position_post_state( + &post_states, + &post_state_account_ids, + position_account.account_id, + &expected_position, + ); assert_eq!(position_post.required_claim(), None); let position = Position::try_from(&position_post.account().data).expect("valid Position"); - assert_eq!( - position, - Position { - collateral_vault_id: vault_id(), - collateral_definition_id: collateral_definition_id(), - collateral_amount: initial_collateral + amount, - debt_amount: initial_debt, - } - ); + assert_eq!(position, expected_position); assert_eq!(position_post.account().program_owner, STABLECOIN_PROGRAM_ID); - assert_eq!(post_states[2].account(), &init_vault_account().account); - assert_eq!( - post_states[3].account(), - &user_holding_account(holding_balance).account - ); - assert_eq!(chained_calls.len(), 1); let expected_transfer = ChainedCall::new( TOKEN_PROGRAM_ID, - vec![user_holding_account(holding_balance), init_vault_account()], + vec![user_holding, vault], &token_core::Instruction::Transfer { amount_to_transfer: amount, }, @@ -573,201 +689,377 @@ fn deposit_collateral_updates_position_and_emits_transfer() { #[test] fn deposit_collateral_allows_zero_amount() { let initial: u128 = 500; + let position_account = init_position_account(initial, 0); let (post_states, chained_calls) = crate::deposit_collateral::deposit_collateral( owner_account(), - init_position_account(initial, 0), + position_account.clone(), init_vault_account(), user_holding_account(1_000), + collateral_definition_account(), STABLECOIN_PROGRAM_ID, 0, ); - let position = Position::try_from(&post_states[1].account().data).expect("valid Position"); + let expected_position = Position { + collateral_vault_id: vault_id(), + collateral_definition_id: collateral_definition_id(), + collateral_amount: initial, + debt_amount: 0, + }; + assert_eq!(post_states.len(), 2); + assert!(post_states + .iter() + .all(|post_state| post_state.account().program_owner != TOKEN_PROGRAM_ID)); + let post_state_account_ids = [owner_id(), position_account.account_id]; + let position_post = position_post_state( + &post_states, + &post_state_account_ids, + position_account.account_id, + &expected_position, + ); + let position = Position::try_from(&position_post.account().data).expect("valid Position"); assert_eq!(position.collateral_amount, initial); + assert!(chained_calls.is_empty()); +} - let expected_transfer = ChainedCall::new( - TOKEN_PROGRAM_ID, - vec![user_holding_account(1_000), init_vault_account()], - &token_core::Instruction::Transfer { - amount_to_transfer: 0, - }, +#[test] +fn deposit_collateral_zero_amount_validates_token_definition() { + let mut fixture = deposit_fixture(); + fixture.amount = 0; + fixture.token_definition.account.data = Data::try_from(vec![0xFC]).expect("test data fits"); + + assert_deposit_collateral_panics( + fixture, + crate::deposit_collateral::ERR_TOKEN_DEFINITION_INVALID, ); - assert_eq!(chained_calls, vec![expected_transfer]); } #[test] -#[should_panic(expected = "Owner authorization is missing")] +fn deposit_collateral_zero_amount_validates_vault_owner() { + let mut fixture = deposit_fixture(); + fixture.amount = 0; + fixture.vault.account.program_owner = [9u32; 8]; + + assert_deposit_collateral_panics( + fixture, + crate::deposit_collateral::ERR_TOKEN_PROGRAM_MISMATCH, + ); +} + +#[test] +fn deposit_collateral_zero_amount_validates_user_holding_data() { + let mut fixture = deposit_fixture(); + fixture.amount = 0; + fixture.user_holding.account.data = Data::try_from(vec![0xFD]).expect("test data fits"); + + assert_deposit_collateral_panics(fixture, crate::deposit_collateral::ERR_USER_HOLDING_INVALID); +} + +#[test] fn deposit_collateral_requires_owner_authorization() { - let mut owner = owner_account(); - owner.is_authorized = false; - crate::deposit_collateral::deposit_collateral( - owner, - init_position_account(500, 0), - init_vault_account(), - user_holding_account(1_000), - STABLECOIN_PROGRAM_ID, - 100, + let mut fixture = deposit_fixture(); + fixture.owner.is_authorized = false; + + assert_deposit_collateral_panics( + fixture, + crate::deposit_collateral::ERR_OWNER_AUTHORIZATION_MISSING, ); } #[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; - crate::deposit_collateral::deposit_collateral( - owner_account(), - init_position_account(500, 0), - init_vault_account(), - holding, - STABLECOIN_PROGRAM_ID, - 100, + let mut fixture = deposit_fixture(); + fixture.user_holding.is_authorized = false; + + assert_deposit_collateral_panics( + fixture, + crate::deposit_collateral::ERR_USER_HOLDING_AUTHORIZATION_MISSING, ); } #[test] -#[should_panic(expected = "Position account must be initialized")] fn deposit_collateral_rejects_uninitialized_position() { - crate::deposit_collateral::deposit_collateral( - owner_account(), - uninit_position_account(), - init_vault_account(), - user_holding_account(1_000), - STABLECOIN_PROGRAM_ID, - 100, + let mut fixture = deposit_fixture(); + fixture.position = uninit_position_account(); + + assert_deposit_collateral_panics( + fixture, + crate::deposit_collateral::ERR_POSITION_UNINITIALIZED, + ); +} + +#[test] +fn deposit_collateral_rejects_default_owned_position_as_uninitialized() { + let mut fixture = deposit_fixture(); + fixture.position.account.program_owner = ProgramId::default(); + + assert_deposit_collateral_panics( + fixture, + crate::deposit_collateral::ERR_POSITION_UNINITIALIZED, ); } #[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]; - crate::deposit_collateral::deposit_collateral( - owner_account(), - position, - init_vault_account(), - user_holding_account(1_000), - STABLECOIN_PROGRAM_ID, - 100, + let mut fixture = deposit_fixture(); + fixture.position.account.program_owner = [9u32; 8]; + + assert_deposit_collateral_panics( + fixture, + crate::deposit_collateral::ERR_POSITION_WRONG_PROGRAM_OWNER, ); } #[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([0xFFu8; 32]); - crate::deposit_collateral::deposit_collateral( - owner_account(), - position, - init_vault_account(), - user_holding_account(1_000), - STABLECOIN_PROGRAM_ID, - 100, +fn deposit_collateral_rejects_invalid_position_data() { + let mut fixture = deposit_fixture(); + fixture.position.account.data = Data::try_from(vec![0xFB]).expect("test data fits"); + + assert_deposit_collateral_panics( + fixture, + crate::deposit_collateral::ERR_POSITION_INVALID_STATE, ); } #[test] -#[should_panic(expected = "Position vault account ID does not match expected derivation")] +fn deposit_collateral_rejects_wrong_position_address() { + let mut fixture = deposit_fixture(); + fixture.position.account_id = AccountId::new([0xFFu8; 32]); + + assert_deposit_collateral_panics(fixture, ERR_POSITION_ACCOUNT_ID_MISMATCH); +} + +#[test] fn deposit_collateral_rejects_wrong_vault_address() { - let mut vault = init_vault_account(); - vault.account_id = AccountId::new([0xEEu8; 32]); - crate::deposit_collateral::deposit_collateral( - owner_account(), - init_position_account(500, 0), - vault, - user_holding_account(1_000), - STABLECOIN_PROGRAM_ID, - 100, + let mut fixture = deposit_fixture(); + fixture.vault.account_id = AccountId::new([0xEEu8; 32]); + + assert_deposit_collateral_panics(fixture, ERR_POSITION_VAULT_ACCOUNT_ID_MISMATCH); +} + +#[test] +fn deposit_collateral_rejects_position_vault_id_mismatch() { + let mut fixture = deposit_fixture(); + fixture.position.account.data = Data::from(&Position { + collateral_vault_id: AccountId::new([0x71u8; 32]), + collateral_definition_id: collateral_definition_id(), + collateral_amount: 500, + debt_amount: 0, + }); + + assert_deposit_collateral_panics( + fixture, + crate::deposit_collateral::ERR_POSITION_VAULT_MISMATCH, + ); +} + +#[test] +fn deposit_collateral_rejects_uninitialized_vault() { + let mut fixture = deposit_fixture(); + fixture.vault = uninit_vault_account(); + + assert_deposit_collateral_panics(fixture, crate::deposit_collateral::ERR_VAULT_UNINITIALIZED); +} + +#[test] +fn deposit_collateral_rejects_default_owned_vault_as_uninitialized() { + let mut fixture = deposit_fixture(); + fixture.vault.account.program_owner = ProgramId::default(); + + assert_deposit_collateral_panics(fixture, crate::deposit_collateral::ERR_VAULT_UNINITIALIZED); +} + +#[test] +fn deposit_collateral_rejects_invalid_vault_holding_data() { + let mut fixture = deposit_fixture(); + fixture.vault.account.data = Data::try_from(vec![0xFA]).expect("test data fits"); + + assert_deposit_collateral_panics( + fixture, + crate::deposit_collateral::ERR_VAULT_INVALID_HOLDING, ); } #[test] -#[should_panic(expected = "Vault token holding is not for the position's collateral definition")] fn deposit_collateral_rejects_vault_for_other_definition() { - let mut vault = init_vault_account(); - vault.account.data = Data::from(&TokenHolding::Fungible { + let mut fixture = deposit_fixture(); + fixture.vault.account.data = Data::from(&TokenHolding::Fungible { definition_id: AccountId::new([0x21u8; 32]), balance: 0, }); - crate::deposit_collateral::deposit_collateral( - owner_account(), - init_position_account(500, 0), - vault, - user_holding_account(1_000), - STABLECOIN_PROGRAM_ID, - 100, + + assert_deposit_collateral_panics( + fixture, + crate::deposit_collateral::ERR_VAULT_WRONG_DEFINITION, + ); +} + +#[test] +fn deposit_collateral_rejects_nonfungible_vault() { + let mut fixture = deposit_fixture(); + fixture.vault.account.data = Data::from(&TokenHolding::NftPrintedCopy { + definition_id: collateral_definition_id(), + owned: false, + }); + + assert_deposit_collateral_panics(fixture, crate::deposit_collateral::ERR_VAULT_NOT_FUNGIBLE); +} + +#[test] +fn deposit_collateral_rejects_vault_definition_owner_mismatch() { + let mut fixture = deposit_fixture(); + fixture.vault.account.program_owner = [9u32; 8]; + + assert_deposit_collateral_panics( + fixture, + crate::deposit_collateral::ERR_TOKEN_PROGRAM_MISMATCH, ); } #[test] -#[should_panic(expected = "User collateral holding must be initialized")] fn deposit_collateral_rejects_uninitialized_user_holding() { - let holding = AccountWithMetadata { + let mut fixture = deposit_fixture(); + fixture.user_holding = AccountWithMetadata { account: Account::default(), is_authorized: true, account_id: user_holding_id(), }; - crate::deposit_collateral::deposit_collateral( - owner_account(), - init_position_account(500, 0), - init_vault_account(), - holding, - STABLECOIN_PROGRAM_ID, - 100, + + assert_deposit_collateral_panics( + fixture, + crate::deposit_collateral::ERR_USER_HOLDING_UNINITIALIZED, + ); +} + +#[test] +fn deposit_collateral_rejects_default_owned_user_holding_as_uninitialized() { + let mut fixture = deposit_fixture(); + fixture.user_holding.account.program_owner = ProgramId::default(); + + assert_deposit_collateral_panics( + fixture, + crate::deposit_collateral::ERR_USER_HOLDING_UNINITIALIZED, ); } #[test] -#[should_panic( - expected = "User collateral holding must be owned by same Token Program as the vault" -)] fn deposit_collateral_rejects_holding_with_different_token_program() { - let mut holding = user_holding_account(1_000); - holding.account.program_owner = [9u32; 8]; - crate::deposit_collateral::deposit_collateral( - owner_account(), - init_position_account(500, 0), - init_vault_account(), - holding, - STABLECOIN_PROGRAM_ID, - 100, + let mut fixture = deposit_fixture(); + fixture.user_holding.account.program_owner = [9u32; 8]; + + assert_deposit_collateral_panics( + fixture, + crate::deposit_collateral::ERR_TOKEN_PROGRAM_MISMATCH, ); } #[test] -#[should_panic( - expected = "User collateral holding does not match the position's collateral definition" -)] fn deposit_collateral_rejects_holding_for_other_definition() { - let mut holding = user_holding_account(1_000); - holding.account.data = Data::from(&TokenHolding::Fungible { + let mut fixture = deposit_fixture(); + fixture.user_holding.account.data = Data::from(&TokenHolding::Fungible { definition_id: AccountId::new([0x21u8; 32]), balance: 1_000, }); - crate::deposit_collateral::deposit_collateral( - owner_account(), - init_position_account(500, 0), - init_vault_account(), - holding, - STABLECOIN_PROGRAM_ID, - 100, + + assert_deposit_collateral_panics( + fixture, + crate::deposit_collateral::ERR_USER_HOLDING_WRONG_DEFINITION, ); } #[test] -#[should_panic(expected = "Deposit amount overflows position collateral")] -fn deposit_collateral_rejects_collateral_overflow() { - crate::deposit_collateral::deposit_collateral( - owner_account(), - init_position_account(u128::MAX, 0), - init_vault_account(), - user_holding_account(1_000), - STABLECOIN_PROGRAM_ID, - 1, +fn deposit_collateral_rejects_insufficient_user_balance() { + let mut fixture = deposit_fixture(); + fixture.user_holding = user_holding_account(99); + + assert_deposit_collateral_panics( + fixture, + crate::deposit_collateral::ERR_USER_HOLDING_INSUFFICIENT_BALANCE, ); } +#[test] +fn deposit_collateral_rejects_nonfungible_user_holding() { + let mut fixture = deposit_fixture(); + fixture.amount = 1; + fixture.user_holding.account.data = Data::from(&TokenHolding::NftPrintedCopy { + definition_id: collateral_definition_id(), + owned: true, + }); + + assert_deposit_collateral_panics( + fixture, + crate::deposit_collateral::ERR_USER_HOLDING_NOT_FUNGIBLE, + ); +} + +#[test] +fn deposit_collateral_rejects_other_token_definition() { + let mut fixture = deposit_fixture(); + fixture.token_definition.account_id = AccountId::new([0x21u8; 32]); + + assert_deposit_collateral_panics( + fixture, + crate::deposit_collateral::ERR_TOKEN_DEFINITION_MISMATCH, + ); +} + +#[test] +fn deposit_collateral_rejects_token_definition_with_wrong_token_program() { + let mut fixture = deposit_fixture(); + fixture.token_definition.account.program_owner = [9u32; 8]; + + assert_deposit_collateral_panics( + fixture, + crate::deposit_collateral::ERR_TOKEN_PROGRAM_MISMATCH, + ); +} + +#[test] +fn deposit_collateral_rejects_uninitialized_token_definition() { + let mut fixture = deposit_fixture(); + fixture.token_definition.account = Account::default(); + + assert_deposit_collateral_panics( + fixture, + crate::deposit_collateral::ERR_TOKEN_DEFINITION_UNINITIALIZED, + ); +} + +#[test] +fn deposit_collateral_rejects_invalid_token_definition_data() { + let mut fixture = deposit_fixture(); + fixture.token_definition.account.data = Data::try_from(vec![0xFF]).expect("test data fits"); + + assert_deposit_collateral_panics( + fixture, + crate::deposit_collateral::ERR_TOKEN_DEFINITION_INVALID, + ); +} + +#[test] +fn deposit_collateral_rejects_nonfungible_token_definition() { + let mut fixture = deposit_fixture(); + fixture.token_definition.account.data = Data::from(&TokenDefinition::NonFungible { + name: "NFT".to_owned(), + printable_supply: 1, + metadata_id: AccountId::new([0x70u8; 32]), + }); + + assert_deposit_collateral_panics( + fixture, + crate::deposit_collateral::ERR_TOKEN_DEFINITION_NOT_FUNGIBLE, + ); +} + +#[test] +fn deposit_collateral_rejects_collateral_overflow() { + let mut fixture = deposit_fixture(); + fixture.position = init_position_account(u128::MAX, 0); + fixture.amount = 1; + + assert_deposit_collateral_panics(fixture, crate::deposit_collateral::ERR_COLLATERAL_OVERFLOW); +} + #[test] fn withdraw_collateral_allows_full_drain() { let amount: u128 = 500; From 5b4a3fc0e73e3621712c578f6416a4c559985ffd Mon Sep 17 00:00:00 2001 From: Ricardo Guilherme Schmidt <3esmit@gmail.com> Date: Mon, 1 Jun 2026 12:18:35 -0300 Subject: [PATCH 3/3] test(stablecoin): cover deposit collateral edge cases --- programs/stablecoin/src/deposit_collateral.rs | 2 + programs/stablecoin/src/tests.rs | 70 +++++++++++++++++++ 2 files changed, 72 insertions(+) diff --git a/programs/stablecoin/src/deposit_collateral.rs b/programs/stablecoin/src/deposit_collateral.rs index f4983ba..973d1c8 100644 --- a/programs/stablecoin/src/deposit_collateral.rs +++ b/programs/stablecoin/src/deposit_collateral.rs @@ -177,6 +177,8 @@ pub fn deposit_collateral( let mut position_post = position.account.clone(); position_post.data = Data::from(&updated_position); + // Framework zips declared inputs with returned post-states and truncates to the shorter + // length, so these must stay positionally aligned with the first two inputs: owner, position. let post_states = vec![ AccountPostState::new(owner.account), AccountPostState::new(position_post), diff --git a/programs/stablecoin/src/tests.rs b/programs/stablecoin/src/tests.rs index 60544aa..7109261 100644 --- a/programs/stablecoin/src/tests.rs +++ b/programs/stablecoin/src/tests.rs @@ -686,6 +686,50 @@ fn deposit_collateral_updates_position_and_emits_transfer() { assert_eq!(chained_calls[0], expected_transfer); } +#[test] +fn deposit_collateral_allows_exact_user_balance() { + let initial_collateral: u128 = 500; + let amount: u128 = 100; + let position_account = init_position_account(initial_collateral, 0); + let vault = init_vault_account(); + let user_holding = user_holding_account(amount); + + let (post_states, chained_calls) = crate::deposit_collateral::deposit_collateral( + owner_account(), + position_account.clone(), + vault.clone(), + user_holding.clone(), + collateral_definition_account(), + STABLECOIN_PROGRAM_ID, + amount, + ); + + let expected_position = Position { + collateral_vault_id: vault_id(), + collateral_definition_id: collateral_definition_id(), + collateral_amount: initial_collateral + amount, + debt_amount: 0, + }; + let post_state_account_ids = [owner_id(), position_account.account_id]; + let position_post = position_post_state( + &post_states, + &post_state_account_ids, + position_account.account_id, + &expected_position, + ); + let position = Position::try_from(&position_post.account().data).expect("valid Position"); + assert_eq!(position, expected_position); + + let expected_transfer = ChainedCall::new( + TOKEN_PROGRAM_ID, + vec![user_holding, vault], + &token_core::Instruction::Transfer { + amount_to_transfer: amount, + }, + ); + assert_eq!(chained_calls, vec![expected_transfer]); +} + #[test] fn deposit_collateral_allows_zero_amount() { let initial: u128 = 500; @@ -904,6 +948,17 @@ fn deposit_collateral_rejects_nonfungible_vault() { assert_deposit_collateral_panics(fixture, crate::deposit_collateral::ERR_VAULT_NOT_FUNGIBLE); } +#[test] +fn deposit_collateral_rejects_master_nft_vault() { + let mut fixture = deposit_fixture(); + fixture.vault.account.data = Data::from(&TokenHolding::NftMaster { + definition_id: collateral_definition_id(), + print_balance: 0, + }); + + assert_deposit_collateral_panics(fixture, crate::deposit_collateral::ERR_VAULT_NOT_FUNGIBLE); +} + #[test] fn deposit_collateral_rejects_vault_definition_owner_mismatch() { let mut fixture = deposit_fixture(); @@ -992,6 +1047,21 @@ fn deposit_collateral_rejects_nonfungible_user_holding() { ); } +#[test] +fn deposit_collateral_rejects_master_nft_user_holding() { + let mut fixture = deposit_fixture(); + fixture.amount = 1; + fixture.user_holding.account.data = Data::from(&TokenHolding::NftMaster { + definition_id: collateral_definition_id(), + print_balance: 0, + }); + + assert_deposit_collateral_panics( + fixture, + crate::deposit_collateral::ERR_USER_HOLDING_NOT_FUNGIBLE, + ); +} + #[test] fn deposit_collateral_rejects_other_token_definition() { let mut fixture = deposit_fixture();