mirror of
https://github.com/logos-blockchain/lez-programs.git
synced 2026-08-26 22:51:14 +00:00
refactor: move programs into programs and UIs into apps
This refactors the repository structure as it has grown over time.
This commit is contained in:
@@ -0,0 +1,15 @@
|
||||
//! The Stablecoin Program implementation.
|
||||
|
||||
pub use stablecoin_core as core;
|
||||
|
||||
/// Open a new collateral-only position for a calling owner.
|
||||
pub mod open_position;
|
||||
|
||||
/// Repay outstanding stablecoin debt against an existing position.
|
||||
pub mod repay_debt;
|
||||
|
||||
/// Withdraw collateral from an existing position back to a user-controlled holding.
|
||||
pub mod withdraw_collateral;
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
@@ -0,0 +1,125 @@
|
||||
use nssa_core::{
|
||||
account::{Account, AccountWithMetadata, Data},
|
||||
program::{AccountPostState, ChainedCall, Claim, ProgramId},
|
||||
};
|
||||
use stablecoin_core::{verify_position_and_get_seed, verify_position_vault_and_get_seed, Position};
|
||||
use token_core::TokenHolding;
|
||||
|
||||
/// Open a new collateral-only position for `owner`.
|
||||
///
|
||||
/// This claims the [`Position`] PDA, issues two chained token-program calls under the
|
||||
/// stablecoin's PDA authority, and stores `collateral_amount` with `debt_amount = 0`:
|
||||
/// 1. `InitializeAccount` materializes the vault token holding for the collateral.
|
||||
/// 2. `Transfer` moves `collateral_amount` collateral tokens from the user's holding into the
|
||||
/// freshly initialized vault.
|
||||
///
|
||||
/// `debt_amount` is deferred to a future `generate_debt` instruction and is intentionally
|
||||
/// not parameterized here.
|
||||
///
|
||||
/// # Panics
|
||||
/// - `owner` or `user_holding` is not authorized.
|
||||
/// - `position` or `vault` is already initialized.
|
||||
/// - `position.account_id` / `vault.account_id` do not match their PDA derivations.
|
||||
/// - `user_holding` cannot be decoded as a [`TokenHolding`].
|
||||
/// - `user_holding`'s definition does not match `token_definition`.
|
||||
/// - `token_definition.program_owner` does not match `user_holding.program_owner`.
|
||||
pub fn open_position(
|
||||
owner: AccountWithMetadata,
|
||||
position: AccountWithMetadata,
|
||||
vault: AccountWithMetadata,
|
||||
user_holding: AccountWithMetadata,
|
||||
token_definition: AccountWithMetadata,
|
||||
stablecoin_program_id: ProgramId,
|
||||
collateral_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_eq!(
|
||||
position.account,
|
||||
Account::default(),
|
||||
"Position account must be uninitialized"
|
||||
);
|
||||
assert_eq!(
|
||||
vault.account,
|
||||
Account::default(),
|
||||
"Position vault account must be uninitialized"
|
||||
);
|
||||
|
||||
let user_holding_definition_id = TokenHolding::try_from(&user_holding.account.data)
|
||||
.expect("User holding must be a valid Token Holding")
|
||||
.definition_id();
|
||||
assert_eq!(
|
||||
user_holding_definition_id, token_definition.account_id,
|
||||
"User collateral holding does not match the provided token definition"
|
||||
);
|
||||
let token_program_id = user_holding.account.program_owner;
|
||||
assert_eq!(
|
||||
token_definition.account.program_owner, token_program_id,
|
||||
"Collateral token definition is not owned by the user holding's Token Program"
|
||||
);
|
||||
|
||||
let position_seed = verify_position_and_get_seed(
|
||||
&position,
|
||||
&owner,
|
||||
token_definition.account_id,
|
||||
stablecoin_program_id,
|
||||
);
|
||||
let vault_seed =
|
||||
verify_position_vault_and_get_seed(&vault, position.account_id, stablecoin_program_id);
|
||||
|
||||
let mut position_post = position.account;
|
||||
position_post.data = Data::from(&Position {
|
||||
collateral_vault_id: vault.account_id,
|
||||
collateral_definition_id: token_definition.account_id,
|
||||
collateral_amount,
|
||||
debt_amount: 0,
|
||||
});
|
||||
|
||||
let post_states = vec![
|
||||
AccountPostState::new(owner.account),
|
||||
AccountPostState::new_claimed(position_post, Claim::Pda(position_seed)),
|
||||
AccountPostState::new(vault.account.clone()),
|
||||
AccountPostState::new(user_holding.account.clone()),
|
||||
AccountPostState::new(token_definition.account.clone()),
|
||||
];
|
||||
|
||||
// Chained Token::InitializeAccount owns the vault as a Token holding. The Stablecoin
|
||||
// program only authorizes that claim by passing the vault PDA seed to the chained call.
|
||||
let mut vault_authorized = vault.clone();
|
||||
vault_authorized.is_authorized = true;
|
||||
let initialize_call = ChainedCall::new(
|
||||
token_program_id,
|
||||
vec![token_definition.clone(), vault_authorized],
|
||||
&token_core::Instruction::InitializeAccount,
|
||||
)
|
||||
.with_pda_seeds(vec![vault_seed]);
|
||||
|
||||
// After InitializeAccount the vault is a zero-balance Fungible holding for the
|
||||
// collateral definition. Token::Transfer only requires the sender to be authorized; the
|
||||
// recipient (vault) is already initialized, so no second PDA claim is needed here.
|
||||
let post_init_vault = AccountWithMetadata {
|
||||
account: Account {
|
||||
program_owner: token_program_id,
|
||||
balance: 0,
|
||||
data: Data::from(&TokenHolding::Fungible {
|
||||
definition_id: token_definition.account_id,
|
||||
balance: 0,
|
||||
}),
|
||||
nonce: vault.account.nonce,
|
||||
},
|
||||
is_authorized: false,
|
||||
account_id: vault.account_id,
|
||||
};
|
||||
let transfer_call = ChainedCall::new(
|
||||
token_program_id,
|
||||
vec![user_holding, post_init_vault],
|
||||
&token_core::Instruction::Transfer {
|
||||
amount_to_transfer: collateral_amount,
|
||||
},
|
||||
);
|
||||
|
||||
(post_states, vec![initialize_call, transfer_call])
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
use nssa_core::{
|
||||
account::{Account, AccountWithMetadata, Data},
|
||||
program::{AccountPostState, ChainedCall, ProgramId},
|
||||
};
|
||||
use stablecoin_core::{verify_position_and_get_seed, Position};
|
||||
use token_core::TokenHolding;
|
||||
|
||||
/// Repay `amount` of outstanding stablecoin debt against an existing position.
|
||||
///
|
||||
/// Burns `amount` stablecoins from `user_stablecoin_holding` via a chained
|
||||
/// `Token::Burn` and decreases `Position.debt_amount` by the same amount. The
|
||||
/// position post-state uses plain [`AccountPostState::new`] — the PDA was
|
||||
/// already claimed at `open_position` time.
|
||||
///
|
||||
/// Until issue #97 (stability fee accrual) lands, the fee-accrual step is a
|
||||
/// no-op (every position structurally has `debt_amount = 0` today because
|
||||
/// `generate_debt` is unimplemented; "fees-accrued" is therefore vacuously
|
||||
/// true). A `// TODO(#97)` comment marks where the accrual code will plug in
|
||||
/// — right before the `checked_sub` below.
|
||||
///
|
||||
/// Until issue #91 (`generate_debt`) records the stablecoin definition into
|
||||
/// `Position`, this instruction cannot validate that `stablecoin_definition`
|
||||
/// is the correct one for the position's debt. The caller is trusted.
|
||||
///
|
||||
/// # Panics
|
||||
/// - `owner` 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)`.
|
||||
/// - `user_stablecoin_holding` is not authorized, is uninitialized, is owned by a different Token
|
||||
/// Program than `stablecoin_definition`, or holds a [`TokenHolding`] whose `definition_id` does
|
||||
/// not match `stablecoin_definition.account_id`.
|
||||
/// - `stablecoin_definition` is uninitialized.
|
||||
/// - `amount > Position.debt_amount`.
|
||||
pub fn repay_debt(
|
||||
owner: AccountWithMetadata,
|
||||
position: AccountWithMetadata,
|
||||
stablecoin_definition: AccountWithMetadata,
|
||||
user_stablecoin_holding: AccountWithMetadata,
|
||||
stablecoin_program_id: ProgramId,
|
||||
amount: u128,
|
||||
) -> (Vec<AccountPostState>, Vec<ChainedCall>) {
|
||||
assert!(owner.is_authorized, "Owner 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"
|
||||
);
|
||||
|
||||
let position_data = Position::try_from(&position.account.data)
|
||||
.expect("Position account must hold valid Position state");
|
||||
// `verify_position_and_get_seed` asserts the position address matches the
|
||||
// (owner, collateral_definition) PDA derivation. The returned seed is
|
||||
// dropped — the position is already PDA-claimed.
|
||||
let _position_seed = verify_position_and_get_seed(
|
||||
&position,
|
||||
&owner,
|
||||
position_data.collateral_definition_id,
|
||||
stablecoin_program_id,
|
||||
);
|
||||
|
||||
assert!(
|
||||
user_stablecoin_holding.is_authorized,
|
||||
"User stablecoin holding authorization is missing"
|
||||
);
|
||||
assert_ne!(
|
||||
user_stablecoin_holding.account,
|
||||
Account::default(),
|
||||
"User stablecoin holding must be initialized"
|
||||
);
|
||||
assert_ne!(
|
||||
stablecoin_definition.account,
|
||||
Account::default(),
|
||||
"Stablecoin definition account must be initialized"
|
||||
);
|
||||
assert_eq!(
|
||||
user_stablecoin_holding.account.program_owner, stablecoin_definition.account.program_owner,
|
||||
"Stablecoin holding and definition must be owned by the same Token Program"
|
||||
);
|
||||
let user_holding_data = TokenHolding::try_from(&user_stablecoin_holding.account.data)
|
||||
.expect("User stablecoin holding must hold a valid TokenHolding");
|
||||
assert_eq!(
|
||||
user_holding_data.definition_id(),
|
||||
stablecoin_definition.account_id,
|
||||
"Stablecoin holding does not match the provided stablecoin definition"
|
||||
);
|
||||
|
||||
// TODO(#97): accrue stability fees onto position_data.debt_amount here, before
|
||||
// the checked_sub below. Today every position has debt_amount = 0 (no
|
||||
// generate_debt yet), so the precondition is trivially met.
|
||||
let new_debt = position_data
|
||||
.debt_amount
|
||||
.checked_sub(amount)
|
||||
.expect("Repay amount exceeds outstanding debt");
|
||||
|
||||
let updated_position = Position {
|
||||
collateral_vault_id: position_data.collateral_vault_id,
|
||||
collateral_definition_id: position_data.collateral_definition_id,
|
||||
collateral_amount: position_data.collateral_amount,
|
||||
debt_amount: new_debt,
|
||||
};
|
||||
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(stablecoin_definition.account.clone()),
|
||||
AccountPostState::new(user_stablecoin_holding.account.clone()),
|
||||
];
|
||||
|
||||
let token_program_id = user_stablecoin_holding.account.program_owner;
|
||||
let burn_call = ChainedCall::new(
|
||||
token_program_id,
|
||||
vec![stablecoin_definition, user_stablecoin_holding],
|
||||
&token_core::Instruction::Burn {
|
||||
amount_to_burn: amount,
|
||||
},
|
||||
);
|
||||
|
||||
(post_states, vec![burn_call])
|
||||
}
|
||||
@@ -0,0 +1,973 @@
|
||||
#![allow(
|
||||
clippy::indexing_slicing,
|
||||
clippy::panic,
|
||||
clippy::unwrap_used,
|
||||
reason = "tests deliberately panic on bad state via assert!/#[should_panic] and index fixed-size vectors"
|
||||
)]
|
||||
|
||||
use nssa_core::{
|
||||
account::{Account, AccountId, AccountWithMetadata, Data, Nonce},
|
||||
program::{ChainedCall, Claim, ProgramId},
|
||||
};
|
||||
use stablecoin_core::{
|
||||
compute_position_pda, compute_position_pda_seed, compute_position_vault_pda,
|
||||
compute_position_vault_pda_seed, Position,
|
||||
};
|
||||
use token_core::{TokenDefinition, TokenHolding};
|
||||
|
||||
const STABLECOIN_PROGRAM_ID: ProgramId = [3u32; 8];
|
||||
const TOKEN_PROGRAM_ID: ProgramId = [2u32; 8];
|
||||
|
||||
fn owner_id() -> AccountId {
|
||||
AccountId::new([0x10u8; 32])
|
||||
}
|
||||
|
||||
fn collateral_definition_id() -> AccountId {
|
||||
AccountId::new([0x20u8; 32])
|
||||
}
|
||||
|
||||
fn user_holding_id() -> AccountId {
|
||||
AccountId::new([0x30u8; 32])
|
||||
}
|
||||
|
||||
fn token_holding_account(
|
||||
account_id: AccountId,
|
||||
definition_id: AccountId,
|
||||
balance: u128,
|
||||
) -> AccountWithMetadata {
|
||||
AccountWithMetadata {
|
||||
account: Account {
|
||||
program_owner: TOKEN_PROGRAM_ID,
|
||||
balance: 0,
|
||||
data: Data::from(&TokenHolding::Fungible {
|
||||
definition_id,
|
||||
balance,
|
||||
}),
|
||||
nonce: Nonce(0),
|
||||
},
|
||||
is_authorized: false,
|
||||
account_id,
|
||||
}
|
||||
}
|
||||
|
||||
fn position_id() -> AccountId {
|
||||
compute_position_pda(
|
||||
STABLECOIN_PROGRAM_ID,
|
||||
owner_id(),
|
||||
collateral_definition_id(),
|
||||
)
|
||||
}
|
||||
|
||||
fn vault_id() -> AccountId {
|
||||
compute_position_vault_pda(STABLECOIN_PROGRAM_ID, position_id())
|
||||
}
|
||||
|
||||
fn owner_account() -> AccountWithMetadata {
|
||||
AccountWithMetadata {
|
||||
account: Account::default(),
|
||||
is_authorized: true,
|
||||
account_id: owner_id(),
|
||||
}
|
||||
}
|
||||
|
||||
fn collateral_definition_account() -> AccountWithMetadata {
|
||||
AccountWithMetadata {
|
||||
account: Account {
|
||||
program_owner: TOKEN_PROGRAM_ID,
|
||||
balance: 0,
|
||||
data: Data::from(&TokenDefinition::Fungible {
|
||||
name: "SNT".to_owned(),
|
||||
total_supply: 1_000_000,
|
||||
metadata_id: None,
|
||||
}),
|
||||
nonce: Nonce(0),
|
||||
},
|
||||
is_authorized: false,
|
||||
account_id: collateral_definition_id(),
|
||||
}
|
||||
}
|
||||
|
||||
fn user_holding_account(balance: u128) -> AccountWithMetadata {
|
||||
let mut account = token_holding_account(user_holding_id(), collateral_definition_id(), balance);
|
||||
account.is_authorized = true;
|
||||
account
|
||||
}
|
||||
|
||||
fn uninit_position_account() -> AccountWithMetadata {
|
||||
AccountWithMetadata {
|
||||
account: Account::default(),
|
||||
is_authorized: false,
|
||||
account_id: position_id(),
|
||||
}
|
||||
}
|
||||
|
||||
fn uninit_vault_account() -> AccountWithMetadata {
|
||||
AccountWithMetadata {
|
||||
account: Account::default(),
|
||||
is_authorized: false,
|
||||
account_id: vault_id(),
|
||||
}
|
||||
}
|
||||
|
||||
fn destination_holding_id() -> AccountId {
|
||||
AccountId::new([0x40u8; 32])
|
||||
}
|
||||
|
||||
fn init_position_account(collateral_amount: u128, debt_amount: u128) -> AccountWithMetadata {
|
||||
AccountWithMetadata {
|
||||
account: Account {
|
||||
program_owner: STABLECOIN_PROGRAM_ID,
|
||||
balance: 0,
|
||||
data: Data::from(&Position {
|
||||
collateral_vault_id: vault_id(),
|
||||
collateral_definition_id: collateral_definition_id(),
|
||||
collateral_amount,
|
||||
debt_amount,
|
||||
}),
|
||||
nonce: Nonce(0),
|
||||
},
|
||||
is_authorized: false,
|
||||
account_id: position_id(),
|
||||
}
|
||||
}
|
||||
|
||||
fn init_vault_account() -> AccountWithMetadata {
|
||||
token_holding_account(vault_id(), collateral_definition_id(), 0)
|
||||
}
|
||||
|
||||
fn destination_holding_account() -> AccountWithMetadata {
|
||||
token_holding_account(destination_holding_id(), collateral_definition_id(), 0)
|
||||
}
|
||||
|
||||
fn stablecoin_definition_id() -> AccountId {
|
||||
AccountId::new([0x50u8; 32])
|
||||
}
|
||||
|
||||
fn user_stablecoin_holding_id() -> AccountId {
|
||||
AccountId::new([0x60u8; 32])
|
||||
}
|
||||
|
||||
fn stablecoin_definition_account() -> AccountWithMetadata {
|
||||
AccountWithMetadata {
|
||||
account: Account {
|
||||
program_owner: TOKEN_PROGRAM_ID,
|
||||
balance: 0,
|
||||
data: Data::from(&TokenDefinition::Fungible {
|
||||
name: "DAI".to_owned(),
|
||||
total_supply: 1_000_000,
|
||||
metadata_id: None,
|
||||
}),
|
||||
nonce: Nonce(0),
|
||||
},
|
||||
is_authorized: false,
|
||||
account_id: stablecoin_definition_id(),
|
||||
}
|
||||
}
|
||||
|
||||
fn user_stablecoin_holding_account(balance: u128) -> AccountWithMetadata {
|
||||
let mut account = token_holding_account(
|
||||
user_stablecoin_holding_id(),
|
||||
stablecoin_definition_id(),
|
||||
balance,
|
||||
);
|
||||
account.is_authorized = true;
|
||||
account
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn open_position_claims_pda_and_emits_chained_calls() {
|
||||
let collateral_amount: u128 = 500;
|
||||
let (post_states, chained_calls) = crate::open_position::open_position(
|
||||
owner_account(),
|
||||
uninit_position_account(),
|
||||
uninit_vault_account(),
|
||||
user_holding_account(1_000),
|
||||
collateral_definition_account(),
|
||||
STABLECOIN_PROGRAM_ID,
|
||||
collateral_amount,
|
||||
);
|
||||
|
||||
assert_eq!(post_states.len(), 5);
|
||||
|
||||
// Position is PDA-claimed and carries the encoded Position state.
|
||||
let position_post = &post_states[1];
|
||||
assert_eq!(
|
||||
position_post.required_claim(),
|
||||
Some(Claim::Pda(compute_position_pda_seed(
|
||||
owner_id(),
|
||||
collateral_definition_id()
|
||||
)))
|
||||
);
|
||||
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,
|
||||
debt_amount: 0,
|
||||
}
|
||||
);
|
||||
// The runtime sets the program_owner on the claimed account after validating Claim::Pda.
|
||||
assert_eq!(position_post.account().program_owner, ProgramId::default());
|
||||
|
||||
assert_eq!(chained_calls.len(), 2);
|
||||
|
||||
let mut vault_authorized = uninit_vault_account();
|
||||
vault_authorized.is_authorized = true;
|
||||
let expected_initialize = ChainedCall::new(
|
||||
TOKEN_PROGRAM_ID,
|
||||
vec![collateral_definition_account(), vault_authorized],
|
||||
&token_core::Instruction::InitializeAccount,
|
||||
)
|
||||
.with_pda_seeds(vec![compute_position_vault_pda_seed(position_id())]);
|
||||
assert_eq!(chained_calls[0], expected_initialize);
|
||||
|
||||
let post_init_vault = AccountWithMetadata {
|
||||
account: Account {
|
||||
program_owner: TOKEN_PROGRAM_ID,
|
||||
balance: 0,
|
||||
data: Data::from(&TokenHolding::Fungible {
|
||||
definition_id: collateral_definition_id(),
|
||||
balance: 0,
|
||||
}),
|
||||
nonce: Nonce(0),
|
||||
},
|
||||
is_authorized: false,
|
||||
account_id: vault_id(),
|
||||
};
|
||||
let expected_transfer = ChainedCall::new(
|
||||
TOKEN_PROGRAM_ID,
|
||||
vec![user_holding_account(1_000), post_init_vault],
|
||||
&token_core::Instruction::Transfer {
|
||||
amount_to_transfer: collateral_amount,
|
||||
},
|
||||
);
|
||||
assert_eq!(chained_calls[1], expected_transfer);
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[should_panic(expected = "Owner authorization is missing")]
|
||||
fn open_position_requires_owner_authorization() {
|
||||
let mut owner = owner_account();
|
||||
owner.is_authorized = false;
|
||||
|
||||
crate::open_position::open_position(
|
||||
owner,
|
||||
uninit_position_account(),
|
||||
uninit_vault_account(),
|
||||
user_holding_account(1_000),
|
||||
collateral_definition_account(),
|
||||
STABLECOIN_PROGRAM_ID,
|
||||
500,
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[should_panic(expected = "User collateral holding authorization is missing")]
|
||||
fn open_position_requires_user_holding_authorization() {
|
||||
let mut holding = user_holding_account(1_000);
|
||||
holding.is_authorized = false;
|
||||
|
||||
crate::open_position::open_position(
|
||||
owner_account(),
|
||||
uninit_position_account(),
|
||||
uninit_vault_account(),
|
||||
holding,
|
||||
collateral_definition_account(),
|
||||
STABLECOIN_PROGRAM_ID,
|
||||
500,
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[should_panic(expected = "Position account must be uninitialized")]
|
||||
fn open_position_rejects_initialized_position() {
|
||||
let position = AccountWithMetadata {
|
||||
account: Account {
|
||||
program_owner: STABLECOIN_PROGRAM_ID,
|
||||
balance: 0,
|
||||
data: Data::from(&Position {
|
||||
collateral_vault_id: vault_id(),
|
||||
collateral_definition_id: collateral_definition_id(),
|
||||
collateral_amount: 1,
|
||||
debt_amount: 0,
|
||||
}),
|
||||
nonce: Nonce(0),
|
||||
},
|
||||
is_authorized: false,
|
||||
account_id: position_id(),
|
||||
};
|
||||
|
||||
crate::open_position::open_position(
|
||||
owner_account(),
|
||||
position,
|
||||
uninit_vault_account(),
|
||||
user_holding_account(1_000),
|
||||
collateral_definition_account(),
|
||||
STABLECOIN_PROGRAM_ID,
|
||||
500,
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[should_panic(expected = "Position vault account must be uninitialized")]
|
||||
fn open_position_rejects_initialized_vault() {
|
||||
let vault = AccountWithMetadata {
|
||||
account: Account {
|
||||
program_owner: TOKEN_PROGRAM_ID,
|
||||
balance: 0,
|
||||
data: Data::from(&TokenHolding::Fungible {
|
||||
definition_id: collateral_definition_id(),
|
||||
balance: 0,
|
||||
}),
|
||||
nonce: Nonce(0),
|
||||
},
|
||||
is_authorized: false,
|
||||
account_id: vault_id(),
|
||||
};
|
||||
|
||||
crate::open_position::open_position(
|
||||
owner_account(),
|
||||
uninit_position_account(),
|
||||
vault,
|
||||
user_holding_account(1_000),
|
||||
collateral_definition_account(),
|
||||
STABLECOIN_PROGRAM_ID,
|
||||
500,
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[should_panic(expected = "Position account ID does not match expected derivation")]
|
||||
fn open_position_rejects_wrong_position_address() {
|
||||
let bad_position = AccountWithMetadata {
|
||||
account: Account::default(),
|
||||
is_authorized: false,
|
||||
account_id: AccountId::new([0xFFu8; 32]),
|
||||
};
|
||||
|
||||
crate::open_position::open_position(
|
||||
owner_account(),
|
||||
bad_position,
|
||||
uninit_vault_account(),
|
||||
user_holding_account(1_000),
|
||||
collateral_definition_account(),
|
||||
STABLECOIN_PROGRAM_ID,
|
||||
500,
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[should_panic(expected = "Position vault account ID does not match expected derivation")]
|
||||
fn open_position_rejects_wrong_vault_address() {
|
||||
let bad_vault = AccountWithMetadata {
|
||||
account: Account::default(),
|
||||
is_authorized: false,
|
||||
account_id: AccountId::new([0xEEu8; 32]),
|
||||
};
|
||||
|
||||
crate::open_position::open_position(
|
||||
owner_account(),
|
||||
uninit_position_account(),
|
||||
bad_vault,
|
||||
user_holding_account(1_000),
|
||||
collateral_definition_account(),
|
||||
STABLECOIN_PROGRAM_ID,
|
||||
500,
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[should_panic(expected = "User collateral holding does not match the provided token definition")]
|
||||
fn open_position_rejects_mismatched_token_definition() {
|
||||
let other_definition = AccountWithMetadata {
|
||||
account: Account {
|
||||
program_owner: TOKEN_PROGRAM_ID,
|
||||
balance: 0,
|
||||
data: Data::from(&TokenDefinition::Fungible {
|
||||
name: "OTHER".to_owned(),
|
||||
total_supply: 1,
|
||||
metadata_id: None,
|
||||
}),
|
||||
nonce: Nonce(0),
|
||||
},
|
||||
is_authorized: false,
|
||||
account_id: AccountId::new([0x21u8; 32]),
|
||||
};
|
||||
|
||||
crate::open_position::open_position(
|
||||
owner_account(),
|
||||
uninit_position_account(),
|
||||
uninit_vault_account(),
|
||||
user_holding_account(1_000),
|
||||
other_definition,
|
||||
STABLECOIN_PROGRAM_ID,
|
||||
500,
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[should_panic(
|
||||
expected = "Collateral token definition is not owned by the user holding's Token Program"
|
||||
)]
|
||||
fn open_position_rejects_definition_with_wrong_token_program() {
|
||||
let mut definition = collateral_definition_account();
|
||||
definition.account.program_owner = [9u32; 8];
|
||||
|
||||
crate::open_position::open_position(
|
||||
owner_account(),
|
||||
uninit_position_account(),
|
||||
uninit_vault_account(),
|
||||
user_holding_account(1_000),
|
||||
definition,
|
||||
STABLECOIN_PROGRAM_ID,
|
||||
500,
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn position_pda_is_deterministic_and_owner_and_collateral_specific() {
|
||||
let id_a = compute_position_pda(
|
||||
STABLECOIN_PROGRAM_ID,
|
||||
owner_id(),
|
||||
collateral_definition_id(),
|
||||
);
|
||||
let id_b = compute_position_pda(
|
||||
STABLECOIN_PROGRAM_ID,
|
||||
owner_id(),
|
||||
collateral_definition_id(),
|
||||
);
|
||||
assert_eq!(id_a, id_b);
|
||||
|
||||
let other_owner = AccountId::new([0x11u8; 32]);
|
||||
assert_ne!(
|
||||
compute_position_pda(
|
||||
STABLECOIN_PROGRAM_ID,
|
||||
other_owner,
|
||||
collateral_definition_id()
|
||||
),
|
||||
id_a
|
||||
);
|
||||
|
||||
let other_definition = AccountId::new([0x21u8; 32]);
|
||||
assert_ne!(
|
||||
compute_position_pda(STABLECOIN_PROGRAM_ID, owner_id(), other_definition),
|
||||
id_a
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn position_pda_and_vault_pda_do_not_collide() {
|
||||
// Distinct domain tags must keep the position id and its vault id disjoint.
|
||||
let position = compute_position_pda(
|
||||
STABLECOIN_PROGRAM_ID,
|
||||
owner_id(),
|
||||
collateral_definition_id(),
|
||||
);
|
||||
let vault = compute_position_vault_pda(STABLECOIN_PROGRAM_ID, position);
|
||||
assert_ne!(position, vault);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn withdraw_collateral_updates_position_and_emits_transfer() {
|
||||
let initial_collateral: u128 = 500;
|
||||
let amount: u128 = 200;
|
||||
let (post_states, chained_calls) = crate::withdraw_collateral::withdraw_collateral(
|
||||
owner_account(),
|
||||
init_position_account(initial_collateral, 0),
|
||||
init_vault_account(),
|
||||
destination_holding_account(),
|
||||
STABLECOIN_PROGRAM_ID,
|
||||
amount,
|
||||
);
|
||||
|
||||
assert_eq!(post_states.len(), 4);
|
||||
|
||||
// Position post-state: plain `new`, holds the decremented Position.
|
||||
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: 0,
|
||||
}
|
||||
);
|
||||
assert_eq!(position_post.account().program_owner, STABLECOIN_PROGRAM_ID);
|
||||
|
||||
// Vault and destination post-states are pre-transfer (mutation comes via chained call).
|
||||
assert_eq!(post_states[2].account(), &init_vault_account().account);
|
||||
assert_eq!(
|
||||
post_states[3].account(),
|
||||
&destination_holding_account().account
|
||||
);
|
||||
|
||||
// Single chained Token::Transfer with vault PDA seed.
|
||||
assert_eq!(chained_calls.len(), 1);
|
||||
let mut vault_authorized = init_vault_account();
|
||||
vault_authorized.is_authorized = true;
|
||||
let expected_transfer = ChainedCall::new(
|
||||
TOKEN_PROGRAM_ID,
|
||||
vec![vault_authorized, destination_holding_account()],
|
||||
&token_core::Instruction::Transfer {
|
||||
amount_to_transfer: amount,
|
||||
},
|
||||
)
|
||||
.with_pda_seeds(vec![compute_position_vault_pda_seed(position_id())]);
|
||||
assert_eq!(chained_calls[0], expected_transfer);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn withdraw_collateral_allows_full_drain() {
|
||||
let amount: u128 = 500;
|
||||
let (post_states, _chained_calls) = crate::withdraw_collateral::withdraw_collateral(
|
||||
owner_account(),
|
||||
init_position_account(amount, 0),
|
||||
init_vault_account(),
|
||||
destination_holding_account(),
|
||||
STABLECOIN_PROGRAM_ID,
|
||||
amount,
|
||||
);
|
||||
let position = Position::try_from(&post_states[1].account().data).expect("valid Position");
|
||||
assert_eq!(position.collateral_amount, 0);
|
||||
assert_eq!(position.debt_amount, 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn withdraw_collateral_allows_zero_amount() {
|
||||
let initial: u128 = 500;
|
||||
let (post_states, chained_calls) = crate::withdraw_collateral::withdraw_collateral(
|
||||
owner_account(),
|
||||
init_position_account(initial, 0),
|
||||
init_vault_account(),
|
||||
destination_holding_account(),
|
||||
STABLECOIN_PROGRAM_ID,
|
||||
0,
|
||||
);
|
||||
let position = Position::try_from(&post_states[1].account().data).expect("valid Position");
|
||||
assert_eq!(position.collateral_amount, initial);
|
||||
|
||||
let mut vault_authorized = init_vault_account();
|
||||
vault_authorized.is_authorized = true;
|
||||
let expected_transfer = ChainedCall::new(
|
||||
TOKEN_PROGRAM_ID,
|
||||
vec![vault_authorized, destination_holding_account()],
|
||||
&token_core::Instruction::Transfer {
|
||||
amount_to_transfer: 0,
|
||||
},
|
||||
)
|
||||
.with_pda_seeds(vec![compute_position_vault_pda_seed(position_id())]);
|
||||
assert_eq!(chained_calls, vec![expected_transfer]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[should_panic(expected = "Owner authorization is missing")]
|
||||
fn withdraw_collateral_requires_owner_authorization() {
|
||||
let mut owner = owner_account();
|
||||
owner.is_authorized = false;
|
||||
crate::withdraw_collateral::withdraw_collateral(
|
||||
owner,
|
||||
init_position_account(500, 0),
|
||||
init_vault_account(),
|
||||
destination_holding_account(),
|
||||
STABLECOIN_PROGRAM_ID,
|
||||
100,
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[should_panic(expected = "Position account must be initialized")]
|
||||
fn withdraw_collateral_rejects_uninitialized_position() {
|
||||
crate::withdraw_collateral::withdraw_collateral(
|
||||
owner_account(),
|
||||
uninit_position_account(),
|
||||
init_vault_account(),
|
||||
destination_holding_account(),
|
||||
STABLECOIN_PROGRAM_ID,
|
||||
100,
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[should_panic(expected = "Position is not owned by this stablecoin program")]
|
||||
fn withdraw_collateral_rejects_position_owned_by_other_program() {
|
||||
let mut position = init_position_account(500, 0);
|
||||
position.account.program_owner = [9u32; 8];
|
||||
crate::withdraw_collateral::withdraw_collateral(
|
||||
owner_account(),
|
||||
position,
|
||||
init_vault_account(),
|
||||
destination_holding_account(),
|
||||
STABLECOIN_PROGRAM_ID,
|
||||
100,
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[should_panic(expected = "Position account ID does not match expected derivation")]
|
||||
fn withdraw_collateral_rejects_wrong_position_address() {
|
||||
let mut position = init_position_account(500, 0);
|
||||
position.account_id = AccountId::new([0xFFu8; 32]);
|
||||
crate::withdraw_collateral::withdraw_collateral(
|
||||
owner_account(),
|
||||
position,
|
||||
init_vault_account(),
|
||||
destination_holding_account(),
|
||||
STABLECOIN_PROGRAM_ID,
|
||||
100,
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[should_panic(expected = "Position vault account ID does not match expected derivation")]
|
||||
fn withdraw_collateral_rejects_wrong_vault_address() {
|
||||
let mut vault = init_vault_account();
|
||||
vault.account_id = AccountId::new([0xEEu8; 32]);
|
||||
crate::withdraw_collateral::withdraw_collateral(
|
||||
owner_account(),
|
||||
init_position_account(500, 0),
|
||||
vault,
|
||||
destination_holding_account(),
|
||||
STABLECOIN_PROGRAM_ID,
|
||||
100,
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[should_panic(expected = "Vault token holding is not for the position's collateral definition")]
|
||||
fn withdraw_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::withdraw_collateral::withdraw_collateral(
|
||||
owner_account(),
|
||||
init_position_account(500, 0),
|
||||
vault,
|
||||
destination_holding_account(),
|
||||
STABLECOIN_PROGRAM_ID,
|
||||
100,
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[should_panic(expected = "Destination must be initialized")]
|
||||
fn withdraw_collateral_rejects_uninitialized_destination() {
|
||||
let destination = AccountWithMetadata {
|
||||
account: Account::default(),
|
||||
is_authorized: false,
|
||||
account_id: destination_holding_id(),
|
||||
};
|
||||
crate::withdraw_collateral::withdraw_collateral(
|
||||
owner_account(),
|
||||
init_position_account(500, 0),
|
||||
init_vault_account(),
|
||||
destination,
|
||||
STABLECOIN_PROGRAM_ID,
|
||||
100,
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[should_panic(expected = "Destination must be owned by the same Token Program as the vault")]
|
||||
fn withdraw_collateral_rejects_destination_with_wrong_token_program() {
|
||||
let mut destination = destination_holding_account();
|
||||
destination.account.program_owner = [9u32; 8];
|
||||
crate::withdraw_collateral::withdraw_collateral(
|
||||
owner_account(),
|
||||
init_position_account(500, 0),
|
||||
init_vault_account(),
|
||||
destination,
|
||||
STABLECOIN_PROGRAM_ID,
|
||||
100,
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[should_panic(
|
||||
expected = "Destination token definition does not match the position's collateral definition"
|
||||
)]
|
||||
fn withdraw_collateral_rejects_destination_for_other_definition() {
|
||||
let mut destination = destination_holding_account();
|
||||
destination.account.data = Data::from(&TokenHolding::Fungible {
|
||||
definition_id: AccountId::new([0x21u8; 32]),
|
||||
balance: 0,
|
||||
});
|
||||
crate::withdraw_collateral::withdraw_collateral(
|
||||
owner_account(),
|
||||
init_position_account(500, 0),
|
||||
init_vault_account(),
|
||||
destination,
|
||||
STABLECOIN_PROGRAM_ID,
|
||||
100,
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[should_panic(expected = "withdraw_collateral with debt is not supported yet")]
|
||||
fn withdraw_collateral_rejects_withdrawal_with_outstanding_debt() {
|
||||
crate::withdraw_collateral::withdraw_collateral(
|
||||
owner_account(),
|
||||
init_position_account(500, 1),
|
||||
init_vault_account(),
|
||||
destination_holding_account(),
|
||||
STABLECOIN_PROGRAM_ID,
|
||||
100,
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[should_panic(expected = "Withdrawal amount exceeds position collateral")]
|
||||
fn withdraw_collateral_rejects_overdraw() {
|
||||
crate::withdraw_collateral::withdraw_collateral(
|
||||
owner_account(),
|
||||
init_position_account(100, 0),
|
||||
init_vault_account(),
|
||||
destination_holding_account(),
|
||||
STABLECOIN_PROGRAM_ID,
|
||||
200,
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn repay_debt_decreases_debt_and_emits_burn() {
|
||||
let initial_collateral: u128 = 500;
|
||||
let initial_debt: u128 = 300;
|
||||
let amount: u128 = 100;
|
||||
let holding_balance: u128 = 1_000;
|
||||
|
||||
let (post_states, chained_calls) = crate::repay_debt::repay_debt(
|
||||
owner_account(),
|
||||
init_position_account(initial_collateral, initial_debt),
|
||||
stablecoin_definition_account(),
|
||||
user_stablecoin_holding_account(holding_balance),
|
||||
STABLECOIN_PROGRAM_ID,
|
||||
amount,
|
||||
);
|
||||
|
||||
assert_eq!(post_states.len(), 4);
|
||||
|
||||
// Position post-state: plain `new`, holds the decremented Position.
|
||||
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,
|
||||
debt_amount: initial_debt - amount,
|
||||
}
|
||||
);
|
||||
assert_eq!(position_post.account().program_owner, STABLECOIN_PROGRAM_ID);
|
||||
|
||||
// Stablecoin definition and user holding post-states are pre-burn.
|
||||
assert_eq!(
|
||||
post_states[2].account(),
|
||||
&stablecoin_definition_account().account
|
||||
);
|
||||
assert_eq!(
|
||||
post_states[3].account(),
|
||||
&user_stablecoin_holding_account(holding_balance).account
|
||||
);
|
||||
|
||||
// Single chained Token::Burn, no PDA seeds (user-authorized burn source).
|
||||
assert_eq!(chained_calls.len(), 1);
|
||||
let expected_burn = ChainedCall::new(
|
||||
TOKEN_PROGRAM_ID,
|
||||
vec![
|
||||
stablecoin_definition_account(),
|
||||
user_stablecoin_holding_account(holding_balance),
|
||||
],
|
||||
&token_core::Instruction::Burn {
|
||||
amount_to_burn: amount,
|
||||
},
|
||||
);
|
||||
assert_eq!(chained_calls[0], expected_burn);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn repay_debt_allows_full_repayment() {
|
||||
let debt: u128 = 300;
|
||||
let (post_states, _chained_calls) = crate::repay_debt::repay_debt(
|
||||
owner_account(),
|
||||
init_position_account(500, debt),
|
||||
stablecoin_definition_account(),
|
||||
user_stablecoin_holding_account(1_000),
|
||||
STABLECOIN_PROGRAM_ID,
|
||||
debt,
|
||||
);
|
||||
let position = Position::try_from(&post_states[1].account().data).expect("valid Position");
|
||||
assert_eq!(position.debt_amount, 0);
|
||||
assert_eq!(position.collateral_amount, 500);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn repay_debt_allows_zero_amount() {
|
||||
let initial_debt: u128 = 300;
|
||||
let (post_states, chained_calls) = crate::repay_debt::repay_debt(
|
||||
owner_account(),
|
||||
init_position_account(500, initial_debt),
|
||||
stablecoin_definition_account(),
|
||||
user_stablecoin_holding_account(1_000),
|
||||
STABLECOIN_PROGRAM_ID,
|
||||
0,
|
||||
);
|
||||
let position = Position::try_from(&post_states[1].account().data).expect("valid Position");
|
||||
assert_eq!(position.debt_amount, initial_debt);
|
||||
|
||||
let expected_burn = ChainedCall::new(
|
||||
TOKEN_PROGRAM_ID,
|
||||
vec![
|
||||
stablecoin_definition_account(),
|
||||
user_stablecoin_holding_account(1_000),
|
||||
],
|
||||
&token_core::Instruction::Burn { amount_to_burn: 0 },
|
||||
);
|
||||
assert_eq!(chained_calls, vec![expected_burn]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[should_panic(expected = "Owner authorization is missing")]
|
||||
fn repay_debt_requires_owner_authorization() {
|
||||
let mut owner = owner_account();
|
||||
owner.is_authorized = false;
|
||||
crate::repay_debt::repay_debt(
|
||||
owner,
|
||||
init_position_account(500, 300),
|
||||
stablecoin_definition_account(),
|
||||
user_stablecoin_holding_account(1_000),
|
||||
STABLECOIN_PROGRAM_ID,
|
||||
100,
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[should_panic(expected = "Position account must be initialized")]
|
||||
fn repay_debt_rejects_uninitialized_position() {
|
||||
crate::repay_debt::repay_debt(
|
||||
owner_account(),
|
||||
uninit_position_account(),
|
||||
stablecoin_definition_account(),
|
||||
user_stablecoin_holding_account(1_000),
|
||||
STABLECOIN_PROGRAM_ID,
|
||||
100,
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[should_panic(expected = "Position is not owned by this stablecoin program")]
|
||||
fn repay_debt_rejects_position_owned_by_other_program() {
|
||||
let mut position = init_position_account(500, 300);
|
||||
position.account.program_owner = [9u32; 8];
|
||||
crate::repay_debt::repay_debt(
|
||||
owner_account(),
|
||||
position,
|
||||
stablecoin_definition_account(),
|
||||
user_stablecoin_holding_account(1_000),
|
||||
STABLECOIN_PROGRAM_ID,
|
||||
100,
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[should_panic(expected = "Position account ID does not match expected derivation")]
|
||||
fn repay_debt_rejects_wrong_position_address() {
|
||||
let mut position = init_position_account(500, 300);
|
||||
position.account_id = AccountId::new([0xFFu8; 32]);
|
||||
crate::repay_debt::repay_debt(
|
||||
owner_account(),
|
||||
position,
|
||||
stablecoin_definition_account(),
|
||||
user_stablecoin_holding_account(1_000),
|
||||
STABLECOIN_PROGRAM_ID,
|
||||
100,
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[should_panic(expected = "User stablecoin holding authorization is missing")]
|
||||
fn repay_debt_requires_user_holding_authorization() {
|
||||
let mut holding = user_stablecoin_holding_account(1_000);
|
||||
holding.is_authorized = false;
|
||||
crate::repay_debt::repay_debt(
|
||||
owner_account(),
|
||||
init_position_account(500, 300),
|
||||
stablecoin_definition_account(),
|
||||
holding,
|
||||
STABLECOIN_PROGRAM_ID,
|
||||
100,
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[should_panic(expected = "User stablecoin holding must be initialized")]
|
||||
fn repay_debt_rejects_uninitialized_user_holding() {
|
||||
let holding = AccountWithMetadata {
|
||||
account: Account::default(),
|
||||
is_authorized: true,
|
||||
account_id: user_stablecoin_holding_id(),
|
||||
};
|
||||
crate::repay_debt::repay_debt(
|
||||
owner_account(),
|
||||
init_position_account(500, 300),
|
||||
stablecoin_definition_account(),
|
||||
holding,
|
||||
STABLECOIN_PROGRAM_ID,
|
||||
100,
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[should_panic(
|
||||
expected = "Stablecoin holding and definition must be owned by the same Token Program"
|
||||
)]
|
||||
fn repay_debt_rejects_holding_with_different_token_program() {
|
||||
let mut holding = user_stablecoin_holding_account(1_000);
|
||||
holding.account.program_owner = [9u32; 8];
|
||||
crate::repay_debt::repay_debt(
|
||||
owner_account(),
|
||||
init_position_account(500, 300),
|
||||
stablecoin_definition_account(),
|
||||
holding,
|
||||
STABLECOIN_PROGRAM_ID,
|
||||
100,
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[should_panic(expected = "Stablecoin holding does not match the provided stablecoin definition")]
|
||||
fn repay_debt_rejects_holding_for_other_definition() {
|
||||
let mut holding = user_stablecoin_holding_account(1_000);
|
||||
holding.account.data = Data::from(&TokenHolding::Fungible {
|
||||
definition_id: AccountId::new([0x21u8; 32]),
|
||||
balance: 1_000,
|
||||
});
|
||||
crate::repay_debt::repay_debt(
|
||||
owner_account(),
|
||||
init_position_account(500, 300),
|
||||
stablecoin_definition_account(),
|
||||
holding,
|
||||
STABLECOIN_PROGRAM_ID,
|
||||
100,
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[should_panic(expected = "Repay amount exceeds outstanding debt")]
|
||||
fn repay_debt_rejects_overrepay() {
|
||||
crate::repay_debt::repay_debt(
|
||||
owner_account(),
|
||||
init_position_account(500, 100),
|
||||
stablecoin_definition_account(),
|
||||
user_stablecoin_holding_account(1_000),
|
||||
STABLECOIN_PROGRAM_ID,
|
||||
200,
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
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;
|
||||
|
||||
/// Withdraw `amount` collateral tokens from `position`'s vault back to `destination`.
|
||||
///
|
||||
/// Decreases `Position.collateral_amount` by `amount` and emits a single chained
|
||||
/// `Token::Transfer` from the vault to `destination`, authorized by the vault
|
||||
/// PDA seed. The position post-state uses plain [`AccountPostState::new`] —
|
||||
/// the initial PDA claim already happened in
|
||||
/// [`crate::open_position::open_position`].
|
||||
///
|
||||
/// Until issues #95 / #96 / #97 land (redemption price, price feed, stability
|
||||
/// fee accrual), this instruction hard-asserts `Position.debt_amount == 0`.
|
||||
/// When those land, this guard is replaced by real fee accrual + a
|
||||
/// collateralization-ratio check against the post-withdrawal collateral.
|
||||
///
|
||||
/// # Panics
|
||||
/// - `owner` 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` 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.
|
||||
/// - `destination` 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.debt_amount` is non-zero.
|
||||
/// - `amount > Position.collateral_amount`.
|
||||
pub fn withdraw_collateral(
|
||||
owner: AccountWithMetadata,
|
||||
position: AccountWithMetadata,
|
||||
vault: AccountWithMetadata,
|
||||
destination: AccountWithMetadata,
|
||||
stablecoin_program_id: ProgramId,
|
||||
amount: u128,
|
||||
) -> (Vec<AccountPostState>, Vec<ChainedCall>) {
|
||||
assert!(owner.is_authorized, "Owner 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"
|
||||
);
|
||||
|
||||
let position_data = Position::try_from(&position.account.data)
|
||||
.expect("Position account must hold valid Position state");
|
||||
// `verify_position_and_get_seed` asserts the position address matches the
|
||||
// (owner, collateral_definition) PDA derivation. We do not use the seed
|
||||
// downstream — the position is already PDA-claimed.
|
||||
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);
|
||||
|
||||
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_ne!(
|
||||
destination.account,
|
||||
Account::default(),
|
||||
"Destination must be initialized"
|
||||
);
|
||||
assert_eq!(
|
||||
destination.account.program_owner, token_program_id,
|
||||
"Destination must be owned by the same Token Program as the vault"
|
||||
);
|
||||
let destination_holding = TokenHolding::try_from(&destination.account.data)
|
||||
.expect("Destination account must hold a valid TokenHolding");
|
||||
assert_eq!(
|
||||
destination_holding.definition_id(),
|
||||
position_data.collateral_definition_id,
|
||||
"Destination token definition does not match the position's collateral definition"
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
position_data.debt_amount, 0,
|
||||
"withdraw_collateral with debt is not supported yet — stability fee accrual and collateralization check land with #97/#96"
|
||||
);
|
||||
let new_collateral = position_data
|
||||
.collateral_amount
|
||||
.checked_sub(amount)
|
||||
.expect("Withdrawal amount exceeds 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(destination.account.clone()),
|
||||
];
|
||||
|
||||
let mut vault_authorized = vault.clone();
|
||||
vault_authorized.is_authorized = true;
|
||||
let transfer_call = ChainedCall::new(
|
||||
token_program_id,
|
||||
vec![vault_authorized, destination],
|
||||
&token_core::Instruction::Transfer {
|
||||
amount_to_transfer: amount,
|
||||
},
|
||||
)
|
||||
.with_pda_seeds(vec![vault_seed]);
|
||||
|
||||
(post_states, vec![transfer_call])
|
||||
}
|
||||
Reference in New Issue
Block a user