From f111c55b0938d02fa56aff82ac6f826bca871a15 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] 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 2d57776..952cf9a 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 d72ad67..587e24e 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 } @@ -249,7 +253,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. @@ -289,6 +293,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(), @@ -313,17 +362,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 d030564..05be5f6 100644 --- a/programs/stablecoin/core/src/lib.rs +++ b/programs/stablecoin/core/src/lib.rs @@ -34,6 +34,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 3cd28b1..c6507c7 100644 --- a/programs/stablecoin/methods/guest/src/bin/stablecoin.rs +++ b/programs/stablecoin/methods/guest/src/bin/stablecoin.rs @@ -46,6 +46,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 57a64ed..d596883 100644 --- a/programs/stablecoin/src/tests.rs +++ b/programs/stablecoin/src/tests.rs @@ -524,6 +524,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;