From cdb53a4d0ca808c1071e0527be2fa6018ca38a3f Mon Sep 17 00:00:00 2001 From: Andrea Franz Date: Fri, 22 May 2026 11:09:16 +0200 Subject: [PATCH] feat(stablecoin): implement `repay_debt` (#93) --- artifacts/stablecoin-idl.json | 35 +++ integration_tests/tests/stablecoin.rs | 171 ++++++++++- stablecoin/core/src/lib.rs | 26 ++ .../methods/guest/src/bin/stablecoin.rs | 29 ++ stablecoin/src/lib.rs | 3 + stablecoin/src/repay_debt.rs | 126 ++++++++ stablecoin/src/tests.rs | 273 ++++++++++++++++++ 7 files changed, 658 insertions(+), 5 deletions(-) create mode 100644 stablecoin/src/repay_debt.rs diff --git a/artifacts/stablecoin-idl.json b/artifacts/stablecoin-idl.json index 9e4a33a..5c601e8 100644 --- a/artifacts/stablecoin-idl.json +++ b/artifacts/stablecoin-idl.json @@ -77,6 +77,41 @@ "type": "u128" } ] + }, + { + "name": "repay_debt", + "accounts": [ + { + "name": "owner", + "writable": false, + "signer": false, + "init": false + }, + { + "name": "position", + "writable": false, + "signer": false, + "init": false + }, + { + "name": "stablecoin_definition", + "writable": false, + "signer": false, + "init": false + }, + { + "name": "user_stablecoin_holding", + "writable": false, + "signer": false, + "init": false + } + ], + "args": [ + { + "name": "amount", + "type": "u128" + } + ] } ], "accounts": [ diff --git a/integration_tests/tests/stablecoin.rs b/integration_tests/tests/stablecoin.rs index 68c51bd..4044d84 100644 --- a/integration_tests/tests/stablecoin.rs +++ b/integration_tests/tests/stablecoin.rs @@ -19,6 +19,10 @@ impl Keys { fn user_holding() -> PrivateKey { PrivateKey::try_new([42; 32]).expect("valid private key") } + + fn user_stablecoin_holding() -> PrivateKey { + PrivateKey::try_new([43; 32]).expect("valid private key") + } } impl Ids { @@ -42,6 +46,16 @@ impl Ids { AccountId::from(&PublicKey::new_from_private_key(&Keys::user_holding())) } + fn stablecoin_definition() -> AccountId { + AccountId::new([6; 32]) + } + + fn user_stablecoin_holding() -> AccountId { + AccountId::from(&PublicKey::new_from_private_key( + &Keys::user_stablecoin_holding(), + )) + } + fn position() -> AccountId { compute_position_pda( Self::stablecoin_program(), @@ -67,6 +81,22 @@ impl Balances { fn collateral_withdraw() -> u128 { 200_000 } + + fn stablecoin_supply_init() -> u128 { + 1_000 + } + + fn user_stablecoin_holding_init() -> u128 { + 1_000 + } + + fn initial_debt() -> u128 { + 300 + } + + fn debt_repay_amount() -> u128 { + 100 + } } impl Accounts { @@ -94,6 +124,45 @@ impl Accounts { nonce: Nonce(0), } } + + fn stablecoin_definition_init() -> Account { + Account { + program_owner: Ids::token_program(), + balance: 0_u128, + data: Data::from(&TokenDefinition::Fungible { + name: String::from("DAI"), + total_supply: Balances::stablecoin_supply_init(), + metadata_id: None, + }), + nonce: Nonce(0), + } + } + + fn user_stablecoin_holding_init() -> Account { + Account { + program_owner: Ids::token_program(), + balance: 0_u128, + data: Data::from(&TokenHolding::Fungible { + definition_id: Ids::stablecoin_definition(), + balance: Balances::user_stablecoin_holding_init(), + }), + nonce: Nonce(0), + } + } + + fn position_with_debt_init() -> Account { + Account { + program_owner: stablecoin_methods::STABLECOIN_ID, + balance: 0_u128, + data: Data::from(&Position { + collateral_vault_id: Ids::vault(), + collateral_definition_id: Ids::collateral_definition(), + collateral_amount: Balances::collateral_deposit(), + debt_amount: Balances::initial_debt(), + }), + nonce: Nonce(0), + } + } } fn deploy_programs(state: &mut V03State) { @@ -129,13 +198,35 @@ fn current_nonce(state: &V03State, account_id: AccountId) -> Nonce { state.get_account_by_id(account_id).nonce } +fn state_for_stablecoin_repay_tests() -> V03State { + let mut state = V03State::new_with_genesis_accounts(&[], vec![], 0); + deploy_programs(&mut state); + state.force_insert_account( + Ids::collateral_definition(), + Accounts::collateral_definition_init(), + ); + state.force_insert_account( + Ids::stablecoin_definition(), + Accounts::stablecoin_definition_init(), + ); + state.force_insert_account(Ids::position(), Accounts::position_with_debt_init()); + state.force_insert_account( + Ids::user_stablecoin_holding(), + Accounts::user_stablecoin_holding_init(), + ); + state +} + fn assert_position(state: &V03State, expected_collateral: u128) { - let position = Position::try_from(&state.get_account_by_id(Ids::position()).data) - .expect("valid Position"); + let position = + Position::try_from(&state.get_account_by_id(Ids::position()).data).expect("valid Position"); assert_eq!(position.collateral_amount, expected_collateral); assert_eq!(position.debt_amount, 0); assert_eq!(position.collateral_vault_id, Ids::vault()); - assert_eq!(position.collateral_definition_id, Ids::collateral_definition()); + assert_eq!( + position.collateral_definition_id, + Ids::collateral_definition() + ); } fn assert_fungible_balance(state: &V03State, account_id: AccountId, expected_balance: u128) { @@ -212,8 +303,7 @@ fn stablecoin_open_position_then_withdraw_collateral() { withdraw, ) .unwrap(); - let witness_set = - public_transaction::WitnessSet::for_message(&message, &[&Keys::owner()]); + let witness_set = public_transaction::WitnessSet::for_message(&message, &[&Keys::owner()]); let tx = PublicTransaction::new(message, witness_set); state .transition_from_public_transaction(&tx, 0, 0) @@ -235,3 +325,74 @@ fn stablecoin_open_position_then_withdraw_collateral() { + Balances::collateral_withdraw(), ); } + +#[test] +fn stablecoin_repay_debt_burns_stablecoins_and_decreases_debt() { + let mut state = state_for_stablecoin_repay_tests(); + + let repay = stablecoin_core::Instruction::RepayDebt { + amount: Balances::debt_repay_amount(), + }; + let message = public_transaction::Message::try_new( + Ids::stablecoin_program(), + vec![ + Ids::owner(), + Ids::position(), + Ids::stablecoin_definition(), + Ids::user_stablecoin_holding(), + ], + vec![ + current_nonce(&state, Ids::owner()), + current_nonce(&state, Ids::user_stablecoin_holding()), + ], + repay, + ) + .unwrap(); + let witness_set = public_transaction::WitnessSet::for_message( + &message, + &[&Keys::owner(), &Keys::user_stablecoin_holding()], + ); + let tx = PublicTransaction::new(message, witness_set); + state + .transition_from_public_transaction(&tx, 0, 0) + .expect("repay_debt must succeed"); + + // Position debt decreased; collateral untouched. + let position = + Position::try_from(&state.get_account_by_id(Ids::position()).data).expect("valid Position"); + assert_eq!( + position.debt_amount, + Balances::initial_debt() - Balances::debt_repay_amount() + ); + assert_eq!(position.collateral_amount, Balances::collateral_deposit()); + + // Stablecoin total supply decreased by the burn amount. + let definition = + TokenDefinition::try_from(&state.get_account_by_id(Ids::stablecoin_definition()).data) + .expect("valid TokenDefinition"); + match definition { + TokenDefinition::Fungible { total_supply, .. } => { + assert_eq!( + total_supply, + Balances::stablecoin_supply_init() - Balances::debt_repay_amount() + ); + } + TokenDefinition::NonFungible { .. } => panic!("expected Fungible definition"), + } + + // User stablecoin holding decreased by the burn amount. + let holding = + TokenHolding::try_from(&state.get_account_by_id(Ids::user_stablecoin_holding()).data) + .expect("valid TokenHolding"); + match holding { + TokenHolding::Fungible { balance, .. } => { + assert_eq!( + balance, + Balances::user_stablecoin_holding_init() - Balances::debt_repay_amount() + ); + } + TokenHolding::NftMaster { .. } | TokenHolding::NftPrintedCopy { .. } => { + panic!("expected Fungible holding") + } + } +} diff --git a/stablecoin/core/src/lib.rs b/stablecoin/core/src/lib.rs index 34c7d4d..b5f51ac 100644 --- a/stablecoin/core/src/lib.rs +++ b/stablecoin/core/src/lib.rs @@ -50,6 +50,32 @@ pub enum Instruction { /// Amount of collateral tokens to move from the vault back to `destination`. amount: u128, }, + /// Repay `amount` of outstanding stablecoin debt against an existing position. + /// + /// Required accounts (4): + /// - Owner account (authorized; binds caller-as-owner via position PDA re-derivation) + /// - Position account (initialized, owned by `self_program_id`) + /// - Stablecoin token definition account (the definition of the stablecoin being repaid) + /// - User's stablecoin holding (authorized, initialized, owned by the same Token Program as + /// the definition, with `TokenHolding.definition_id == stablecoin_definition.account_id`) + /// + /// `token_program_id` is derived from `user_stablecoin_holding.account.program_owner`. + /// `collateral_definition_id` (for position PDA verification) is read from the + /// decoded [`Position`]. + /// + /// **Note:** until issue #97 (stability fee accrual) lands, this instruction does + /// not accrue fees before reducing debt. A `// TODO(#97)` comment in the host + /// function marks where the accrual code will plug in. Today every position has + /// `debt_amount = 0` (no `generate_debt` yet), so the precondition is vacuously met. + /// + /// **Note:** until issue #91 (`generate_debt`) records the stablecoin definition + /// into `Position`, this instruction cannot validate that the passed + /// `stablecoin_token_definition` is the one this position's debt is denominated + /// in. The caller is trusted for that until then. + RepayDebt { + /// Amount of stablecoin debt to repay (also the amount burned from the user's holding). + amount: u128, + }, } /// Persistent state held by a Stablecoin [`Position`] account. diff --git a/stablecoin/methods/guest/src/bin/stablecoin.rs b/stablecoin/methods/guest/src/bin/stablecoin.rs index 85e8908..f677ca3 100644 --- a/stablecoin/methods/guest/src/bin/stablecoin.rs +++ b/stablecoin/methods/guest/src/bin/stablecoin.rs @@ -73,4 +73,33 @@ mod stablecoin { chained_calls, )) } + + /// Repay `amount` of outstanding stablecoin debt against an existing position. + /// + /// # Errors + /// Returns the host program's panic-converted error if any precondition + /// fails (see [`stablecoin_program::repay_debt::repay_debt`] for the + /// full list). + #[instruction] + pub fn repay_debt( + ctx: ProgramContext, + owner: AccountWithMetadata, + position: AccountWithMetadata, + stablecoin_definition: AccountWithMetadata, + user_stablecoin_holding: AccountWithMetadata, + amount: u128, + ) -> SpelResult { + let (post_states, chained_calls) = stablecoin_program::repay_debt::repay_debt( + owner, + position, + stablecoin_definition, + user_stablecoin_holding, + ctx.self_program_id, + amount, + ); + Ok(spel_framework::SpelOutput::execute( + post_states, + chained_calls, + )) + } } diff --git a/stablecoin/src/lib.rs b/stablecoin/src/lib.rs index 12d3de4..690024e 100644 --- a/stablecoin/src/lib.rs +++ b/stablecoin/src/lib.rs @@ -5,6 +5,9 @@ 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; diff --git a/stablecoin/src/repay_debt.rs b/stablecoin/src/repay_debt.rs new file mode 100644 index 0000000..dfe72bd --- /dev/null +++ b/stablecoin/src/repay_debt.rs @@ -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, Vec) { + 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]) +} diff --git a/stablecoin/src/tests.rs b/stablecoin/src/tests.rs index 0bab094..41e0154 100644 --- a/stablecoin/src/tests.rs +++ b/stablecoin/src/tests.rs @@ -139,6 +139,41 @@ 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; @@ -698,3 +733,241 @@ fn withdraw_collateral_rejects_overdraw() { 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, + ); +}