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
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, ¶ms, 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.)
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, ¶ms, 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).
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,
))
}
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(),
}
}
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.
#[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.
#[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.
#[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.
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.
make clippy
RISC0_DEV_MODE=1 cargo test -p stablecoin_program
make idl
Expected: green.
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.
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.
Issue 04 —
withdraw_collateralrebuildPlan: 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
Positionshape).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-assertsdebt_amount == 0) with the spec §10.6 version: 7-account input list, reads the global accumulator + redemption-price state, projects both forward tonowviacompound_rate, and invokes the Plan 3 issue 01 collateralization helper on the post-decrement position. Frozen ⇒ panic.Spec reference
§10.6 withdraw_collateral— account list, panics, single chainedToken::Transferwith vault PDA seed authorization.§5.3 Current value projections—current_accumulated_rateandcurrent_redemption_price.§6.2 Collateralization invariant— the check, in the form implemented by issue 01.§7 invariant 8—is_frozen = trueblockswithdraw_collateral.Why
The Plan 1 scaffold's
Position.debt_amount == 0guard was a placeholder marker. With Plan 1'sProtocolParameters/StabilityFeeAccumulator/RedemptionPriceStatelanded and the collateralization helper from Plan 3 issue 01 in place, all the pieces exist to makewithdraw_collateralcorrect 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::ProjectStateinstruction (over-engineered).Architecture
pub fn current_accumulated_rate(state: &StabilityFeeAccumulator, params: &ProtocolParameters, now: u64) -> u128andpub fn current_redemption_price(state: &RedemptionPriceState, now: u64) -> u128tostablecoin_core::math(or wherever Plan 2 placed them — Plan 2'saccrue_stability_feeplan 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.Files
programs/stablecoin/src/withdraw_collateral.rs— full rewrite.programs/stablecoin/core/src/lib.rs— updateInstruction::WithdrawCollateraldoc comment (no field changes, just docs).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.programs/stablecoin/methods/guest/src/bin/stablecoin.rs— guest entrypoint adds the four extra accounts +ctx.now.programs/stablecoin/src/tests.rs— replace the scaffold tests; add the new ones.artifacts/stablecoin-idl.jsonviamake idl.Acceptance criteria
cargo test -p stablecoin_program tests::withdraw_collateral_passes (new test list — 12+ tests).make clippyis green.make idlregenerates the IDL."Position is undercollateralized"."Protocol is frozen".Implementation steps
In
programs/stablecoin/core/src/math.rs(added in Plan 1 issue 01), check whether Plan 2 already addedcurrent_accumulated_rate/current_redemption_price. If not, append:Add to the existing
#[cfg(test)] mod testsinmath.rs: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.)
Replace the entire contents of
programs/stablecoin/src/withdraw_collateral.rswith:Run
cargo check -p stablecoin_program. Expected: clean (existing tests will fail to compile — Step 4 rewrites them).In
programs/stablecoin/methods/guest/src/bin/stablecoin.rs, replace the existingwithdraw_collateral#[instruction]block with:In
programs/stablecoin/src/tests.rs, append (just above the first existingwithdraw_collateral_*test):Replace the existing
withdraw_collateral_updates_position_and_emits_transfertest with:Run. Expected: 1 PASS.
Run
cargo test -p stablecoin_program tests::withdraw_collateral_with_debt. Expected: 2 PASS.Run. Expected: 1 PASS.
Run
cargo test -p stablecoin_program tests::withdraw_collateral_. Expected: 7 PASS so far.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_definitionshould be carried forward and updated to pass the three new globals. Replace each one with the same scaffold but adding:just before the final
amountarg 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:
Drop the old
withdraw_collateral_rejects_withdrawal_with_outstanding_debttest — the new with-debt-succeeds tests replace its intent.Run
cargo test -p stablecoin_program tests::withdraw_collateral_. Expected: 17 PASS.make clippy RISC0_DEV_MODE=1 cargo test -p stablecoin_program make idlExpected: green.
Open
programs/integration_tests/tests/stablecoin.rs. The existingstablecoin_open_position_then_withdraw_collateraltest now needs to passstability_fee_accumulator,redemption_price_state, andprotocol_parametersaccounts in thewithdrawmessage. AddIds::stability_fee_accumulator()/Ids::redemption_price_state()helpers (using the Plan 1 PDA helpers), the correspondingAccounts::*_init()helpers (initial accumulator =FIXED_POINT_ONE, last_accrued_at = 0; same for redemption), seed them instate_for_stablecoin_tests, and add them to the message:Run
RISC0_DEV_MODE=1 cargo test -p integration_tests --test stablecoin. Expected: green.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
current_accumulated_rate/current_redemption_pricehelpers must produce identical values to whatever Plan 2'saccrue_stability_fee/update_redemption_ratewrites 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.StabilityFeeAccumulatorhere)? 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 permissionlessaccrue_stability_feeis the canonical anchor-update path; this instruction only reads the projection."Position is undercollateralized". If issue 01 changes the message, update this commit's tests in lockstep.should_panictest 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.