Files
lez-programs/programs/stablecoin/src/tests.rs
T
r4bbit 75a99d1224 refactor: rename nssa/nssa_core → lee/lee_core
Follow the spel fork's `nssa → lee` refactor (chore/update-lez): the
`#[lez_program]` macro now emits `lee_core::…` paths, so the crate must be
referenced by its real name rather than the `nssa_core` alias.

- Cargo.toml: rename the `nssa_core`/`nssa` dep keys to `lee_core`/`lee` and
  drop the now-redundant `package = "lee_core"`/`package = "lee"` aliases
  (dep name == package name).
- Rust: `nssa_core::` → `lee_core::`, `nssa::` → `lee::` across programs,
  modules, and tools.
- Docs: update the library reference in CLAUDE.md and README.md.

Also drop the dead `NSSA_WALLET_HOME_DIR` env var — superseded by
`LEE_WALLET_HOME_DIR`, which is the only wallet-home var any v0.2.x wallet
reads — from the test tooling (setup-amm-testnet.sh, custom-token.mjs,
run-basecamp-e2e.sh) and docs.

Closes #225
2026-08-26 12:30:36 +02:00

1015 lines
31 KiB
Rust

#![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 lee_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];
const TEST_POSITION_NONCE: u64 = 0;
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(), TEST_POSITION_NONCE)
}
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,
authority: 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,
normalized_debt_amount: u128,
) -> AccountWithMetadata {
AccountWithMetadata {
account: Account {
program_owner: STABLECOIN_PROGRAM_ID,
balance: 0,
data: Data::from(&Position {
owner_account_id: owner_id(),
position_nonce: TEST_POSITION_NONCE,
vault_account_id: vault_id(),
collateral_amount,
normalized_debt_amount,
opened_at: 0,
}),
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,
authority: 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,
TEST_POSITION_NONCE,
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(),
TEST_POSITION_NONCE
)))
);
let position = Position::try_from(&position_post.account().data).expect("valid Position");
assert_eq!(
position,
Position {
owner_account_id: owner_id(),
position_nonce: TEST_POSITION_NONCE,
vault_account_id: vault_id(),
collateral_amount,
normalized_debt_amount: 0,
opened_at: 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,
TEST_POSITION_NONCE,
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,
TEST_POSITION_NONCE,
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 {
owner_account_id: owner_id(),
position_nonce: TEST_POSITION_NONCE,
vault_account_id: vault_id(),
collateral_amount: 1,
normalized_debt_amount: 0,
opened_at: 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,
TEST_POSITION_NONCE,
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,
TEST_POSITION_NONCE,
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,
TEST_POSITION_NONCE,
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,
TEST_POSITION_NONCE,
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,
authority: 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,
TEST_POSITION_NONCE,
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,
TEST_POSITION_NONCE,
500,
);
}
#[test]
fn position_pda_is_deterministic_and_owner_and_nonce_specific() {
let id_a = compute_position_pda(STABLECOIN_PROGRAM_ID, owner_id(), TEST_POSITION_NONCE);
let id_b = compute_position_pda(STABLECOIN_PROGRAM_ID, owner_id(), TEST_POSITION_NONCE);
assert_eq!(id_a, id_b);
let other_owner = AccountId::new([0x11u8; 32]);
assert_ne!(
compute_position_pda(STABLECOIN_PROGRAM_ID, other_owner, TEST_POSITION_NONCE),
id_a
);
let other_nonce = TEST_POSITION_NONCE + 1;
assert_ne!(
compute_position_pda(STABLECOIN_PROGRAM_ID, owner_id(), other_nonce),
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(), TEST_POSITION_NONCE);
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 {
owner_account_id: owner_id(),
position_nonce: TEST_POSITION_NONCE,
vault_account_id: vault_id(),
collateral_amount: initial_collateral - amount,
normalized_debt_amount: 0,
opened_at: 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.normalized_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 = "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 = "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 = "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 {
owner_account_id: owner_id(),
position_nonce: TEST_POSITION_NONCE,
vault_account_id: vault_id(),
collateral_amount: initial_collateral,
normalized_debt_amount: initial_debt - amount,
opened_at: 0,
}
);
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.normalized_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.normalized_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,
);
}
/// Mutates only the stored discovery copy, leaving the position address (and so
/// the `(owner, position_nonce)` PDA derivation) valid — the state a corrupted
/// or malformed `Position` would present.
fn position_with_mutated_fields(mutate: impl FnOnce(&mut Position)) -> AccountWithMetadata {
let mut position = init_position_account(500, 0);
let mut data = Position::try_from(&position.account.data).expect("valid Position");
mutate(&mut data);
position.account.data = Data::from(&data);
position
}
#[test]
#[should_panic(expected = "Position owner_account_id does not match the owner account")]
fn withdraw_collateral_rejects_position_with_stale_owner_field() {
crate::withdraw_collateral::withdraw_collateral(
owner_account(),
position_with_mutated_fields(|p| p.owner_account_id = AccountId::new([0xAAu8; 32])),
init_vault_account(),
destination_holding_account(),
STABLECOIN_PROGRAM_ID,
100,
);
}
#[test]
#[should_panic(expected = "Position vault_account_id does not match the vault account")]
fn withdraw_collateral_rejects_position_with_stale_vault_field() {
crate::withdraw_collateral::withdraw_collateral(
owner_account(),
position_with_mutated_fields(|p| p.vault_account_id = AccountId::new([0xBBu8; 32])),
init_vault_account(),
destination_holding_account(),
STABLECOIN_PROGRAM_ID,
100,
);
}
#[test]
#[should_panic(expected = "Position owner_account_id does not match the owner account")]
fn repay_debt_rejects_position_with_stale_owner_field() {
crate::repay_debt::repay_debt(
owner_account(),
position_with_mutated_fields(|p| {
p.normalized_debt_amount = 300;
p.owner_account_id = AccountId::new([0xAAu8; 32]);
}),
stablecoin_definition_account(),
user_stablecoin_holding_account(1_000),
STABLECOIN_PROGRAM_ID,
100,
);
}