Merge a71fdde3f0117f90e37c96e9cb387a129286f0e6 into fe4c7a96da393808946d0ffdb9ef44a5da9d8ef0

This commit is contained in:
Ricardo Guilherme Schmidt 2026-07-02 20:30:35 +00:00 committed by GitHub
commit ea7c8250dd
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
7 changed files with 986 additions and 14 deletions

View File

@ -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", "name": "withdraw_collateral",
"accounts": [ "accounts": [

View File

@ -82,6 +82,10 @@ impl Balances {
200_000 200_000
} }
fn collateral_extra_deposit() -> u128 {
100_000
}
fn stablecoin_supply_init() -> u128 { fn stablecoin_supply_init() -> u128 {
1_000 1_000
} }
@ -249,7 +253,7 @@ fn assert_fungible_balance(state: &V03State, account_id: AccountId, expected_bal
} }
#[test] #[test]
fn stablecoin_open_position_then_withdraw_collateral() { fn stablecoin_open_position_deposit_then_withdraw_collateral() {
let mut state = state_for_stablecoin_tests(); let mut state = state_for_stablecoin_tests();
// Open the position: deposit collateral from the user's holding into a fresh vault. // Open the position: deposit collateral from the user's holding into a fresh vault.
@ -289,6 +293,52 @@ fn stablecoin_open_position_then_withdraw_collateral() {
Balances::user_holding_init() - Balances::collateral_deposit(), 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. // Withdraw part of the collateral back to the same user holding.
let withdraw = stablecoin_core::Instruction::WithdrawCollateral { let withdraw = stablecoin_core::Instruction::WithdrawCollateral {
amount: Balances::collateral_withdraw(), amount: Balances::collateral_withdraw(),
@ -313,17 +363,21 @@ fn stablecoin_open_position_then_withdraw_collateral() {
assert_position( assert_position(
&state, &state,
Balances::collateral_deposit() - Balances::collateral_withdraw(), Balances::collateral_deposit() + Balances::collateral_extra_deposit()
- Balances::collateral_withdraw(),
); );
assert_fungible_balance( assert_fungible_balance(
&state, &state,
Ids::vault(), Ids::vault(),
Balances::collateral_deposit() - Balances::collateral_withdraw(), Balances::collateral_deposit() + Balances::collateral_extra_deposit()
- Balances::collateral_withdraw(),
); );
assert_fungible_balance( assert_fungible_balance(
&state, &state,
Ids::user_holding(), Ids::user_holding(),
Balances::user_holding_init() - Balances::collateral_deposit() Balances::user_holding_init()
- Balances::collateral_deposit()
- Balances::collateral_extra_deposit()
+ Balances::collateral_withdraw(), + Balances::collateral_withdraw(),
); );
} }

View File

@ -15,6 +15,11 @@ use spel_framework_macros::account_type;
const POSITION_PDA_DOMAIN: &[u8] = b"POSITION"; const POSITION_PDA_DOMAIN: &[u8] = b"POSITION";
const POSITION_VAULT_PDA_DOMAIN: &[u8] = b"POSITION_VAULT"; 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. /// Stablecoin Program Instruction.
#[derive(Debug, Serialize, Deserialize)] #[derive(Debug, Serialize, Deserialize)]
pub enum Instruction { pub enum Instruction {
@ -34,6 +39,25 @@ pub enum Instruction {
/// Amount of collateral tokens to deposit into the position vault. /// Amount of collateral tokens to deposit into the position vault.
collateral_amount: u128, 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. /// Withdraw `amount` collateral tokens from a position back to a user-controlled holding.
/// ///
/// Required accounts (4): /// Required accounts (4):
@ -190,10 +214,12 @@ pub fn verify_position_and_get_seed(
) -> PdaSeed { ) -> PdaSeed {
let seed = compute_position_pda_seed(owner.account_id, collateral_definition_id); let seed = compute_position_pda_seed(owner.account_id, collateral_definition_id);
let expected_id = AccountId::for_public_pda(&stablecoin_program_id, &seed); let expected_id = AccountId::for_public_pda(&stablecoin_program_id, &seed);
assert_eq!( if position.account_id != expected_id {
position.account_id, expected_id, panic!(
"Position account ID does not match expected derivation" "{ERR_POSITION_ACCOUNT_ID_MISMATCH}: provided {}, expected {}",
position.account_id, expected_id
); );
}
seed seed
} }
@ -210,9 +236,11 @@ pub fn verify_position_vault_and_get_seed(
) -> PdaSeed { ) -> PdaSeed {
let seed = compute_position_vault_pda_seed(position_id); let seed = compute_position_vault_pda_seed(position_id);
let expected_id = AccountId::for_public_pda(&stablecoin_program_id, &seed); let expected_id = AccountId::for_public_pda(&stablecoin_program_id, &seed);
assert_eq!( if vault.account_id != expected_id {
vault.account_id, expected_id, panic!(
"Position vault account ID does not match expected derivation" "{ERR_POSITION_VAULT_ACCOUNT_ID_MISMATCH}: provided {}, expected {}",
vault.account_id, expected_id
); );
}
seed seed
} }

View File

@ -46,6 +46,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 /// Withdraw `amount` collateral tokens from an existing position back to a
/// user-controlled holding. /// user-controlled holding.
/// ///

View File

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

View File

@ -2,6 +2,9 @@
pub use stablecoin_core as core; 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. /// Open a new collateral-only position for a calling owner.
pub mod open_position; pub mod open_position;

View File

@ -7,11 +7,12 @@
use nssa_core::{ use nssa_core::{
account::{Account, AccountId, AccountWithMetadata, Data, Nonce}, account::{Account, AccountId, AccountWithMetadata, Data, Nonce},
program::{ChainedCall, Claim, ProgramId}, program::{AccountPostState, ChainedCall, Claim, ProgramId},
}; };
use stablecoin_core::{ use stablecoin_core::{
compute_position_pda, compute_position_pda_seed, compute_position_vault_pda, 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}; use token_core::{TokenDefinition, TokenHolding};
@ -176,6 +177,116 @@ fn user_stablecoin_holding_account(balance: u128) -> AccountWithMetadata {
account 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<AccountPostState>, Vec<ChainedCall>) {
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<F>(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::<String>() {
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] #[test]
fn open_position_claims_pda_and_emits_chained_calls() { fn open_position_claims_pda_and_emits_chained_calls() {
let collateral_amount: u128 = 500; let collateral_amount: u128 = 500;
@ -524,6 +635,504 @@ fn withdraw_collateral_updates_position_and_emits_transfer() {
assert_eq!(chained_calls[0], expected_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] #[test]
fn withdraw_collateral_allows_full_drain() { fn withdraw_collateral_allows_full_drain() {
let amount: u128 = 500; let amount: u128 = 500;