Skip to content

[Plan 5 · 01] Frozen-check propagation in open_position, withdraw_collateral, generate_debt #188

Description

@gravityblast

Issue 01 — Frozen-check propagation in open_position, withdraw_collateral, generate_debt

Plan: Stablecoin Plan 5 — Emergency Freeze
Depends on: Plan 1 (the ProtocolParameters struct with is_frozen field, exposed from stablecoin_core); Plan 3 (the three target host functions exist in their full form, reading and decoding ProtocolParameters).
Blocks: issues 02 and 03 in this plan (the integration tests rely on the gate landed here).

Goal: Add a single assert!(!params.is_frozen, "Protocol is frozen") line to each of the three risk-increasing position instructions, immediately after the ProtocolParameters decode. Cover both directions of the new behaviour with focused unit tests (happy path when is_frozen = false, panic path when is_frozen = true) and one zkVM integration test that proves the gate works end-to-end.

Spec reference

Why

Once freeze (issue 02) flips ProtocolParameters.is_frozen = true, the protocol's three risk-increasing operations must stop accepting new state transitions until unfreeze flips it back. Without this gate, calling freeze would be a no-op — users could keep opening positions, withdrawing collateral, and minting debt while the operator believes the protocol is halted. Landing the gate BEFORE freeze ships (issue 02) keeps the kill switch real from the moment it ships.

The check is deliberately placed AFTER the existing ProtocolParameters decode, not before it: a malformed or wrong protocol_parameters input should still surface its own panic message (helpful to debug bad call sites) rather than be masked by a frozen panic the caller wouldn't be hitting in practice.

Architecture

For each of the three target host functions, find the line where ProtocolParameters is decoded from the protocol_parameters account — the line will look like:

let params = ProtocolParameters::try_from(&protocol_parameters.account.data)
    .expect("ProtocolParameters must decode");

and insert immediately after it:

assert!(!params.is_frozen, "Protocol is frozen");

Three identical insertions, one panic message. No new helpers — the check is one line and inlining it keeps the call site readable. (If a future instruction grows another frozen-check, we can extract a assert_not_frozen(&params) helper; not worth the indirection at three sites.)

The integration test lives in programs/integration_tests/tests/stablecoin.rs and reuses the existing scaffolding (Keys, Ids, etc.) that Plan 3 introduces. It bootstraps the protocol via initialize_program, manually flips is_frozen on the ProtocolParameters account state (since freeze ships in issue 02), then tries open_position and asserts the zkVM rejects the transaction.

Files

  • Modify: programs/stablecoin/src/open_position.rs — one new assert! line, plus a // see spec §10.4 — frozen check comment.
  • Modify: programs/stablecoin/src/withdraw_collateral.rs — one new assert! line, plus the same shape of comment for §10.6.
  • Modify: programs/stablecoin/src/generate_debt.rs — one new assert! line, plus the same shape of comment for §10.7.
  • Modify: programs/stablecoin/src/tests/open_position_tests.rs (or whatever file Plan 3 created — confirm filename in Step 1) — append a happy-path-when-not-frozen sanity test (if not already exhaustive) and a #[should_panic(expected = "Protocol is frozen")] test.
  • Modify: programs/stablecoin/src/tests/withdraw_collateral_tests.rs — same shape of tests for §10.6.
  • Modify: programs/stablecoin/src/tests/generate_debt_tests.rs — same shape of tests for §10.7.
  • Modify: programs/integration_tests/tests/stablecoin.rs — append one new test, stablecoin_freeze_gate_blocks_open_position, that proves the panic message surfaces through the zkVM. (Issue 03 adds the broader stablecoin_freeze_blocks_risky_ops and stablecoin_unfreeze_resumes_normal_operation tests once freeze itself exists.)

Acceptance criteria

  • cargo test -p stablecoin_program open_position_tests::rejects_when_frozen passes.
  • cargo test -p stablecoin_program withdraw_collateral_tests::rejects_when_frozen passes.
  • cargo test -p stablecoin_program generate_debt_tests::rejects_when_frozen passes.
  • cargo test -p stablecoin_program is green overall.
  • RISC0_DEV_MODE=1 cargo test -p integration_tests --test stablecoin stablecoin_freeze_gate_blocks_open_position passes.
  • make clippy is green.
  • deposit_collateral, repay_debt, and close_position host functions are UNCHANGED — verify by git diff programs/stablecoin/src/deposit_collateral.rs programs/stablecoin/src/repay_debt.rs programs/stablecoin/src/close_position.rs showing no output.

Implementation steps

  • Step 1: Confirm the exact line each host function decodes ProtocolParameters

The insertion point depends on Plan 3's final shape. Run:

grep -n "ProtocolParameters::try_from" \
    programs/stablecoin/src/open_position.rs \
    programs/stablecoin/src/withdraw_collateral.rs \
    programs/stablecoin/src/generate_debt.rs

Expected: one line per file, near the top of the function body, binding the decoded struct to a local. Note the binding name — it will most likely be params or protocol_params. Plan 3's stylistic choice wins. Below this issue uses params; replace with whatever Plan 3 actually used. Confirm the names match before editing.

Also list the existing test files so the next steps reference the right filenames:

ls programs/stablecoin/src/tests/

Expected: mod.rs, existing.rs, initialize_program_tests.rs, open_position_tests.rs, withdraw_collateral_tests.rs, generate_debt_tests.rs, plus other Plan 3 / Plan 2 files. Confirm the three test filenames; the steps below use these names verbatim.

  • Step 2: Add the frozen check to open_position

In programs/stablecoin/src/open_position.rs, find:

    let params = ProtocolParameters::try_from(&protocol_parameters.account.data)
        .expect("ProtocolParameters must decode");

Insert immediately after it:

    // Spec §10.4 — open_position is blocked when the protocol is frozen.
    assert!(!params.is_frozen, "Protocol is frozen");

Also update the # Panics doc comment at the top of the function to add a bullet:

/// - `protocol_parameters.is_frozen = true` (protocol is frozen, see spec §10.4).

Run cargo check -p stablecoin_program. Expected: clean.

  • Step 3: Add the frozen check to withdraw_collateral

In programs/stablecoin/src/withdraw_collateral.rs, find the same ProtocolParameters::try_from(...) line and insert immediately after:

    // Spec §10.6 — withdraw_collateral is blocked when the protocol is frozen.
    assert!(!params.is_frozen, "Protocol is frozen");

Update the # Panics doc:

/// - `protocol_parameters.is_frozen = true` (protocol is frozen, see spec §10.6).

Run cargo check -p stablecoin_program. Expected: clean.

  • Step 4: Add the frozen check to generate_debt

In programs/stablecoin/src/generate_debt.rs, find the same line and insert:

    // Spec §10.7 — generate_debt is blocked when the protocol is frozen.
    assert!(!params.is_frozen, "Protocol is frozen");

Update the # Panics doc:

/// - `protocol_parameters.is_frozen = true` (protocol is frozen, see spec §10.7).

Run cargo check -p stablecoin_program. Expected: clean.

  • Step 5: Verify deposit_collateral, repay_debt, close_position were NOT touched
git diff --name-only programs/stablecoin/src/

Expected output set: only open_position.rs, withdraw_collateral.rs, generate_debt.rs. If any of deposit_collateral.rs, repay_debt.rs, close_position.rs show up, revert those edits — they are allowed when frozen by design (spec §10.5, §10.8, §10.9 and §16.2). The check belongs only in the three risk-increasing instructions.

  • Step 6: Unit test — open_position happy path when not frozen

In programs/stablecoin/src/tests/open_position_tests.rs, find (or add, if Plan 3 left the file thin) a small helper that produces a default ProtocolParameters account input. The helper most likely already exists with a name like protocol_parameters_account() returning an AccountWithMetadata whose account.data decodes to ProtocolParameters { is_frozen: false, .. }. If it doesn't expose is_frozen directly, give it a builder-style override:

fn protocol_parameters_account_with_is_frozen(is_frozen: bool) -> nssa_core::account::AccountWithMetadata {
    use nssa_core::account::Data;
    use stablecoin_core::ProtocolParameters;
    let mut decoded = decoded_default_protocol_parameters(); // helper Plan 3 ships
    decoded.is_frozen = is_frozen;
    let mut acc = protocol_parameters_account();
    acc.account.data = Data::from(&decoded);
    acc
}

Then append:

#[test]
fn happy_path_succeeds_when_protocol_is_not_frozen() {
    // Sanity check: the default fixture has is_frozen = false and the call succeeds.
    let (post_states, chained_calls) = invoke_open_position_with_protocol_parameters(
        protocol_parameters_account_with_is_frozen(false),
    );
    assert_eq!(post_states.len(), EXPECTED_POST_STATE_COUNT);
    assert_eq!(chained_calls.len(), EXPECTED_CHAINED_CALL_COUNT);
}

EXPECTED_POST_STATE_COUNT and EXPECTED_CHAINED_CALL_COUNT are whatever Plan 3 documents (6 post-states + 2 chained calls based on §10.4). invoke_open_position_with_protocol_parameters is a small helper that takes the protocol-parameters account and threads it into open_position(...) with all the other inputs pulled from existing default fixtures. If Plan 3 named its invocation helper differently, use that name verbatim instead of writing a new one.

Run cargo test -p stablecoin_program open_position_tests::happy_path_succeeds_when_protocol_is_not_frozen. Expected: PASS.

  • Step 7: Unit test — open_position panics when frozen

Append:

#[test]
#[should_panic(expected = "Protocol is frozen")]
fn rejects_open_position_when_protocol_is_frozen() {
    let _ = invoke_open_position_with_protocol_parameters(
        protocol_parameters_account_with_is_frozen(true),
    );
}

Run cargo test -p stablecoin_program open_position_tests::rejects_open_position_when_protocol_is_frozen. Expected: PASS.

  • Step 8: Same two tests for withdraw_collateral

In programs/stablecoin/src/tests/withdraw_collateral_tests.rs, mirror steps 6 and 7. The helper / invocation function names follow Plan 3's; the two new tests should be:

#[test]
fn happy_path_succeeds_when_protocol_is_not_frozen() {
    let (post_states, chained_calls) = invoke_withdraw_collateral_with_protocol_parameters(
        protocol_parameters_account_with_is_frozen(false),
    );
    assert_eq!(post_states.len(), EXPECTED_POST_STATE_COUNT);
    assert_eq!(chained_calls.len(), EXPECTED_CHAINED_CALL_COUNT);
}

#[test]
#[should_panic(expected = "Protocol is frozen")]
fn rejects_withdraw_collateral_when_protocol_is_frozen() {
    let _ = invoke_withdraw_collateral_with_protocol_parameters(
        protocol_parameters_account_with_is_frozen(true),
    );
}

Run cargo test -p stablecoin_program withdraw_collateral_tests::. Expected: green including the two new tests.

  • Step 9: Same two tests for generate_debt

In programs/stablecoin/src/tests/generate_debt_tests.rs, mirror steps 6 and 7:

#[test]
fn happy_path_succeeds_when_protocol_is_not_frozen() {
    let (post_states, chained_calls) = invoke_generate_debt_with_protocol_parameters(
        protocol_parameters_account_with_is_frozen(false),
    );
    assert_eq!(post_states.len(), EXPECTED_POST_STATE_COUNT);
    assert_eq!(chained_calls.len(), EXPECTED_CHAINED_CALL_COUNT);
}

#[test]
#[should_panic(expected = "Protocol is frozen")]
fn rejects_generate_debt_when_protocol_is_frozen() {
    let _ = invoke_generate_debt_with_protocol_parameters(
        protocol_parameters_account_with_is_frozen(true),
    );
}

Run cargo test -p stablecoin_program generate_debt_tests::. Expected: green including the two new tests.

  • Step 10: Integration test — freeze gate through the zkVM

In programs/integration_tests/tests/stablecoin.rs, append:

#[test]
fn stablecoin_freeze_gate_blocks_open_position() {
    // Build the standard initialized protocol + funded user (helpers from Plan 3
    // already in this file), then mutate the ProtocolParameters state to set
    // is_frozen = true BEFORE the open_position transaction. `freeze` itself
    // ships in plan 5 issue 02; this test exists to verify the gate landed
    // here is honored by the zkVM, independent of how the flip happens.
    let mut state = bootstrapped_state();
    set_is_frozen_in_state(&mut state, true);

    let owner_key = Keys::owner();
    let tx: PublicTransaction = open_position_transaction(
        &state,
        &owner_key,
        Balances::collateral_deposit(),
    );

    let outcome = state.try_apply_public_transaction(&tx);
    let err = outcome.expect_err("freeze gate must reject open_position");
    let formatted = format!("{err:?}");
    assert!(
        formatted.contains("Protocol is frozen"),
        "expected 'Protocol is frozen' in error, got: {formatted}"
    );
}

Three of the names above must match what Plan 3 introduced in this file. Specifically:

  • bootstrapped_state() — a helper that returns a V03State with the stablecoin program deployed and initialize_program already applied. Plan 3 introduced one when it landed the lifecycle integration tests; reuse it. If the name differs, swap.
  • open_position_transaction(state, owner_key, amount) — a helper that builds a signed PublicTransaction for the OpenPosition instruction. Reuse Plan 3's name verbatim.
  • set_is_frozen_in_state(state, true) — a helper this test introduces. Add it to the same file:
fn set_is_frozen_in_state(state: &mut V03State, is_frozen: bool) {
    use stablecoin_core::{compute_protocol_parameters_pda, ProtocolParameters};
    let pp_id = compute_protocol_parameters_pda(Ids::stablecoin_program());
    let mut acc = state
        .read_account(&pp_id)
        .expect("ProtocolParameters must exist after bootstrap")
        .clone();
    let mut decoded = ProtocolParameters::try_from(&acc.data)
        .expect("ProtocolParameters must decode");
    decoded.is_frozen = is_frozen;
    acc.data = nssa_core::account::Data::from(&decoded);
    state
        .force_write_account(pp_id, acc)
        .expect("test harness must allow forcing the ProtocolParameters state");
}

The force_write_account API on V03State is the test-only override LEZ exposes for fixtures. If the harness Plan 3 uses calls it something different (overwrite_account, set_account_for_test, etc.), use that name verbatim. Confirm by grepping the existing test file:

grep -n "force_write_account\|overwrite_account\|set_account_for_test" \
    programs/integration_tests/tests/stablecoin.rs

Run:

RISC0_DEV_MODE=1 cargo test -p integration_tests --test stablecoin \
    stablecoin_freeze_gate_blocks_open_position

Expected: PASS.

  • Step 11: Full sweep
make clippy
RISC0_DEV_MODE=1 cargo test -p stablecoin_program
RISC0_DEV_MODE=1 cargo test -p integration_tests --test stablecoin

Expected: all green.

  • Step 12: Commit
git add programs/stablecoin/src/open_position.rs \
        programs/stablecoin/src/withdraw_collateral.rs \
        programs/stablecoin/src/generate_debt.rs \
        programs/stablecoin/src/tests/open_position_tests.rs \
        programs/stablecoin/src/tests/withdraw_collateral_tests.rs \
        programs/stablecoin/src/tests/generate_debt_tests.rs \
        programs/integration_tests/tests/stablecoin.rs
cargo +nightly fmt --all
git add -u
git commit -m "feat(stablecoin): propagate is_frozen gate into open_position, withdraw_collateral, generate_debt

Add an assert!(!params.is_frozen, \"Protocol is frozen\") line to the
three risk-increasing position instructions, immediately after the
ProtocolParameters decode. deposit_collateral, repay_debt, and
close_position remain allowed when frozen by design — see spec
§10.5 / §10.8 / §10.9 and the emergency walkthrough in §16.2.

Unit tests: six new tests (happy path + frozen-panic for each of the
three host functions). Integration test: one new zkVM-level test that
flips is_frozen on the ProtocolParameters state and verifies
open_position is rejected with the expected message.

The freeze and unfreeze instructions themselves ship in the next
issue in plan 5; this gate is what makes them effective once they
ship."

Notes for reviewer / future maintainers

  • The frozen check is intentionally placed AFTER the ProtocolParameters decode. If the decode itself fails (bad account input), the caller deserves the precise decode error, not a frozen-protocol error that wouldn't be the actual cause.
  • The integration test uses a state-fixture override (force_write_account / equivalent) rather than calling freeze because freeze ships in issue 02. This test deliberately scopes itself to one thing: that the gate landed here is real once is_frozen = true is observed in the on-chain state. Issue 03 will add tests that flip the flag via the real freeze instruction end-to-end.
  • One panic message string ("Protocol is frozen") shared across all three sites. If you ever want per-instruction differentiation (e.g. for telemetry), change all three at once and update the three #[should_panic(expected = ...)] attributes plus the integration test's contains(...) assertion.
  • No new dependencies. No new crate features. No new public API on stablecoin_core beyond what Plan 1 already exposed (ProtocolParameters with is_frozen: bool).

Dependencies

Depends on: Plan 1 (#156, is_frozen field); Plan 3 (#173, the three target host functions).

Blocks: #189, #190.

Metadata

Metadata

Assignees

No one assigned

    Type

    Fields

    No fields configured for Task.

    Projects

    No projects

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions