Skip to content

[Plan 5 · 03] Guest wiring + integration tests + IDL regen #190

Description

@gravityblast

Issue 03 — Guest wiring + integration tests + IDL regen

Plan: Stablecoin Plan 5 — Emergency Freeze
Depends on: issue 02 (the freeze / unfreeze host functions and Instruction variants must exist).
Blocks: none — this is the last issue in Plan 5.

Goal: Wire the two new host functions into the guest binary via #[instruction] entry points, add two end-to-end integration tests that exercise the freeze kill switch through the zkVM, regenerate the IDL, and validate everything is green.

Spec reference

Why

Until the guest entries exist, neither freeze nor unfreeze is callable from the sequencer or from integration tests. The unit tests landed in issue 02 cover the host-function logic exhaustively; this issue confirms the same logic actually executes inside the zkVM and through the LEZ runtime, and that the IDL stays in sync (the CI IDL check fails otherwise).

The two new integration tests together prove the kill switch's full lifecycle:

  • stablecoin_freeze_blocks_risky_ops — after freeze, open_position, withdraw_collateral, and generate_debt all fail; deposit_collateral and repay_debt still succeed.
  • stablecoin_unfreeze_resumes_normal_operation — after freeze and then unfreeze, open_position succeeds again.

Architecture

Two new #[instruction] entries inside the existing #[lez_program] module in programs/stablecoin/methods/guest/src/bin/stablecoin.rs. Each consumes 2 AccountWithMetadata inputs (freeze_authority, protocol_parameters) and forwards them to the corresponding host function with ctx.self_program_id. No new ProgramContext fields needed (no clock).

Two new integration tests appended to programs/integration_tests/tests/stablecoin.rs, reusing the bootstrap fixtures landed in Plan 3 (bootstrapped_state, Keys, Ids, etc.) and the Plan 5 issue 01 freeze-gate test as a structural reference for how to read errors out of the LEZ runtime.

A regenerated artifacts/stablecoin-idl.json reflects the two new Instruction variants and their guest entries. CI fails if the file is stale.

Files

  • Modify: programs/stablecoin/methods/guest/src/bin/stablecoin.rs — two new #[instruction] pub fn entries (freeze, unfreeze).
  • Modify: programs/integration_tests/tests/stablecoin.rs — two new tests + helper(s) to build signed Freeze / Unfreeze transactions.
  • Modify: artifacts/stablecoin-idl.json — regenerated by make idl.

Acceptance criteria

  • cargo risczero build --manifest-path programs/stablecoin/methods/guest/Cargo.toml succeeds (the guest builds).
  • RISC0_DEV_MODE=1 cargo test -p integration_tests --test stablecoin stablecoin_freeze_blocks_risky_ops stablecoin_unfreeze_resumes_normal_operation passes both tests.
  • make idl produces a git diff only on artifacts/stablecoin-idl.json; the diff covers the two new variants and their guest entries.
  • make clippy is green.
  • The CI IDL check (make idl followed by git diff --exit-code artifacts/stablecoin-idl.json) returns 0.

Implementation steps

  • Step 1: Add the two guest entries

Edit programs/stablecoin/methods/guest/src/bin/stablecoin.rs and append two #[instruction] entries inside the existing mod stablecoin { ... } block, after the last existing entry:

    /// Set [`stablecoin_core::ProtocolParameters::is_frozen`] to `true`.
    ///
    /// # Errors
    /// Returns the host program's panic-converted error if any precondition
    /// fails (see [`stablecoin_program::freeze::freeze`] for the full list).
    #[instruction]
    pub fn freeze(
        ctx: ProgramContext,
        freeze_authority: AccountWithMetadata,
        protocol_parameters: AccountWithMetadata,
    ) -> SpelResult {
        let (post_states, chained_calls) = stablecoin_program::freeze::freeze(
            freeze_authority,
            protocol_parameters,
            ctx.self_program_id,
        );
        Ok(spel_framework::SpelOutput::execute(
            post_states,
            chained_calls,
        ))
    }

    /// Set [`stablecoin_core::ProtocolParameters::is_frozen`] to `false`.
    ///
    /// # Errors
    /// Returns the host program's panic-converted error if any precondition
    /// fails (see [`stablecoin_program::freeze::unfreeze`] for the full list).
    #[instruction]
    pub fn unfreeze(
        ctx: ProgramContext,
        freeze_authority: AccountWithMetadata,
        protocol_parameters: AccountWithMetadata,
    ) -> SpelResult {
        let (post_states, chained_calls) = stablecoin_program::freeze::unfreeze(
            freeze_authority,
            protocol_parameters,
            ctx.self_program_id,
        );
        Ok(spel_framework::SpelOutput::execute(
            post_states,
            chained_calls,
        ))
    }

Run:

cargo check --manifest-path programs/stablecoin/methods/guest/Cargo.toml --target riscv32im-risc0-zkvm-elf

Expected: clean. If the toolchain target isn't installed, fall back to cargo risczero build --manifest-path programs/stablecoin/methods/guest/Cargo.toml — it must build cleanly before moving on.

  • Step 2: Confirm the integration-test scaffolding

Open programs/integration_tests/tests/stablecoin.rs and confirm the helpers that Plan 3 + Plan 5 issue 01 introduced. Specifically the steps below assume these helpers exist:

  • bootstrapped_state() -> V03State — returns a state with initialize_program applied. Plan 3.
  • Keys::owner(), Keys::user_holding(), Keys::user_stablecoin_holding() — already exist.
  • Keys::freeze_authority() — Plan 1 / Plan 3 should have added this when initialize_program started binding a freeze authority. If it doesn't exist yet, add it now:
impl Keys {
    fn freeze_authority() -> PrivateKey {
        PrivateKey::try_new([44; 32]).expect("valid private key")
    }
}

and likewise Ids::freeze_authority():

impl Ids {
    fn freeze_authority() -> AccountId {
        AccountId::from(&PublicKey::new_from_private_key(&Keys::freeze_authority()))
    }
}

Make sure the freeze authority account id is the one initialize_program recorded as freeze_authority_account_id — i.e. update the test's initialize_program call (already in place from Plan 1) to pass Ids::freeze_authority() as the freeze authority. If Plan 1's integration test already wires it through, no change.

  • open_position_transaction(state, owner_key, amount), withdraw_collateral_transaction(...), deposit_collateral_transaction(...), generate_debt_transaction(...), repay_debt_transaction(...) — these are Plan 3 helpers. Reuse verbatim.

Add two new helpers — freeze_transaction and unfreeze_transaction:

fn freeze_transaction(state: &V03State, freeze_authority_key: &PrivateKey) -> PublicTransaction {
    use nssa::public_transaction::PublicTransactionInput;
    use stablecoin_core::{compute_protocol_parameters_pda, Instruction};

    let pp_id = compute_protocol_parameters_pda(Ids::stablecoin_program());
    public_transaction::build_and_sign(
        state,
        PublicTransactionInput {
            program_id: Ids::stablecoin_program(),
            instruction_payload: serde_json::to_vec(&Instruction::Freeze)
                .expect("Instruction::Freeze must serialize"),
            account_ids: vec![Ids::freeze_authority(), pp_id],
            signing_keys: vec![freeze_authority_key.clone()],
        },
    )
    .expect("freeze_transaction must build")
}

fn unfreeze_transaction(state: &V03State, freeze_authority_key: &PrivateKey) -> PublicTransaction {
    use nssa::public_transaction::PublicTransactionInput;
    use stablecoin_core::{compute_protocol_parameters_pda, Instruction};

    let pp_id = compute_protocol_parameters_pda(Ids::stablecoin_program());
    public_transaction::build_and_sign(
        state,
        PublicTransactionInput {
            program_id: Ids::stablecoin_program(),
            instruction_payload: serde_json::to_vec(&Instruction::Unfreeze)
                .expect("Instruction::Unfreeze must serialize"),
            account_ids: vec![Ids::freeze_authority(), pp_id],
            signing_keys: vec![freeze_authority_key.clone()],
        },
    )
    .expect("unfreeze_transaction must build")
}

The exact PublicTransactionInput shape may differ slightly (some integration tests use a build_public_transaction(...) free function or PublicTransactionBuilder). Match what the existing helpers in the file use — open_position_transaction is the right structural reference. Confirm the shape against open_position_transaction before pasting.

  • Step 3: Integration test — stablecoin_freeze_blocks_risky_ops

Append to programs/integration_tests/tests/stablecoin.rs:

#[test]
fn stablecoin_freeze_blocks_risky_ops() {
    let mut state = bootstrapped_state();

    // Open a position FIRST while the protocol is still unfrozen — we need a
    // live position to test withdraw_collateral / generate_debt / repay_debt
    // / deposit_collateral once frozen.
    let open_tx = open_position_transaction(
        &state,
        &Keys::owner(),
        Balances::collateral_deposit(),
    );
    state
        .try_apply_public_transaction(&open_tx)
        .expect("initial open_position succeeds on unfrozen protocol");

    // Generate a small amount of debt so repay_debt has something to burn.
    let initial_debt = Balances::initial_debt();
    let gen_tx = generate_debt_transaction(&state, &Keys::owner(), initial_debt);
    state
        .try_apply_public_transaction(&gen_tx)
        .expect("initial generate_debt succeeds on unfrozen protocol");

    // Freeze the protocol.
    let freeze_tx = freeze_transaction(&state, &Keys::freeze_authority());
    state
        .try_apply_public_transaction(&freeze_tx)
        .expect("freeze succeeds for the authorized freeze authority");

    // 1. open_position is blocked.
    let open_again_tx = open_position_transaction(
        &state,
        &Keys::owner(),
        Balances::collateral_deposit(),
    );
    let err = state
        .try_apply_public_transaction(&open_again_tx)
        .expect_err("open_position must be blocked when frozen");
    assert!(
        format!("{err:?}").contains("Protocol is frozen"),
        "expected 'Protocol is frozen' from open_position, got: {err:?}"
    );

    // 2. withdraw_collateral is blocked.
    let withdraw_tx = withdraw_collateral_transaction(
        &state,
        &Keys::owner(),
        Balances::collateral_withdraw(),
    );
    let err = state
        .try_apply_public_transaction(&withdraw_tx)
        .expect_err("withdraw_collateral must be blocked when frozen");
    assert!(
        format!("{err:?}").contains("Protocol is frozen"),
        "expected 'Protocol is frozen' from withdraw_collateral, got: {err:?}"
    );

    // 3. generate_debt is blocked.
    let gen_again_tx = generate_debt_transaction(&state, &Keys::owner(), initial_debt);
    let err = state
        .try_apply_public_transaction(&gen_again_tx)
        .expect_err("generate_debt must be blocked when frozen");
    assert!(
        format!("{err:?}").contains("Protocol is frozen"),
        "expected 'Protocol is frozen' from generate_debt, got: {err:?}"
    );

    // 4. deposit_collateral is STILL ALLOWED — pays down risk.
    let deposit_tx = deposit_collateral_transaction(
        &state,
        &Keys::owner(),
        Balances::collateral_withdraw(), // reuse a sane amount under the user's balance
    );
    state
        .try_apply_public_transaction(&deposit_tx)
        .expect("deposit_collateral must remain allowed when frozen (spec §10.5)");

    // 5. repay_debt is STILL ALLOWED — pays down risk.
    let repay_tx = repay_debt_transaction(&state, &Keys::owner(), Balances::debt_repay_amount());
    state
        .try_apply_public_transaction(&repay_tx)
        .expect("repay_debt must remain allowed when frozen (spec §10.8)");
}

Run:

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

Expected: PASS.

  • Step 4: Integration test — stablecoin_unfreeze_resumes_normal_operation

Append:

#[test]
fn stablecoin_unfreeze_resumes_normal_operation() {
    let mut state = bootstrapped_state();

    // Freeze, then unfreeze, then verify open_position works again.
    let freeze_tx = freeze_transaction(&state, &Keys::freeze_authority());
    state
        .try_apply_public_transaction(&freeze_tx)
        .expect("freeze succeeds for the authorized freeze authority");

    // While frozen: open_position is blocked (sanity check).
    let open_while_frozen = open_position_transaction(
        &state,
        &Keys::owner(),
        Balances::collateral_deposit(),
    );
    let err = state
        .try_apply_public_transaction(&open_while_frozen)
        .expect_err("open_position must be blocked when frozen");
    assert!(
        format!("{err:?}").contains("Protocol is frozen"),
        "expected 'Protocol is frozen', got: {err:?}"
    );

    // Unfreeze.
    let unfreeze_tx = unfreeze_transaction(&state, &Keys::freeze_authority());
    state
        .try_apply_public_transaction(&unfreeze_tx)
        .expect("unfreeze succeeds for the authorized freeze authority");

    // open_position works again.
    let open_tx = open_position_transaction(
        &state,
        &Keys::owner(),
        Balances::collateral_deposit(),
    );
    state
        .try_apply_public_transaction(&open_tx)
        .expect("open_position must succeed after unfreeze");
}

Run:

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

Expected: PASS.

  • Step 5: Regenerate the IDL
make idl
git diff artifacts/stablecoin-idl.json

Expected diff:

  • Two new entries in the program's instruction list — freeze and unfreeze, each with a 2-element accounts array (freeze_authority, protocol_parameters) and no scalar parameters.
  • Two new variants in the Instruction enum schema: Freeze and Unfreeze, both unit variants.

No other diff. If the diff is empty, the IDL was already up to date — possible if the file was regenerated in a prior step. If the diff is huge (unexpected re-ordering of unrelated variants), inspect the idl-gen crate first; do NOT commit a noisy diff.

  • Step 6: Full sweep
make clippy
make idl
git diff --exit-code artifacts/stablecoin-idl.json  # if non-zero, the diff is the new IDL — see Step 5
RISC0_DEV_MODE=1 cargo test -p stablecoin_program
RISC0_DEV_MODE=1 cargo test -p integration_tests --test stablecoin

Expected: clippy green; IDL diff bounded to the two new entries from Step 5; both stablecoin_program and integration_tests test suites green.

  • Step 7: Commit
git add programs/stablecoin/methods/guest/src/bin/stablecoin.rs \
        programs/integration_tests/tests/stablecoin.rs \
        artifacts/stablecoin-idl.json
cargo +nightly fmt --all
git add -u
git commit -m "feat(stablecoin): wire freeze/unfreeze guest entries + integration tests

Two new #[instruction] entries in the stablecoin guest binary
(freeze, unfreeze), each forwarding to the host functions landed in
the previous issue. Regenerated artifacts/stablecoin-idl.json picks
up the two new Instruction variants and their guest entries.

Two new integration tests in programs/integration_tests/tests/stablecoin.rs:

- stablecoin_freeze_blocks_risky_ops: opens a position, generates a
  bit of debt, freezes the protocol, then verifies open_position,
  withdraw_collateral, and generate_debt all fail with 'Protocol is
  frozen'. Verifies deposit_collateral and repay_debt remain allowed,
  consistent with spec §10.5 / §10.8 / §16.2.

- stablecoin_unfreeze_resumes_normal_operation: freezes, confirms
  open_position is blocked, unfreezes, confirms open_position succeeds
  again.

End-to-end the kill switch is now real through the zkVM."

Notes for reviewer / future maintainers

  • The integration tests do NOT exercise the freeze-authority handle mismatch path at the zkVM level — that is fully covered by the unit tests in issue 02 and adding it here would only re-test the same panic message through more layers. Wall-clock integration-test cost is dominated by zkVM startup; we keep the count tight.
  • stablecoin_freeze_blocks_risky_ops deliberately tests BOTH sides of the gate (the three blocked instructions AND two allowed instructions) in one test. Splitting would multiply the zkVM setup cost without adding signal — they share the exact same bootstrap and freeze-step.
  • If the force_write_account (or equivalent) override added in Plan 5 issue 01 is no longer needed once the real freeze instruction exists, leave it — Plan 1 / Plan 3 / Plan 5 issue 01 tests still use it. It's a test-only override on V03State.
  • The IDL regen step is the last item; CI's IDL diff check will fail the PR if the artifact is stale. The make idl command is idempotent and stable.
  • Future Plan 6+ work (RFP-014 liquidations etc.) may add more risk-increasing instructions; they will need the same one-line frozen check that Plan 5 issue 01 landed. Document this expectation in any future plan that touches a position-mutating instruction.

Dependencies

Depends on: #189.

Blocks: none — last issue in Plan 5.

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