From 056b7a510259bf8188bab2c652d12c8147a91221 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] 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 | 582 +++++++++++++----- 6 files changed, 609 insertions(+), 218 deletions(-) diff --git a/artifacts/stablecoin-idl.json b/artifacts/stablecoin-idl.json index 952cf9a..213bc70 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 587e24e..e44e67b 100644 --- a/programs/integration_tests/tests/stablecoin.rs +++ b/programs/integration_tests/tests/stablecoin.rs @@ -304,6 +304,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 05be5f6..fd9feeb 100644 --- a/programs/stablecoin/core/src/lib.rs +++ b/programs/stablecoin/core/src/lib.rs @@ -15,6 +15,11 @@ use spel_framework_macros::account_type; const POSITION_PDA_DOMAIN: &[u8] = b"POSITION"; const POSITION_VAULT_PDA_DOMAIN: &[u8] = b"POSITION_VAULT"; +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 { @@ -36,14 +41,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 { @@ -206,10 +214,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 } @@ -226,9 +236,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 c6507c7..35d1817 100644 --- a/programs/stablecoin/methods/guest/src/bin/stablecoin.rs +++ b/programs/stablecoin/methods/guest/src/bin/stablecoin.rs @@ -56,10 +56,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) = @@ -68,6 +73,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 d596883..43c97c5 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}; @@ -176,6 +177,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; @@ -530,42 +641,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, }, @@ -576,201 +692,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); - - 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]); + assert!(chained_calls.is_empty()); +} + +#[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, + ); +} + +#[test] +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] -#[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, + 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] +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 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, - ); + let mut fixture = deposit_fixture(); + fixture.position.account_id = AccountId::new([0xFFu8; 32]); + + assert_deposit_collateral_panics(fixture, ERR_POSITION_ACCOUNT_ID_MISMATCH); } #[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, + 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;