From 56f709dd523d0534374cb1db2b83f050b597b775 Mon Sep 17 00:00:00 2001 From: Andrea Franz Date: Mon, 7 Sep 2026 08:38:40 +0000 Subject: [PATCH] feat(stablecoin): rebuild repay_debt with floor-rounded debt deltas closes #179 --- artifacts/stablecoin-idl.json | 18 ++ .../integration_tests/tests/stablecoin.rs | 12 ++ programs/stablecoin/core/src/lib.rs | 34 ++-- .../methods/guest/src/bin/stablecoin.rs | 10 ++ programs/stablecoin/src/repay_debt.rs | 79 +++++++-- programs/stablecoin/src/tests.rs | 166 +++++++++++++++++- 6 files changed, 283 insertions(+), 36 deletions(-) diff --git a/artifacts/stablecoin-idl.json b/artifacts/stablecoin-idl.json index 1d53250c..93643019 100644 --- a/artifacts/stablecoin-idl.json +++ b/artifacts/stablecoin-idl.json @@ -455,6 +455,24 @@ "writable": true, "signer": true, "init": false + }, + { + "name": "stability_fee_accumulator", + "writable": false, + "signer": false, + "init": false + }, + { + "name": "protocol_parameters", + "writable": false, + "signer": false, + "init": false + }, + { + "name": "clock", + "writable": false, + "signer": false, + "init": false } ], "args": [ diff --git a/programs/integration_tests/tests/stablecoin.rs b/programs/integration_tests/tests/stablecoin.rs index 7fde05c5..2f2f9748 100644 --- a/programs/integration_tests/tests/stablecoin.rs +++ b/programs/integration_tests/tests/stablecoin.rs @@ -389,6 +389,15 @@ fn state_for_stablecoin_repay_tests() -> V03State { Ids::user_stablecoin_holding(), Accounts::user_stablecoin_holding_init(), ); + state.force_insert_account( + compute_protocol_parameters_pda(Ids::stablecoin_program()), + Accounts::protocol_parameters_init(), + ); + state.force_insert_account( + compute_stability_fee_accumulator_pda(Ids::stablecoin_program()), + Accounts::stability_fee_accumulator_init(), + ); + seed_clock(&mut state, OPEN_POSITION_NOW); state } @@ -551,6 +560,9 @@ fn stablecoin_repay_debt_burns_stablecoins_and_decreases_debt() { Ids::position(), Ids::stablecoin_definition(), Ids::user_stablecoin_holding(), + compute_stability_fee_accumulator_pda(Ids::stablecoin_program()), + compute_protocol_parameters_pda(Ids::stablecoin_program()), + CLOCK_01_PROGRAM_ACCOUNT_ID, ], vec![ current_nonce(&state, Ids::owner()), diff --git a/programs/stablecoin/core/src/lib.rs b/programs/stablecoin/core/src/lib.rs index b1d412d5..cc6c5310 100644 --- a/programs/stablecoin/core/src/lib.rs +++ b/programs/stablecoin/core/src/lib.rs @@ -239,27 +239,23 @@ pub enum Instruction { }, /// 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`) + /// Allowed while frozen — repaying only improves the protocol's position (§7). + /// The normalized-debt decrement is rounded **down** (§6.3), so debt shrinks + /// by at most what was burned. /// - /// `token_program_id` is derived from `user_stablecoin_holding.account.program_owner`. - /// `position_nonce` (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 - /// `normalized_debt_amount = 0` (no `generate_debt` yet), so the precondition - /// is vacuously met. + /// Required accounts (7), in order: + /// 1. `owner` — authorized; bound to the position via PDA re-derivation. + /// 2. `position` — initialized, writable, owned by `self_program_id`. + /// 3. `stablecoin_definition` — initialized, writable via the chained `Token::Burn`; must + /// equal `protocol_parameters.stablecoin_definition_id`. + /// 4. `user_stablecoin_holding` — authorized, initialized; same Token Program and definition + /// as `stablecoin_definition`. + /// 5. `stability_fee_accumulator` — initialized, read-only; at its canonical PDA. + /// 6. `protocol_parameters` — initialized, read-only; at its canonical PDA. + /// 7. `clock` — the system `CLOCK_01` account; read-only. /// - /// **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. + /// `token_program_id` is derived from `user_stablecoin_holding.account.program_owner`. + /// `position_nonce` (for position PDA verification) is read from the decoded [`Position`]. RepayDebt { /// Amount of stablecoin debt to repay (also the amount burned from the user's holding). amount: u128, diff --git a/programs/stablecoin/methods/guest/src/bin/stablecoin.rs b/programs/stablecoin/methods/guest/src/bin/stablecoin.rs index 0481cb56..7bc75852 100644 --- a/programs/stablecoin/methods/guest/src/bin/stablecoin.rs +++ b/programs/stablecoin/methods/guest/src/bin/stablecoin.rs @@ -386,6 +386,10 @@ mod stablecoin { /// fails (see [`stablecoin_program::repay_debt::repay_debt`] for the /// full list). #[instruction] + #[allow( + clippy::too_many_arguments, + reason = "the seven account inputs mirror the spec §10.8 ABI" + )] pub fn repay_debt( ctx: ProgramContext, #[account(signer)] @@ -396,6 +400,9 @@ mod stablecoin { stablecoin_definition: AccountWithMetadata, #[account(mut, signer)] user_stablecoin_holding: AccountWithMetadata, + stability_fee_accumulator: AccountWithMetadata, + protocol_parameters: AccountWithMetadata, + clock: AccountWithMetadata, amount: u128, ) -> SpelResult { let (post_states, chained_calls) = stablecoin_program::repay_debt::repay_debt( @@ -403,6 +410,9 @@ mod stablecoin { position, stablecoin_definition, user_stablecoin_holding, + stability_fee_accumulator, + protocol_parameters, + clock, ctx.self_program_id, amount, ); diff --git a/programs/stablecoin/src/repay_debt.rs b/programs/stablecoin/src/repay_debt.rs index d23efdf7..164e6151 100644 --- a/programs/stablecoin/src/repay_debt.rs +++ b/programs/stablecoin/src/repay_debt.rs @@ -2,7 +2,11 @@ use lee_core::{ account::{Account, AccountWithMetadata, Data}, program::{AccountPostState, ChainedCall, ProgramId}, }; -use stablecoin_core::{verify_position_and_get_seed, Position}; +use stablecoin_core::{ + compute_protocol_parameters_pda, compute_stability_fee_accumulator_pda, + math::{compute_current_accumulated_rate, mul_div, FIXED_POINT_ONE}, + verify_position_and_get_seed, Position, ProtocolParameters, StabilityFeeAccumulator, +}; use token_core::TokenHolding; /// Repay `amount` of outstanding stablecoin debt against an existing position. @@ -12,15 +16,14 @@ use token_core::TokenHolding; /// amount. The position post-state uses plain [`AccountPostState::new`] — the /// PDA was already claimed at `open_position` time. /// -/// Until #173 (stability fee accrual) lands, the fee-accrual step is a -/// no-op (every position structurally has `normalized_debt_amount = 0` today -/// because `generate_debt` is unimplemented; "fees-accrued" is therefore -/// vacuously true). A `// TODO(#173)` comment marks where the accrual code -/// will plug in — right before the `checked_sub` below. +/// The normalized-debt decrement is `⌊amount × FIXED_POINT_ONE / +/// current_accumulator⌋`, rounded **down** per §6.3, so the position's debt +/// shrinks by at most what was burned and the rounding remainder stays with the +/// protocol. The accumulator is projected forward to the clock timestamp (§5.3). /// -/// Until #173 (`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. +/// Allowed while the protocol is frozen — repaying only improves the protocol's +/// position (§7). `stablecoin_definition` is pinned against +/// `ProtocolParameters.stablecoin_definition_id`. /// /// # Panics /// - `owner` is not authorized. @@ -30,13 +33,24 @@ use token_core::TokenHolding; /// - `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.normalized_debt_amount`. +/// - `stablecoin_definition` is uninitialized, or does not match +/// `protocol_parameters.stablecoin_definition_id`. +/// - `protocol_parameters` or `stability_fee_accumulator` is uninitialized, wrongly owned, not at +/// its canonical PDA, or does not decode. +/// - `clock` is not the initialized system `CLOCK_01` account. +/// - The floored decrement exceeds `Position.normalized_debt_amount`. +#[allow( + clippy::too_many_arguments, + reason = "the seven account inputs mirror the spec §10.8 ABI" +)] pub fn repay_debt( owner: AccountWithMetadata, position: AccountWithMetadata, stablecoin_definition: AccountWithMetadata, user_stablecoin_holding: AccountWithMetadata, + stability_fee_accumulator: AccountWithMetadata, + protocol_parameters: AccountWithMetadata, + clock: AccountWithMetadata, stablecoin_program_id: ProgramId, amount: u128, ) -> (Vec, Vec) { @@ -73,6 +87,21 @@ pub fn repay_debt( user_stablecoin_holding.is_authorized, "User stablecoin holding authorization is missing" ); + + let parameters = ProtocolParameters::try_from(&crate::checks::decode_global( + &protocol_parameters, + compute_protocol_parameters_pda(stablecoin_program_id), + stablecoin_program_id, + "ProtocolParameters", + )) + .expect("ProtocolParameters must decode"); + // `is_frozen` is deliberately not read: repaying only improves the protocol's + // position, so spec §7 keeps it available while frozen. + assert_eq!( + stablecoin_definition.account_id, parameters.stablecoin_definition_id, + "Stablecoin definition does not match the one bound at initialize_program" + ); + assert_ne!( user_stablecoin_holding.account, Account::default(), @@ -95,13 +124,28 @@ pub fn repay_debt( "Stablecoin holding does not match the provided stablecoin definition" ); - // TODO(#173): accrue stability fees onto position_data.normalized_debt_amount - // here, before the checked_sub below. Today every position has - // normalized_debt_amount = 0 (no generate_debt yet), so the precondition is - // trivially met. + let accumulator = StabilityFeeAccumulator::try_from(&crate::checks::decode_global( + &stability_fee_accumulator, + compute_stability_fee_accumulator_pda(stablecoin_program_id), + stablecoin_program_id, + "StabilityFeeAccumulator", + )) + .expect("StabilityFeeAccumulator must decode"); + + let now = crate::accrue_stability_fee::read_clock(&clock); + let current_accumulator = compute_current_accumulated_rate( + accumulator.accumulated_rate_at_last_accrual, + parameters.stability_fee_per_millisecond, + accumulator.last_accrued_at, + now, + ); + + // Round DOWN (§6.3): the borrower burned exactly `amount`, and their debt + // shrinks by at most that much. The remainder is fee credit for the protocol. + let debt_delta = mul_div(amount, FIXED_POINT_ONE, current_accumulator); let new_debt = position_data .normalized_debt_amount - .checked_sub(amount) + .checked_sub(debt_delta) .expect("Repay amount exceeds outstanding debt"); let updated_position = Position { @@ -120,6 +164,9 @@ pub fn repay_debt( AccountPostState::new(position_post), AccountPostState::new(stablecoin_definition.account.clone()), AccountPostState::new(user_stablecoin_holding.account.clone()), + AccountPostState::new(stability_fee_accumulator.account), + AccountPostState::new(protocol_parameters.account), + AccountPostState::new(clock.account), ]; let token_program_id = user_stablecoin_holding.account.program_owner; diff --git a/programs/stablecoin/src/tests.rs b/programs/stablecoin/src/tests.rs index 1f96d3d3..0a7faafd 100644 --- a/programs/stablecoin/src/tests.rs +++ b/programs/stablecoin/src/tests.rs @@ -1188,6 +1188,131 @@ fn generate_debt_rejects_holding_for_another_definition() { ); } +// --- repay_debt: fee-aware rebuild (spec §10.8) --- + +fn repay( + position: AccountWithMetadata, + definition: AccountWithMetadata, + accumulator: AccountWithMetadata, + parameters: AccountWithMetadata, + amount: u128, +) -> (Vec, Vec) { + crate::repay_debt::repay_debt( + owner_account(), + position, + definition, + user_stablecoin_holding_account(1_000), + accumulator, + parameters, + clock_account(NOW), + STABLECOIN_PROGRAM_ID, + amount, + ) +} + +#[test] +fn repay_debt_echoes_the_three_new_accounts() { + let (post_states, _) = repay( + init_position_account(1_000, 300), + stablecoin_definition_account(), + crate::test_support::accumulator_account(FIXED_POINT_ONE, NOW), + protocol_parameters_account(false), + 100, + ); + + assert_eq!(post_states.len(), 7); + assert_eq!(*post_states[6].account(), clock_account(NOW).account); +} + +#[test] +fn repay_debt_rounds_the_normalized_delta_down() { + // accumulator 3.0 → 100 / 3 = 33.33…, floored to 33 (§6.3), so the debt + // shrinks by slightly less than paid and the remainder favours the protocol. + let (post_states, _) = repay( + init_position_account(1_000, 300), + stablecoin_definition_account(), + crate::test_support::accumulator_account(FIXED_POINT_ONE * 3, NOW), + protocol_parameters_account(false), + 100, + ); + + let position = Position::try_from(&post_states[1].account().data).expect("valid Position"); + assert_eq!(position.normalized_debt_amount, 300 - 33); +} + +#[test] +fn repay_debt_is_allowed_while_frozen() { + // Repaying only improves the protocol's position, so §7 keeps it open. + let (post_states, chained_calls) = repay( + init_position_account(1_000, 300), + stablecoin_definition_account(), + crate::test_support::accumulator_account(FIXED_POINT_ONE, NOW), + protocol_parameters_account(true), + 100, + ); + + assert_eq!(chained_calls.len(), 1); + let position = Position::try_from(&post_states[1].account().data).expect("valid Position"); + assert_eq!(position.normalized_debt_amount, 200); +} + +#[test] +#[should_panic( + expected = "Stablecoin definition does not match the one bound at initialize_program" +)] +fn repay_debt_rejects_an_unbound_stablecoin_definition() { + // The Plan 1 scaffold trusted the caller here; ProtocolParameters now pins it. + let mut definition = stablecoin_definition_account(); + definition.account_id = AccountId::new([0x88u8; 32]); + repay( + init_position_account(1_000, 300), + definition, + crate::test_support::accumulator_account(FIXED_POINT_ONE, NOW), + protocol_parameters_account(false), + 100, + ); +} + +#[test] +#[should_panic(expected = "Repay amount exceeds outstanding debt")] +fn repay_debt_rejects_overrepay_against_the_floored_delta() { + repay( + init_position_account(1_000, 10), + stablecoin_definition_account(), + crate::test_support::accumulator_account(FIXED_POINT_ONE, NOW), + protocol_parameters_account(false), + 11, + ); +} + +#[test] +#[should_panic(expected = "ProtocolParameters account must be initialized")] +fn repay_debt_rejects_uninitialized_protocol_parameters() { + repay( + init_position_account(1_000, 300), + stablecoin_definition_account(), + crate::test_support::accumulator_account(FIXED_POINT_ONE, NOW), + AccountWithMetadata { + account: Account::default(), + is_authorized: false, + account_id: protocol_parameters_id(), + }, + 100, + ); +} + +#[test] +#[should_panic(expected = "StabilityFeeAccumulator account must be initialized")] +fn repay_debt_rejects_uninitialized_accumulator() { + repay( + init_position_account(1_000, 300), + stablecoin_definition_account(), + crate::test_support::uninitialized(crate::test_support::accumulator_id()), + protocol_parameters_account(false), + 100, + ); +} + #[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); @@ -1650,11 +1775,14 @@ fn repay_debt_decreases_debt_and_emits_burn() { init_position_account(initial_collateral, initial_debt), stablecoin_definition_account(), user_stablecoin_holding_account(holding_balance), + crate::test_support::accumulator_account(FIXED_POINT_ONE, NOW), + protocol_parameters_account(false), + clock_account(NOW), STABLECOIN_PROGRAM_ID, amount, ); - assert_eq!(post_states.len(), 4); + assert_eq!(post_states.len(), 7); // Position post-state: plain `new`, holds the decremented Position. let position_post = &post_states[1]; @@ -1706,6 +1834,9 @@ fn repay_debt_allows_full_repayment() { init_position_account(500, debt), stablecoin_definition_account(), user_stablecoin_holding_account(1_000), + crate::test_support::accumulator_account(FIXED_POINT_ONE, NOW), + protocol_parameters_account(false), + clock_account(NOW), STABLECOIN_PROGRAM_ID, debt, ); @@ -1722,6 +1853,9 @@ fn repay_debt_allows_zero_amount() { init_position_account(500, initial_debt), stablecoin_definition_account(), user_stablecoin_holding_account(1_000), + crate::test_support::accumulator_account(FIXED_POINT_ONE, NOW), + protocol_parameters_account(false), + clock_account(NOW), STABLECOIN_PROGRAM_ID, 0, ); @@ -1749,6 +1883,9 @@ fn repay_debt_requires_owner_authorization() { init_position_account(500, 300), stablecoin_definition_account(), user_stablecoin_holding_account(1_000), + crate::test_support::accumulator_account(FIXED_POINT_ONE, NOW), + protocol_parameters_account(false), + clock_account(NOW), STABLECOIN_PROGRAM_ID, 100, ); @@ -1762,6 +1899,9 @@ fn repay_debt_rejects_uninitialized_position() { uninit_position_account(), stablecoin_definition_account(), user_stablecoin_holding_account(1_000), + crate::test_support::accumulator_account(FIXED_POINT_ONE, NOW), + protocol_parameters_account(false), + clock_account(NOW), STABLECOIN_PROGRAM_ID, 100, ); @@ -1777,6 +1917,9 @@ fn repay_debt_rejects_position_owned_by_other_program() { position, stablecoin_definition_account(), user_stablecoin_holding_account(1_000), + crate::test_support::accumulator_account(FIXED_POINT_ONE, NOW), + protocol_parameters_account(false), + clock_account(NOW), STABLECOIN_PROGRAM_ID, 100, ); @@ -1792,6 +1935,9 @@ fn repay_debt_rejects_wrong_position_address() { position, stablecoin_definition_account(), user_stablecoin_holding_account(1_000), + crate::test_support::accumulator_account(FIXED_POINT_ONE, NOW), + protocol_parameters_account(false), + clock_account(NOW), STABLECOIN_PROGRAM_ID, 100, ); @@ -1807,6 +1953,9 @@ fn repay_debt_requires_user_holding_authorization() { init_position_account(500, 300), stablecoin_definition_account(), holding, + crate::test_support::accumulator_account(FIXED_POINT_ONE, NOW), + protocol_parameters_account(false), + clock_account(NOW), STABLECOIN_PROGRAM_ID, 100, ); @@ -1825,6 +1974,9 @@ fn repay_debt_rejects_uninitialized_user_holding() { init_position_account(500, 300), stablecoin_definition_account(), holding, + crate::test_support::accumulator_account(FIXED_POINT_ONE, NOW), + protocol_parameters_account(false), + clock_account(NOW), STABLECOIN_PROGRAM_ID, 100, ); @@ -1842,6 +1994,9 @@ fn repay_debt_rejects_holding_with_different_token_program() { init_position_account(500, 300), stablecoin_definition_account(), holding, + crate::test_support::accumulator_account(FIXED_POINT_ONE, NOW), + protocol_parameters_account(false), + clock_account(NOW), STABLECOIN_PROGRAM_ID, 100, ); @@ -1860,6 +2015,9 @@ fn repay_debt_rejects_holding_for_other_definition() { init_position_account(500, 300), stablecoin_definition_account(), holding, + crate::test_support::accumulator_account(FIXED_POINT_ONE, NOW), + protocol_parameters_account(false), + clock_account(NOW), STABLECOIN_PROGRAM_ID, 100, ); @@ -1873,6 +2031,9 @@ fn repay_debt_rejects_overrepay() { init_position_account(500, 100), stablecoin_definition_account(), user_stablecoin_holding_account(1_000), + crate::test_support::accumulator_account(FIXED_POINT_ONE, NOW), + protocol_parameters_account(false), + clock_account(NOW), STABLECOIN_PROGRAM_ID, 200, ); @@ -1934,6 +2095,9 @@ fn repay_debt_rejects_position_with_stale_owner_field() { }), stablecoin_definition_account(), user_stablecoin_holding_account(1_000), + crate::test_support::accumulator_account(FIXED_POINT_ONE, NOW), + protocol_parameters_account(false), + clock_account(NOW), STABLECOIN_PROGRAM_ID, 100, );