Skip to content

[Plan 3 · 04] withdraw_collateral rebuild #177

Description

@gravityblast

Issue 04 — withdraw_collateral rebuild

Plan: Stablecoin Plan 3 — Position Lifecycle
Depends on: Plan 1 (whole plan); Plan 3 issue 01 (collateralization helper); Plan 3 issue 02 (test fixtures for the new Position shape).
Blocks: 08 (lifecycle integration test).
Label suggestion: breaking — guest signature + integration test fixture both change.

Goal: Replace the Plan 1 scaffold withdraw_collateral (which hard-asserts debt_amount == 0) with the spec §10.6 version: 7-account input list, reads the global accumulator + redemption-price state, projects both forward to now via compound_rate, and invokes the Plan 3 issue 01 collateralization helper on the post-decrement position. Frozen ⇒ panic.

Spec reference

Why

The Plan 1 scaffold's Position.debt_amount == 0 guard was a placeholder marker. With Plan 1's ProtocolParameters / StabilityFeeAccumulator / RedemptionPriceState landed and the collateralization helper from Plan 3 issue 01 in place, all the pieces exist to make withdraw_collateral correct against the spec.

The spec's design splits the accumulator + redemption-price reads as separate inputs (rather than letting the host function compute them via a chained "fetch projected state" call) because they're constant-time hot-path reads — the alternative would require either Plan 2's pokes to have rolled the state forward (we don't want to require that for a withdrawal), or a chained call into a Stablecoin::ProjectState instruction (over-engineered).

Architecture

  • New host-function signature with the 7-account list from spec §10.6.
  • Add pub fn current_accumulated_rate(state: &StabilityFeeAccumulator, params: &ProtocolParameters, now: u64) -> u128 and pub fn current_redemption_price(state: &RedemptionPriceState, now: u64) -> u128 to stablecoin_core::math (or wherever Plan 2 placed them — Plan 2's accrue_stability_fee plan introduces them, so coordinate with whoever lands Plan 2). If Plan 2 hasn't landed and the helpers don't exist yet, this issue adds them.
  • Call the collateralization helper from Plan 3 issue 01.
  • Frozen check up-front; everything else mirrors the scaffold (vault PDA seed in the chained Transfer).

Files

  • Modify: programs/stablecoin/src/withdraw_collateral.rs — full rewrite.
  • Modify: programs/stablecoin/core/src/lib.rs — update Instruction::WithdrawCollateral doc comment (no field changes, just docs).
  • Modify: programs/stablecoin/core/src/math.rs (or wherever Plan 1 placed the math module) — add the two §5.3 projection helpers if not already added by Plan 2.
  • Modify: programs/stablecoin/methods/guest/src/bin/stablecoin.rs — guest entrypoint adds the four extra accounts + ctx.now.
  • Modify: programs/stablecoin/src/tests.rs — replace the scaffold tests; add the new ones.
  • Regenerate: artifacts/stablecoin-idl.json via make idl.

Acceptance criteria

  • cargo test -p stablecoin_program tests::withdraw_collateral_ passes (new test list — 12+ tests).
  • make clippy is green.
  • make idl regenerates the IDL.
  • Happy-path with zero debt produces 7 post-states and 1 chained call (matching the scaffold's shape, just with two more passthrough accounts).
  • Happy-path with debt but plenty of collateral passes; happy-path with debt right at the boundary passes.
  • Withdrawal that would push the position under the ratio panics with "Position is undercollateralized".
  • Frozen-state test panics with "Protocol is frozen".

Implementation steps

  • Step 1: Add (or confirm) the §5.3 projection helpers

In programs/stablecoin/core/src/math.rs (added in Plan 1 issue 01), check whether Plan 2 already added current_accumulated_rate / current_redemption_price. If not, append:

use crate::{ProtocolParameters, RedemptionPriceState, StabilityFeeAccumulator};

/// Spec §5.3: project the stability-fee accumulator forward to `now`.
#[must_use]
pub fn current_accumulated_rate(
    state: &StabilityFeeAccumulator,
    params: &ProtocolParameters,
    now: u64,
) -> u128 {
    // Clamp dt to MAXIMUM_COMPOUNDING_WINDOW_MILLISECONDS (spec §5.3): over an
    // unbounded window any rate > 1.0 eventually overflows compound_rate, so the
    // cap is load-bearing — it is NOT "overflow impossible" from the rate bound alone.
    let dt = now
        .saturating_sub(state.last_accrued_at)
        .min(MAXIMUM_COMPOUNDING_WINDOW_MILLISECONDS);
    let factor = compound_rate(params.stability_fee_per_millisecond, dt);
    mul_div(state.accumulated_rate_at_last_accrual, factor, FIXED_POINT_ONE)
}

/// Spec §5.3: project the redemption price forward to `now`.
#[must_use]
pub fn current_redemption_price(state: &RedemptionPriceState, now: u64) -> u128 {
    // Same clamp rationale as above — redemption_rate_per_millisecond can also exceed 1.0.
    let dt = now
        .saturating_sub(state.last_updated_at)
        .min(MAXIMUM_COMPOUNDING_WINDOW_MILLISECONDS);
    let factor = compound_rate(state.redemption_rate_per_millisecond, dt);
    mul_div(state.redemption_price_at_last_update, factor, FIXED_POINT_ONE)
}

Add to the existing #[cfg(test)] mod tests in math.rs:

    #[test]
    fn current_accumulated_rate_at_zero_dt_returns_anchor() {
        let state = StabilityFeeAccumulator {
            accumulated_rate_at_last_accrual: FIXED_POINT_ONE * 2,
            last_accrued_at: 100,
        };
        let params = ProtocolParameters {
            admin_account_id: nssa_core::account::AccountId::new([0; 32]),
            freeze_authority_account_id: nssa_core::account::AccountId::new([0; 32]),
            stablecoin_definition_id: nssa_core::account::AccountId::new([0; 32]),
            collateral_definition_id: nssa_core::account::AccountId::new([0; 32]),
            market_price_oracle_id: nssa_core::account::AccountId::new([0; 32]),
            stability_fee_per_millisecond: FIXED_POINT_ONE * 105 / 100,
            controller_proportional_gain: 0,
            controller_integral_gain: 0,
            minimum_collateralization_ratio: FIXED_POINT_ONE * 3 / 2,
            minimum_milliseconds_between_rate_updates: 1,
            maximum_oracle_price_age_milliseconds: 86_400,
            is_frozen: false,
        };
        assert_eq!(current_accumulated_rate(&state, &params, 100), FIXED_POINT_ONE * 2);
    }

    #[test]
    fn current_redemption_price_at_zero_dt_returns_anchor() {
        let state = RedemptionPriceState {
            redemption_price_at_last_update: FIXED_POINT_ONE / 2,
            redemption_rate_per_millisecond: FIXED_POINT_ONE,
            controller_integral_term: 0,
            last_updated_at: 50,
        };
        assert_eq!(current_redemption_price(&state, 50), FIXED_POINT_ONE / 2);
    }

Run cargo test -p stablecoin_core math::tests. Expected: green.

(If Plan 2 already added the helpers, just skip this step — note the coordination in the PR description.)

  • Step 2: Rewrite the host function

Replace the entire contents of programs/stablecoin/src/withdraw_collateral.rs with:

use nssa_core::{
    account::{Account, AccountWithMetadata, Data},
    program::{AccountPostState, ChainedCall, ProgramId},
};
use stablecoin_core::{
    math::{current_accumulated_rate, current_redemption_price},
    verify_position_and_get_seed, verify_position_vault_and_get_seed, Position,
    ProtocolParameters, RedemptionPriceState, StabilityFeeAccumulator,
};
use token_core::TokenHolding;

use crate::checks::assert_position_is_collateralized;

/// Withdraw `amount` collateral tokens from `position`'s vault back to
/// `user_collateral_holding`.
///
/// Reads the global `StabilityFeeAccumulator` and `RedemptionPriceState` to
/// project the current nominal debt and the current redemption price, then
/// asserts (spec §6.2) that the position remains collateralized after the
/// decrement.
///
/// # Panics
/// - `owner.is_authorized = false`.
/// - `position` uninitialized, not owned by `stablecoin_program_id`, or PDA mismatch.
/// - `protocol_parameters` / `stability_fee_accumulator` / `redemption_price_state`
///   uninitialized or fail to decode.
/// - `protocol_parameters.is_frozen = true` — message `"Protocol is frozen"`.
/// - `vault.account_id != position.vault_account_id`, or vault holding's
///   `definition_id` mismatches `protocol_parameters.collateral_definition_id`.
/// - `user_collateral_holding` uninitialized, has a different Token Program from the
///   vault, or holds a `TokenHolding::Fungible` for a different definition.
/// - `amount > position.collateral_amount` — message `"Withdrawal amount exceeds position collateral"`.
/// - Collateralization check fails post-decrement — message `"Position is undercollateralized"`.
#[allow(clippy::too_many_arguments)]
pub fn withdraw_collateral(
    owner: AccountWithMetadata,
    position: AccountWithMetadata,
    vault: AccountWithMetadata,
    user_collateral_holding: AccountWithMetadata,
    stability_fee_accumulator: AccountWithMetadata,
    redemption_price_state: AccountWithMetadata,
    protocol_parameters: AccountWithMetadata,
    stablecoin_program_id: ProgramId,
    now: u64,
    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");

    let _position_seed = verify_position_and_get_seed(
        &position,
        &owner,
        position_data.position_nonce,
        stablecoin_program_id,
    );
    let vault_seed =
        verify_position_vault_and_get_seed(&vault, position.account_id, stablecoin_program_id);

    // Decode the three globals.
    assert_ne!(
        protocol_parameters.account,
        Account::default(),
        "ProtocolParameters account must be initialized"
    );
    let params = ProtocolParameters::try_from(&protocol_parameters.account.data)
        .expect("ProtocolParameters must decode");
    assert!(!params.is_frozen, "Protocol is frozen");

    assert_ne!(
        stability_fee_accumulator.account,
        Account::default(),
        "StabilityFeeAccumulator account must be initialized"
    );
    let accumulator_state =
        StabilityFeeAccumulator::try_from(&stability_fee_accumulator.account.data)
            .expect("StabilityFeeAccumulator must decode");

    assert_ne!(
        redemption_price_state.account,
        Account::default(),
        "RedemptionPriceState account must be initialized"
    );
    let redemption_state = RedemptionPriceState::try_from(&redemption_price_state.account.data)
        .expect("RedemptionPriceState must decode");

    // Vault address must match the position.
    assert_eq!(
        vault.account_id, position_data.vault_account_id,
        "Vault account does not match Position.vault_account_id"
    );

    // Vault holding shape matches the global collateral definition.
    let vault_holding = TokenHolding::try_from(&vault.account.data)
        .expect("Vault account must hold a valid TokenHolding");
    assert_eq!(
        vault_holding.definition_id(),
        params.collateral_definition_id,
        "Vault token holding is not for the global collateral definition"
    );

    // Destination shape.
    let token_program_id = vault.account.program_owner;
    assert_ne!(
        user_collateral_holding.account,
        Account::default(),
        "Destination must be initialized"
    );
    assert_eq!(
        user_collateral_holding.account.program_owner, token_program_id,
        "Destination must be owned by the same Token Program as the vault"
    );
    let destination_holding = TokenHolding::try_from(&user_collateral_holding.account.data)
        .expect("Destination account must hold a valid TokenHolding");
    assert_eq!(
        destination_holding.definition_id(),
        params.collateral_definition_id,
        "Destination token definition does not match the global collateral definition"
    );

    // Apply the decrement.
    let new_collateral = position_data
        .collateral_amount
        .checked_sub(amount)
        .expect("Withdrawal amount exceeds position collateral");

    let updated_position = Position {
        collateral_amount: new_collateral,
        ..position_data
    };

    // Collateralization check on the post-decrement position.
    let accumulator = current_accumulated_rate(&accumulator_state, &params, now);
    let redemption = current_redemption_price(&redemption_state, now);
    assert_position_is_collateralized(
        &updated_position,
        accumulator,
        redemption,
        params.minimum_collateralization_ratio,
    );

    let mut position_post = position.account.clone();
    position_post.data = Data::from(&updated_position);

    let post_states = vec![
        AccountPostState::new(owner.account.clone()),
        AccountPostState::new(position_post),
        AccountPostState::new(vault.account.clone()),
        AccountPostState::new(user_collateral_holding.account.clone()),
        AccountPostState::new(stability_fee_accumulator.account.clone()),
        AccountPostState::new(redemption_price_state.account.clone()),
        AccountPostState::new(protocol_parameters.account.clone()),
    ];

    let mut vault_authorized = vault.clone();
    vault_authorized.is_authorized = true;
    let transfer_call = ChainedCall::new(
        token_program_id,
        vec![vault_authorized, user_collateral_holding],
        &token_core::Instruction::Transfer {
            amount_to_transfer: amount,
        },
    )
    .with_pda_seeds(vec![vault_seed]);

    (post_states, vec![transfer_call])
}

Run cargo check -p stablecoin_program. Expected: clean (existing tests will fail to compile — Step 4 rewrites them).

  • Step 3: Update the guest entrypoint

In programs/stablecoin/methods/guest/src/bin/stablecoin.rs, replace the existing withdraw_collateral #[instruction] block with:

    /// Withdraw `amount` collateral tokens from an existing position back to the
    /// user's collateral holding, subject to the collateralization check.
    ///
    /// # Errors
    /// Returns the host program's panic-converted error if any precondition fails
    /// (see [`stablecoin_program::withdraw_collateral::withdraw_collateral`] for
    /// the full list).
    #[instruction]
    pub fn withdraw_collateral(
        ctx: ProgramContext,
        owner: AccountWithMetadata,
        position: AccountWithMetadata,
        vault: AccountWithMetadata,
        user_collateral_holding: AccountWithMetadata,
        stability_fee_accumulator: AccountWithMetadata,
        redemption_price_state: AccountWithMetadata,
        protocol_parameters: AccountWithMetadata,
        amount: u128,
    ) -> SpelResult {
        let (post_states, chained_calls) =
            stablecoin_program::withdraw_collateral::withdraw_collateral(
                owner,
                position,
                vault,
                user_collateral_holding,
                stability_fee_accumulator,
                redemption_price_state,
                protocol_parameters,
                ctx.self_program_id,
                ctx.now,
                amount,
            );
        Ok(spel_framework::SpelOutput::execute(
            post_states,
            chained_calls,
        ))
    }
  • Step 4: Add fixture helpers for the two new globals

In programs/stablecoin/src/tests.rs, append (just above the first existing withdraw_collateral_* test):

use stablecoin_core::{
    compute_redemption_price_state_pda, compute_stability_fee_accumulator_pda,
    RedemptionPriceState, StabilityFeeAccumulator,
};

fn stability_fee_accumulator_id() -> AccountId {
    compute_stability_fee_accumulator_pda(STABLECOIN_PROGRAM_ID)
}

fn redemption_price_state_id() -> AccountId {
    compute_redemption_price_state_pda(STABLECOIN_PROGRAM_ID)
}

fn stability_fee_accumulator_account(
    accumulator: u128,
    last_accrued_at: u64,
) -> AccountWithMetadata {
    AccountWithMetadata {
        account: Account {
            program_owner: STABLECOIN_PROGRAM_ID,
            balance: 0,
            data: Data::from(&StabilityFeeAccumulator {
                accumulated_rate_at_last_accrual: accumulator,
                last_accrued_at,
            }),
            nonce: Nonce(0),
        },
        is_authorized: false,
        account_id: stability_fee_accumulator_id(),
    }
}

fn redemption_price_state_account(price: u128, last_updated_at: u64) -> AccountWithMetadata {
    AccountWithMetadata {
        account: Account {
            program_owner: STABLECOIN_PROGRAM_ID,
            balance: 0,
            data: Data::from(&RedemptionPriceState {
                redemption_price_at_last_update: price,
                redemption_rate_per_millisecond: stablecoin_core::math::FIXED_POINT_ONE,
                controller_integral_term: 0,
                last_updated_at,
            }),
            nonce: Nonce(0),
        },
        is_authorized: false,
        account_id: redemption_price_state_id(),
    }
}
  • Step 5: Rewrite the happy-path test (zero debt)

Replace the existing withdraw_collateral_updates_position_and_emits_transfer test with:

#[test]
fn withdraw_collateral_updates_position_and_emits_transfer() {
    use stablecoin_core::math::FIXED_POINT_ONE;
    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(),
        stability_fee_accumulator_account(FIXED_POINT_ONE, NOW),
        redemption_price_state_account(FIXED_POINT_ONE, NOW),
        protocol_parameters_account(false),
        STABLECOIN_PROGRAM_ID,
        NOW,
        amount,
    );

    assert_eq!(post_states.len(), 7);
    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.collateral_amount, initial_collateral - amount);
    assert_eq!(position.normalized_debt_amount, 0);

    assert_eq!(chained_calls.len(), 1);
    let mut vault_authorized = init_vault_account();
    vault_authorized.is_authorized = true;
    let expected = 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);
}

Run. Expected: 1 PASS.

  • Step 6: Add the with-debt happy-path tests
#[test]
fn withdraw_collateral_with_debt_succeeds_when_collateralization_holds() {
    use stablecoin_core::math::FIXED_POINT_ONE;
    // collateral 500, normalized_debt 100, accumulator = 1.0, redemption = 0.5, ratio = 1.5x
    // → nominal_debt = 100, required = 100 * 0.5 * 1.5 = 75 → plenty of slack.
    // Withdrawing 100 → remaining 400 collateral, still way above 75.
    let (post_states, _) = crate::withdraw_collateral::withdraw_collateral(
        owner_account(),
        init_position_account(500, 100),
        init_vault_account(),
        destination_holding_account(),
        stability_fee_accumulator_account(FIXED_POINT_ONE, NOW),
        redemption_price_state_account(FIXED_POINT_ONE / 2, NOW),
        protocol_parameters_account(false),
        STABLECOIN_PROGRAM_ID,
        NOW,
        100,
    );
    let position = Position::try_from(&post_states[1].account().data).expect("valid Position");
    assert_eq!(position.collateral_amount, 400);
}

#[test]
fn withdraw_collateral_at_exact_ratio_boundary_succeeds() {
    use stablecoin_core::math::FIXED_POINT_ONE;
    // Same params; nominal_debt = 100, required = 75. Withdraw down to exactly 75.
    let (post_states, _) = crate::withdraw_collateral::withdraw_collateral(
        owner_account(),
        init_position_account(500, 100),
        init_vault_account(),
        destination_holding_account(),
        stability_fee_accumulator_account(FIXED_POINT_ONE, NOW),
        redemption_price_state_account(FIXED_POINT_ONE / 2, NOW),
        protocol_parameters_account(false),
        STABLECOIN_PROGRAM_ID,
        NOW,
        425,
    );
    let position = Position::try_from(&post_states[1].account().data).expect("valid Position");
    assert_eq!(position.collateral_amount, 75);
}

Run cargo test -p stablecoin_program tests::withdraw_collateral_with_debt. Expected: 2 PASS.

  • Step 7: Add the undercollateralized-fail test
#[test]
#[should_panic(expected = "Position is undercollateralized")]
fn withdraw_collateral_fails_when_below_ratio() {
    use stablecoin_core::math::FIXED_POINT_ONE;
    // Same params; required collateral = 75; withdraw down to 74 → fail.
    let _ = crate::withdraw_collateral::withdraw_collateral(
        owner_account(),
        init_position_account(500, 100),
        init_vault_account(),
        destination_holding_account(),
        stability_fee_accumulator_account(FIXED_POINT_ONE, NOW),
        redemption_price_state_account(FIXED_POINT_ONE / 2, NOW),
        protocol_parameters_account(false),
        STABLECOIN_PROGRAM_ID,
        NOW,
        426,
    );
}

Run. Expected: 1 PASS.

  • Step 8: Frozen + zero-amount + overdraw tests
#[test]
#[should_panic(expected = "Protocol is frozen")]
fn withdraw_collateral_rejects_when_frozen() {
    use stablecoin_core::math::FIXED_POINT_ONE;
    let _ = crate::withdraw_collateral::withdraw_collateral(
        owner_account(),
        init_position_account(500, 0),
        init_vault_account(),
        destination_holding_account(),
        stability_fee_accumulator_account(FIXED_POINT_ONE, NOW),
        redemption_price_state_account(FIXED_POINT_ONE, NOW),
        protocol_parameters_account(true),
        STABLECOIN_PROGRAM_ID,
        NOW,
        100,
    );
}

#[test]
fn withdraw_collateral_allows_zero_amount() {
    use stablecoin_core::math::FIXED_POINT_ONE;
    let (post_states, chained_calls) = crate::withdraw_collateral::withdraw_collateral(
        owner_account(),
        init_position_account(500, 0),
        init_vault_account(),
        destination_holding_account(),
        stability_fee_accumulator_account(FIXED_POINT_ONE, NOW),
        redemption_price_state_account(FIXED_POINT_ONE, NOW),
        protocol_parameters_account(false),
        STABLECOIN_PROGRAM_ID,
        NOW,
        0,
    );
    let position = Position::try_from(&post_states[1].account().data).expect("valid Position");
    assert_eq!(position.collateral_amount, 500);
    assert_eq!(chained_calls.len(), 1);
}

#[test]
#[should_panic(expected = "Withdrawal amount exceeds position collateral")]
fn withdraw_collateral_rejects_overdraw() {
    use stablecoin_core::math::FIXED_POINT_ONE;
    let _ = crate::withdraw_collateral::withdraw_collateral(
        owner_account(),
        init_position_account(100, 0),
        init_vault_account(),
        destination_holding_account(),
        stability_fee_accumulator_account(FIXED_POINT_ONE, NOW),
        redemption_price_state_account(FIXED_POINT_ONE, NOW),
        protocol_parameters_account(false),
        STABLECOIN_PROGRAM_ID,
        NOW,
        200,
    );
}

Run cargo test -p stablecoin_program tests::withdraw_collateral_. Expected: 7 PASS so far.

  • Step 9: Re-add the structural / mismatch panic tests

The Plan 1 scaffold's tests for owner_authorization, uninitialized_position, position_owned_by_other_program, wrong_position_address, wrong_vault_address, vault_for_other_definition, uninitialized_destination, destination_with_wrong_token_program, destination_for_other_definition should be carried forward and updated to pass the three new globals. Replace each one with the same scaffold but adding:

        stability_fee_accumulator_account(stablecoin_core::math::FIXED_POINT_ONE, NOW),
        redemption_price_state_account(stablecoin_core::math::FIXED_POINT_ONE, NOW),
        protocol_parameters_account(false),
        STABLECOIN_PROGRAM_ID,
        NOW,

just before the final amount arg in each call site. The expected panic message strings are unchanged except "Vault token holding is not for the position's collateral definition""Vault token holding is not for the global collateral definition", and "Destination token definition does not match the position's collateral definition""Destination token definition does not match the global collateral definition".

Also add:

#[test]
#[should_panic(expected = "ProtocolParameters account must be initialized")]
fn withdraw_collateral_rejects_uninitialized_protocol_parameters() {
    use stablecoin_core::math::FIXED_POINT_ONE;
    let pp = AccountWithMetadata {
        account: Account::default(),
        is_authorized: false,
        account_id: protocol_parameters_id(),
    };
    let _ = crate::withdraw_collateral::withdraw_collateral(
        owner_account(),
        init_position_account(500, 0),
        init_vault_account(),
        destination_holding_account(),
        stability_fee_accumulator_account(FIXED_POINT_ONE, NOW),
        redemption_price_state_account(FIXED_POINT_ONE, NOW),
        pp,
        STABLECOIN_PROGRAM_ID,
        NOW,
        100,
    );
}

#[test]
#[should_panic(expected = "StabilityFeeAccumulator account must be initialized")]
fn withdraw_collateral_rejects_uninitialized_accumulator() {
    use stablecoin_core::math::FIXED_POINT_ONE;
    let acc = AccountWithMetadata {
        account: Account::default(),
        is_authorized: false,
        account_id: stability_fee_accumulator_id(),
    };
    let _ = crate::withdraw_collateral::withdraw_collateral(
        owner_account(),
        init_position_account(500, 0),
        init_vault_account(),
        destination_holding_account(),
        acc,
        redemption_price_state_account(FIXED_POINT_ONE, NOW),
        protocol_parameters_account(false),
        STABLECOIN_PROGRAM_ID,
        NOW,
        100,
    );
}

#[test]
#[should_panic(expected = "RedemptionPriceState account must be initialized")]
fn withdraw_collateral_rejects_uninitialized_redemption_state() {
    use stablecoin_core::math::FIXED_POINT_ONE;
    let rp = AccountWithMetadata {
        account: Account::default(),
        is_authorized: false,
        account_id: redemption_price_state_id(),
    };
    let _ = crate::withdraw_collateral::withdraw_collateral(
        owner_account(),
        init_position_account(500, 0),
        init_vault_account(),
        destination_holding_account(),
        stability_fee_accumulator_account(FIXED_POINT_ONE, NOW),
        rp,
        protocol_parameters_account(false),
        STABLECOIN_PROGRAM_ID,
        NOW,
        100,
    );
}

Drop the old withdraw_collateral_rejects_withdrawal_with_outstanding_debt test — the new with-debt-succeeds tests replace its intent.

Run cargo test -p stablecoin_program tests::withdraw_collateral_. Expected: 17 PASS.

  • Step 10: Lint + IDL
make clippy
RISC0_DEV_MODE=1 cargo test -p stablecoin_program
make idl

Expected: green.

  • Step 11: Patch the integration test fixture

Open programs/integration_tests/tests/stablecoin.rs. The existing stablecoin_open_position_then_withdraw_collateral test now needs to pass stability_fee_accumulator, redemption_price_state, and protocol_parameters accounts in the withdraw message. Add Ids::stability_fee_accumulator() / Ids::redemption_price_state() helpers (using the Plan 1 PDA helpers), the corresponding Accounts::*_init() helpers (initial accumulator = FIXED_POINT_ONE, last_accrued_at = 0; same for redemption), seed them in state_for_stablecoin_tests, and add them to the message:

        vec![
            Ids::owner(),
            Ids::position(),
            Ids::vault(),
            Ids::user_holding(),
            Ids::stability_fee_accumulator(),
            Ids::redemption_price_state(),
            Ids::protocol_parameters(),
        ],

Run RISC0_DEV_MODE=1 cargo test -p integration_tests --test stablecoin. Expected: green.

  • Step 12: Commit
cargo +nightly fmt --all
git add programs/stablecoin/src/withdraw_collateral.rs \
        programs/stablecoin/src/tests.rs \
        programs/stablecoin/core/src/lib.rs \
        programs/stablecoin/core/src/math.rs \
        programs/stablecoin/methods/guest/src/bin/stablecoin.rs \
        programs/integration_tests/tests/stablecoin.rs \
        artifacts/stablecoin-idl.json
git commit -m "feat(stablecoin): rebuild withdraw_collateral with fee-aware collateralization check

Replaces the Plan 1 scaffold's hard 'debt_amount == 0' guard with the
spec §10.6 / §6.2 collateralization invariant. New 7-account signature:
adds StabilityFeeAccumulator, RedemptionPriceState, and ProtocolParameters
to the input list. Projects the accumulator and redemption price forward
to ctx.now via compound_rate (spec §5.3), then asserts the post-decrement
position satisfies the collateralization invariant via the shared
stablecoin_program::checks helper.

Also panics with 'Protocol is frozen' when ProtocolParameters.is_frozen
is true (spec §7 invariant 8).

If Plan 2 has not yet landed, this commit also adds current_accumulated_rate
and current_redemption_price to stablecoin_core::math (spec §5.3).
Coordinate with Plan 2 implementer to avoid duplication.

17 host-function tests cover happy paths (zero debt, with debt with slack,
at exact boundary), undercollateralized fail, frozen-state reject, zero-amount
no-op, overdraw, authorization (1), structural (5), and globals-uninitialized
(3)."

Notes for reviewer

  • The current_accumulated_rate / current_redemption_price helpers must produce identical values to whatever Plan 2's accrue_stability_fee / update_redemption_rate writes when they "commit" the projection. If Plan 2 lands first, this issue picks them up; if Plan 3 lands first, Plan 2 reuses them. Either order works — the math is determined by the spec.
  • Why don't we make this instruction also auto-accrue (i.e., commit the projected accumulator to the on-chain StabilityFeeAccumulator here)? Because withdrawing is not a global-state op — the user pays nothing to call it. If a withdrawer were charged with the "anchor update gas," the protocol would unfairly burden the slow-moving user. Plan 2's permissionless accrue_stability_fee is the canonical anchor-update path; this instruction only reads the projection.
  • The collateralization check uses the helper exactly as designed in Plan 3 issue 01 — caller-side projection, scalar args, panic message "Position is undercollateralized". If issue 01 changes the message, update this commit's tests in lockstep.
  • The should_panic test messages target distinct prefixes (e.g. "StabilityFeeAccumulator account must be initialized" vs "RedemptionPriceState account must be initialized") so the assertion ordering in the host function is tested implicitly. If the host fn ever rearranges its checks, the test names highlight what changed.

Dependencies

Depends on: Plan 1 (#156); #174; #175.

Blocks: #181.

Metadata

Metadata

Assignees

No one assigned

    Labels

    breakingBreaking change to on-chain shape or signatureskind/instructionInstruction / host function implementationprogram: stablecoinstablecoin-plan-3Stablecoin Plan 3 — Position Lifecycle

    Type

    Fields

    No fields configured for Task.

    Projects

    No projects

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions