diff --git a/artifacts/stablecoin-idl.json b/artifacts/stablecoin-idl.json index 5c601e8..1fd73cc 100644 --- a/artifacts/stablecoin-idl.json +++ b/artifacts/stablecoin-idl.json @@ -43,6 +43,47 @@ } ] }, + { + "name": "deposit_collateral", + "accounts": [ + { + "name": "owner", + "writable": false, + "signer": true, + "init": false + }, + { + "name": "position", + "writable": true, + "signer": false, + "init": false + }, + { + "name": "vault", + "writable": true, + "signer": false, + "init": false + }, + { + "name": "user_holding", + "writable": true, + "signer": true, + "init": false + }, + { + "name": "token_definition", + "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..1f7a3b3 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,52 @@ 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(), + Ids::collateral_definition(), + ], + 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 +361,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..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 { @@ -30,6 +35,25 @@ 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 (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 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 { + /// 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): @@ -186,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 } @@ -206,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 f677ca3..4dc915f 100644 --- a/programs/stablecoin/methods/guest/src/bin/stablecoin.rs +++ b/programs/stablecoin/methods/guest/src/bin/stablecoin.rs @@ -42,6 +42,43 @@ 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, + #[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) = + stablecoin_program::deposit_collateral::deposit_collateral( + owner, + position, + vault, + user_holding, + token_definition, + 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..973d1c8 --- /dev/null +++ b/programs/stablecoin/src/deposit_collateral.rs @@ -0,0 +1,200 @@ +use nssa_core::{ + account::{Account, AccountWithMetadata, Data}, + 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::{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_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. +/// - `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)`, 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) { + 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) + .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 _ = 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) + .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}"); + } + } + + 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) + .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) + .unwrap_or_else(|| panic!("{ERR_COLLATERAL_OVERFLOW}")); + + 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); + + // 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), + ]; + + if amount == 0 { + return (post_states, vec![]); + } + + 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..7109261 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; @@ -521,6 +632,504 @@ 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 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(), + position_account.clone(), + vault.clone(), + user_holding.clone(), + collateral_definition_account(), + STABLECOIN_PROGRAM_ID, + amount, + ); + + assert_eq!(post_states.len(), 2); + assert!(post_states + .iter() + .all(|post_state| post_state.account().program_owner != TOKEN_PROGRAM_ID)); + + 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, expected_position); + assert_eq!(position_post.account().program_owner, STABLECOIN_PROGRAM_ID); + + assert_eq!(chained_calls.len(), 1); + let expected_transfer = ChainedCall::new( + TOKEN_PROGRAM_ID, + vec![user_holding, vault], + &token_core::Instruction::Transfer { + amount_to_transfer: amount, + }, + ); + 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; + let position_account = init_position_account(initial, 0); + let (post_states, chained_calls) = crate::deposit_collateral::deposit_collateral( + owner_account(), + position_account.clone(), + init_vault_account(), + user_holding_account(1_000), + collateral_definition_account(), + STABLECOIN_PROGRAM_ID, + 0, + ); + 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()); +} + +#[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] +fn deposit_collateral_requires_owner_authorization() { + let mut fixture = deposit_fixture(); + fixture.owner.is_authorized = false; + + assert_deposit_collateral_panics( + fixture, + crate::deposit_collateral::ERR_OWNER_AUTHORIZATION_MISSING, + ); +} + +#[test] +fn deposit_collateral_requires_user_holding_authorization() { + 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] +fn deposit_collateral_rejects_uninitialized_position() { + 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] +fn deposit_collateral_rejects_position_owned_by_other_program() { + 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] +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 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] +fn deposit_collateral_rejects_vault_for_other_definition() { + let mut fixture = deposit_fixture(); + fixture.vault.account.data = Data::from(&TokenHolding::Fungible { + definition_id: AccountId::new([0x21u8; 32]), + balance: 0, + }); + + 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_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(); + fixture.vault.account.program_owner = [9u32; 8]; + + assert_deposit_collateral_panics( + fixture, + crate::deposit_collateral::ERR_TOKEN_PROGRAM_MISMATCH, + ); +} + +#[test] +fn deposit_collateral_rejects_uninitialized_user_holding() { + let mut fixture = deposit_fixture(); + fixture.user_holding = AccountWithMetadata { + account: Account::default(), + is_authorized: true, + account_id: user_holding_id(), + }; + + 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] +fn deposit_collateral_rejects_holding_with_different_token_program() { + 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] +fn deposit_collateral_rejects_holding_for_other_definition() { + let mut fixture = deposit_fixture(); + fixture.user_holding.account.data = Data::from(&TokenHolding::Fungible { + definition_id: AccountId::new([0x21u8; 32]), + balance: 1_000, + }); + + assert_deposit_collateral_panics( + fixture, + crate::deposit_collateral::ERR_USER_HOLDING_WRONG_DEFINITION, + ); +} + +#[test] +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_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(); + 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;