Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 18 additions & 0 deletions artifacts/stablecoin-idl.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": [
Expand Down
12 changes: 12 additions & 0 deletions programs/integration_tests/tests/stablecoin.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
}

Expand Down Expand Up @@ -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()),
Expand Down
34 changes: 15 additions & 19 deletions programs/stablecoin/core/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
10 changes: 10 additions & 0 deletions programs/stablecoin/methods/guest/src/bin/stablecoin.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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)]
Expand All @@ -396,13 +400,19 @@ 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(
owner,
position,
stablecoin_definition,
user_stablecoin_holding,
stability_fee_accumulator,
protocol_parameters,
clock,
ctx.self_program_id,
amount,
);
Expand Down
79 changes: 63 additions & 16 deletions programs/stablecoin/src/repay_debt.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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.
Expand All @@ -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<AccountPostState>, Vec<ChainedCall>) {
Expand Down Expand Up @@ -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(),
Expand All @@ -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");
Comment on lines 146 to 149

let updated_position = Position {
Expand All @@ -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;
Expand Down
Loading
Loading