feat(stablecoin): add collateral deposits

This commit is contained in:
Ricardo Guilherme Schmidt 2026-05-27 10:05:49 -03:00
parent fe4c7a96da
commit f111c55b09
No known key found for this signature in database
GPG Key ID: 1396EA17DE132FFE
7 changed files with 513 additions and 4 deletions

View File

@ -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": [

View File

@ -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(),
);
}

View File

@ -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):

View File

@ -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.
///

View File

@ -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<AccountPostState>, Vec<ChainedCall>) {
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])
}

View File

@ -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;

View File

@ -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;