Skip to content

[Plan 4 · 04] Guest wiring + integration tests + IDL regen #186

Description

@gravityblast

Issue 04 — Guest wiring + integration tests + IDL regen

Plan: Stablecoin Plan 4 — Admin parameter updates
Depends on: 02 (set_stability_fee_per_millisecond host fn), 03 (the other six host fns).
Blocks: Plan 5 (the freeze / unfreeze guest entries assume the same scaffold pattern).

Goal: Expose all seven admin setters as guest-callable #[instruction]s, write two end-to-end integration tests that run through the zkVM under RISC0_DEV_MODE=1, and regenerate artifacts/stablecoin-idl.json.

Spec reference

§10.10–10.16 Admin parameter updates — the seven setter contracts the guest forwards verbatim.

Why

The host functions from issues 02–03 aren't reachable without guest-side #[instruction] wrappers. The two integration tests pin down two distinct behaviors that unit tests can't reach:

  1. stablecoin_admin_param_sweep — proves the full happy path of every setter through the zkVM in one transaction-by-transaction sequence: initialize, then call each of the seven setters with a valid value, then read ProtocolParameters and confirm every field reflects the latest change.
  2. stablecoin_set_stability_fee_per_millisecond_auto_accrues — pins the load-bearing property of the special setter: after an elapsed simulated time dt, the accumulator's new anchor equals the OLD-rate compound, not the new-rate compound. Host-side unit tests already cover this, but the integration test proves the wiring (guest entry forwards ctx.now correctly, the Borsh round-trip survives the zkVM) doesn't break the property.

IDL regen keeps artifacts/stablecoin-idl.json in sync; CI checks it.

Files

  • Modify: programs/stablecoin/methods/guest/src/bin/stablecoin.rs — add seven #[instruction] entries.
  • Modify: programs/integration_tests/tests/stablecoin.rs — two new end-to-end tests + supporting helpers.
  • Modify: artifacts/stablecoin-idl.json — regenerated by make idl.

Acceptance criteria

  • cargo build --manifest-path programs/stablecoin/methods/guest/Cargo.toml succeeds.
  • make clippy-guest is green.
  • RISC0_DEV_MODE=1 cargo test -p integration_tests --test stablecoin -- --nocapture stablecoin_admin_param_sweep passes.
  • RISC0_DEV_MODE=1 cargo test -p integration_tests --test stablecoin -- --nocapture stablecoin_set_stability_fee_per_millisecond_auto_accrues passes.
  • make idl regenerates artifacts/stablecoin-idl.json without diff after a clean re-run (deterministic).
  • The generated IDL contains all seven new instructions (SetStabilityFeePerMillisecond, SetMinimumCollateralizationRatio, SetControllerGains, SetMarketPriceOracle, SetTimingParameters, SetAdmin, SetFreezeAuthority) with their parameters in the expected order.

Implementation steps

  • Step 1: Add the seven guest entries

In programs/stablecoin/methods/guest/src/bin/stablecoin.rs, append the new instructions inside the #[lez_program] module. Order them as in the spec (set_stability_fee_per_millisecond first, then the rest in §10.11–§10.16 order):

    /// Update the protocol's stability fee. Auto-accrues the accumulator at
    /// the OLD rate forward to `ctx.now` before writing the new rate. See spec
    /// §10.10 and the host function
    /// `stablecoin_program::admin::set_stability_fee_per_millisecond`.
    ///
    /// # Errors
    /// Returns the host program's panic-converted error if any precondition fails.
    #[instruction]
    pub fn set_stability_fee_per_millisecond(
        ctx: ProgramContext,
        admin: AccountWithMetadata,
        protocol_parameters: AccountWithMetadata,
        stability_fee_accumulator: AccountWithMetadata,
        new_stability_fee_per_millisecond: u128,
    ) -> SpelResult {
        let (post_states, chained_calls) =
            stablecoin_program::admin::set_stability_fee_per_millisecond(
                admin,
                protocol_parameters,
                stability_fee_accumulator,
                ctx.self_program_id,
                ctx.now,
                new_stability_fee_per_millisecond,
            );
        Ok(spel_framework::SpelOutput::execute(post_states, chained_calls))
    }

    /// Update the minimum collateralization ratio (spec §10.11). Auth via
    /// admin handle on `ProtocolParameters`.
    ///
    /// # Errors
    /// Returns the host program's panic-converted error if any precondition fails.
    #[instruction]
    pub fn set_minimum_collateralization_ratio(
        ctx: ProgramContext,
        admin: AccountWithMetadata,
        protocol_parameters: AccountWithMetadata,
        new_minimum_collateralization_ratio: u128,
    ) -> SpelResult {
        let (post_states, chained_calls) =
            stablecoin_program::admin::set_minimum_collateralization_ratio(
                admin,
                protocol_parameters,
                ctx.self_program_id,
                new_minimum_collateralization_ratio,
            );
        Ok(spel_framework::SpelOutput::execute(post_states, chained_calls))
    }

    /// Update both PI controller gains atomically (spec §10.12). Does NOT
    /// reset the persisted integral term.
    ///
    /// # Errors
    /// Returns the host program's panic-converted error if any precondition fails.
    #[instruction]
    pub fn set_controller_gains(
        ctx: ProgramContext,
        admin: AccountWithMetadata,
        protocol_parameters: AccountWithMetadata,
        new_controller_proportional_gain: i128,
        new_controller_integral_gain: i128,
    ) -> SpelResult {
        let (post_states, chained_calls) = stablecoin_program::admin::set_controller_gains(
            admin,
            protocol_parameters,
            ctx.self_program_id,
            new_controller_proportional_gain,
            new_controller_integral_gain,
        );
        Ok(spel_framework::SpelOutput::execute(post_states, chained_calls))
    }

    /// Rotate the market price oracle (spec §10.13). Validates the new
    /// oracle's base/quote pair.
    ///
    /// # Errors
    /// Returns the host program's panic-converted error if any precondition fails.
    #[instruction]
    pub fn set_market_price_oracle(
        ctx: ProgramContext,
        admin: AccountWithMetadata,
        protocol_parameters: AccountWithMetadata,
        new_oracle: AccountWithMetadata,
    ) -> SpelResult {
        let (post_states, chained_calls) = stablecoin_program::admin::set_market_price_oracle(
            admin,
            protocol_parameters,
            new_oracle,
            ctx.self_program_id,
        );
        Ok(spel_framework::SpelOutput::execute(post_states, chained_calls))
    }

    /// Update both timing parameters atomically (spec §10.14).
    ///
    /// # Errors
    /// Returns the host program's panic-converted error if any precondition fails.
    #[instruction]
    pub fn set_timing_parameters(
        ctx: ProgramContext,
        admin: AccountWithMetadata,
        protocol_parameters: AccountWithMetadata,
        new_minimum_milliseconds_between_rate_updates: u64,
        new_maximum_oracle_price_age_milliseconds: u64,
    ) -> SpelResult {
        let (post_states, chained_calls) = stablecoin_program::admin::set_timing_parameters(
            admin,
            protocol_parameters,
            ctx.self_program_id,
            new_minimum_milliseconds_between_rate_updates,
            new_maximum_oracle_price_age_milliseconds,
        );
        Ok(spel_framework::SpelOutput::execute(post_states, chained_calls))
    }

    /// One-step admin rotation (spec §10.15).
    ///
    /// # Errors
    /// Returns the host program's panic-converted error if any precondition fails.
    #[instruction]
    pub fn set_admin(
        ctx: ProgramContext,
        admin: AccountWithMetadata,
        protocol_parameters: AccountWithMetadata,
        new_admin_account_id: nssa_core::account::AccountId,
    ) -> SpelResult {
        let (post_states, chained_calls) = stablecoin_program::admin::set_admin(
            admin,
            protocol_parameters,
            ctx.self_program_id,
            new_admin_account_id,
        );
        Ok(spel_framework::SpelOutput::execute(post_states, chained_calls))
    }

    /// One-step freeze-authority rotation (spec §10.16).
    ///
    /// # Errors
    /// Returns the host program's panic-converted error if any precondition fails.
    #[instruction]
    pub fn set_freeze_authority(
        ctx: ProgramContext,
        admin: AccountWithMetadata,
        protocol_parameters: AccountWithMetadata,
        new_freeze_authority_account_id: nssa_core::account::AccountId,
    ) -> SpelResult {
        let (post_states, chained_calls) = stablecoin_program::admin::set_freeze_authority(
            admin,
            protocol_parameters,
            ctx.self_program_id,
            new_freeze_authority_account_id,
        );
        Ok(spel_framework::SpelOutput::execute(post_states, chained_calls))
    }

(AccountId is imported via the fully-qualified path nssa_core::account::AccountId to avoid a top-of-file use change. If the existing guest file already pulls AccountId into scope, use the bare name.)

  • Step 2: Smoke-build the guest
cargo build --manifest-path programs/stablecoin/methods/guest/Cargo.toml
make clippy-guest

Expected: green.

  • Step 3: Add the param-sweep integration test

In programs/integration_tests/tests/stablecoin.rs, append after the existing tests (this test depends on the Plan 1 issue 08 stablecoin_initialize_program_creates_globals_and_stablecoin_definition helpers — Keys::admin, Ids::freeze_authority, Accounts::oracle_init):

#[test]
fn stablecoin_admin_param_sweep() {
    let mut state = V03State::new();

    // 1. Deploy token + stablecoin programs (same as the existing init test).
    let token_deploy = ProgramDeploymentTransaction::new(
        token_methods::TOKEN_ELF.to_vec(),
        Ids::token_program(),
    );
    state
        .transition_from_program_deployment(&token_deploy)
        .expect("deploy token");

    let stablecoin_deploy = ProgramDeploymentTransaction::new(
        stablecoin_methods::STABLECOIN_ELF.to_vec(),
        Ids::stablecoin_program(),
    );
    state
        .transition_from_program_deployment(&stablecoin_deploy)
        .expect("deploy stablecoin");

    // 2. Force-insert the collateral definition + the FIRST oracle (used by init).
    let collateral_definition = Accounts::collateral_definition_init();
    state.force_insert(
        collateral_definition.account_id,
        collateral_definition.account.clone(),
    );

    let stablecoin_def_id =
        stablecoin_core::compute_stablecoin_definition_pda(Ids::stablecoin_program());
    let oracle = Accounts::oracle_init(stablecoin_def_id, Ids::collateral_definition());
    state.force_insert(oracle.account_id, oracle.account.clone());

    // 3. Run InitializeProgram.
    let admin = Keys::admin();
    let admin_account_id = AccountId::from(&PublicKey::new_from_private_key(&admin));
    let init_instruction = stablecoin_core::Instruction::InitializeProgram {
        freeze_authority_account_id: Ids::freeze_authority(),
        initial_stability_fee_per_millisecond:
            stablecoin_core::math::FIXED_POINT_ONE + 1_000_000_000_000_000,
        initial_controller_proportional_gain: 0,
        initial_controller_integral_gain: 0,
        initial_minimum_collateralization_ratio:
            stablecoin_core::math::FIXED_POINT_ONE * 3 / 2,
        minimum_milliseconds_between_rate_updates: 300_000,
        maximum_oracle_price_age_milliseconds: 900_000,
        initial_redemption_price: stablecoin_core::math::FIXED_POINT_ONE / 2,
        stablecoin_name: "test-stable".to_owned(),
    };
    let init_tx = public_transaction(
        Ids::stablecoin_program(),
        &init_instruction,
        vec![(admin_account_id, admin.clone())],
        vec![
            stablecoin_core::compute_protocol_parameters_pda(Ids::stablecoin_program()),
            stablecoin_core::compute_stability_fee_accumulator_pda(Ids::stablecoin_program()),
            stablecoin_core::compute_redemption_price_state_pda(Ids::stablecoin_program()),
            stablecoin_def_id,
            stablecoin_core::compute_stablecoin_master_holding_pda(Ids::stablecoin_program()),
            Ids::collateral_definition(),
            oracle.account_id,
        ],
    );
    state
        .transition_from_public_transaction(&init_tx)
        .expect("initialize_program");

    let pp_id = stablecoin_core::compute_protocol_parameters_pda(Ids::stablecoin_program());
    let acc_id = stablecoin_core::compute_stability_fee_accumulator_pda(Ids::stablecoin_program());

    // 4. SetStabilityFeePerMillisecond
    let new_fee = stablecoin_core::math::FIXED_POINT_ONE + 2_000_000_000_000_000;
    let tx = public_transaction(
        Ids::stablecoin_program(),
        &stablecoin_core::Instruction::SetStabilityFeePerMillisecond {
            new_stability_fee_per_millisecond: new_fee,
        },
        vec![(admin_account_id, admin.clone())],
        vec![pp_id, acc_id],
    );
    state
        .transition_from_public_transaction(&tx)
        .expect("set_stability_fee_per_millisecond");

    // 5. SetMinimumCollateralizationRatio
    let new_ratio = stablecoin_core::math::FIXED_POINT_ONE * 175 / 100;
    let tx = public_transaction(
        Ids::stablecoin_program(),
        &stablecoin_core::Instruction::SetMinimumCollateralizationRatio {
            new_minimum_collateralization_ratio: new_ratio,
        },
        vec![(admin_account_id, admin.clone())],
        vec![pp_id],
    );
    state
        .transition_from_public_transaction(&tx)
        .expect("set_minimum_collateralization_ratio");

    // 6. SetControllerGains
    let new_kp = 12345_i128;
    let new_ki = -67890_i128;
    let tx = public_transaction(
        Ids::stablecoin_program(),
        &stablecoin_core::Instruction::SetControllerGains {
            new_controller_proportional_gain: new_kp,
            new_controller_integral_gain: new_ki,
        },
        vec![(admin_account_id, admin.clone())],
        vec![pp_id],
    );
    state
        .transition_from_public_transaction(&tx)
        .expect("set_controller_gains");

    // 7. SetMarketPriceOracle — build a SECOND oracle with the same base/quote
    //    and force-insert it.
    let new_oracle = AccountWithMetadata {
        account: Account {
            program_owner: [9u32; 8],
            balance: 0,
            data: Data::from(&twap_oracle_core::OraclePriceAccount {
                base_asset: stablecoin_def_id,
                quote_asset: Ids::collateral_definition(),
                price: stablecoin_core::math::FIXED_POINT_ONE / 2,
                timestamp: 0,
                source_id: "new-twap".to_owned(),
                confidence_interval: 0,
            }),
            nonce: Nonce(0),
        },
        is_authorized: false,
        account_id: AccountId::new([0x71; 32]),
    };
    state.force_insert(new_oracle.account_id, new_oracle.account.clone());
    let tx = public_transaction(
        Ids::stablecoin_program(),
        &stablecoin_core::Instruction::SetMarketPriceOracle,
        vec![(admin_account_id, admin.clone())],
        vec![pp_id, new_oracle.account_id],
    );
    state
        .transition_from_public_transaction(&tx)
        .expect("set_market_price_oracle");

    // 8. SetTimingParameters
    let new_b = 600_000_u64;
    let new_c = 1_200_000_u64;
    let tx = public_transaction(
        Ids::stablecoin_program(),
        &stablecoin_core::Instruction::SetTimingParameters {
            new_minimum_milliseconds_between_rate_updates: new_b,
            new_maximum_oracle_price_age_milliseconds: new_c,
        },
        vec![(admin_account_id, admin.clone())],
        vec![pp_id],
    );
    state
        .transition_from_public_transaction(&tx)
        .expect("set_timing_parameters");

    // 9. SetFreezeAuthority (rotate FIRST, while we still hold the admin key,
    //    so we can verify it changed).
    let new_freeze_authority = AccountId::new([0xEE; 32]);
    let tx = public_transaction(
        Ids::stablecoin_program(),
        &stablecoin_core::Instruction::SetFreezeAuthority {
            new_freeze_authority_account_id: new_freeze_authority,
        },
        vec![(admin_account_id, admin.clone())],
        vec![pp_id],
    );
    state
        .transition_from_public_transaction(&tx)
        .expect("set_freeze_authority");

    // 10. SetAdmin LAST — the current admin signs handing off to a new admin.
    let new_admin_account_id = AccountId::new([0xCC; 32]);
    let tx = public_transaction(
        Ids::stablecoin_program(),
        &stablecoin_core::Instruction::SetAdmin { new_admin_account_id },
        vec![(admin_account_id, admin.clone())],
        vec![pp_id],
    );
    state
        .transition_from_public_transaction(&tx)
        .expect("set_admin");

    // 11. Verify ProtocolParameters reflects every change.
    let pp = state.account(pp_id).expect("ProtocolParameters present");
    let decoded =
        stablecoin_core::ProtocolParameters::try_from(&pp.data).expect("decode ProtocolParameters");

    assert_eq!(decoded.stability_fee_per_millisecond, new_fee, "fee");
    assert_eq!(decoded.minimum_collateralization_ratio, new_ratio, "ratio");
    assert_eq!(decoded.controller_proportional_gain, new_kp, "kp");
    assert_eq!(decoded.controller_integral_gain, new_ki, "ki");
    assert_eq!(decoded.market_price_oracle_id, new_oracle.account_id, "oracle");
    assert_eq!(decoded.minimum_milliseconds_between_rate_updates, new_b, "timing rate-update interval");
    assert_eq!(decoded.maximum_oracle_price_age_milliseconds, new_c, "timing oracle max age");
    assert_eq!(decoded.freeze_authority_account_id, new_freeze_authority, "fa");
    assert_eq!(decoded.admin_account_id, new_admin_account_id, "admin");

    // Bonds that must remain UNCHANGED across the sweep.
    assert_eq!(
        decoded.stablecoin_definition_id, stablecoin_def_id,
        "stablecoin_definition_id is immutable",
    );
    assert_eq!(
        decoded.collateral_definition_id, Ids::collateral_definition(),
        "collateral_definition_id is immutable",
    );
    assert!(!decoded.is_frozen, "is_frozen must remain false; freeze is Plan 5");
}
  • Step 4: Add the auto-accrue integration test

Append:

#[test]
fn stablecoin_set_stability_fee_per_millisecond_auto_accrues() {
    // This test pins the load-bearing property of set_stability_fee_per_millisecond
    // through the zkVM: when the admin updates the rate, the accumulator must
    // be advanced at the OLD rate forward to the simulated `now`, never at
    // the new rate.
    //
    // Approach: initialize the protocol, manually edit the accumulator's
    // `last_accrued_at` field via state.force_insert to simulate an elapsed
    // delta, then run set_stability_fee_per_millisecond and read back the
    // accumulator. Assert the new anchor equals
    // `old_anchor * compound_rate(OLD_rate, dt) / FIXED_POINT_ONE`.

    let mut state = V03State::new();

    let token_deploy = ProgramDeploymentTransaction::new(
        token_methods::TOKEN_ELF.to_vec(),
        Ids::token_program(),
    );
    state
        .transition_from_program_deployment(&token_deploy)
        .expect("deploy token");
    let stablecoin_deploy = ProgramDeploymentTransaction::new(
        stablecoin_methods::STABLECOIN_ELF.to_vec(),
        Ids::stablecoin_program(),
    );
    state
        .transition_from_program_deployment(&stablecoin_deploy)
        .expect("deploy stablecoin");

    let collateral_definition = Accounts::collateral_definition_init();
    state.force_insert(
        collateral_definition.account_id,
        collateral_definition.account.clone(),
    );
    let stablecoin_def_id =
        stablecoin_core::compute_stablecoin_definition_pda(Ids::stablecoin_program());
    let oracle = Accounts::oracle_init(stablecoin_def_id, Ids::collateral_definition());
    state.force_insert(oracle.account_id, oracle.account.clone());

    let admin = Keys::admin();
    let admin_account_id = AccountId::from(&PublicKey::new_from_private_key(&admin));

    let old_fee = stablecoin_core::math::FIXED_POINT_ONE + 1_000_000_000_000_000;
    let init_instruction = stablecoin_core::Instruction::InitializeProgram {
        freeze_authority_account_id: Ids::freeze_authority(),
        initial_stability_fee_per_millisecond: old_fee,
        initial_controller_proportional_gain: 0,
        initial_controller_integral_gain: 0,
        initial_minimum_collateralization_ratio:
            stablecoin_core::math::FIXED_POINT_ONE * 3 / 2,
        minimum_milliseconds_between_rate_updates: 300_000,
        maximum_oracle_price_age_milliseconds: 900_000,
        initial_redemption_price: stablecoin_core::math::FIXED_POINT_ONE / 2,
        stablecoin_name: "test-stable".to_owned(),
    };
    let init_tx = public_transaction(
        Ids::stablecoin_program(),
        &init_instruction,
        vec![(admin_account_id, admin.clone())],
        vec![
            stablecoin_core::compute_protocol_parameters_pda(Ids::stablecoin_program()),
            stablecoin_core::compute_stability_fee_accumulator_pda(Ids::stablecoin_program()),
            stablecoin_core::compute_redemption_price_state_pda(Ids::stablecoin_program()),
            stablecoin_def_id,
            stablecoin_core::compute_stablecoin_master_holding_pda(Ids::stablecoin_program()),
            Ids::collateral_definition(),
            oracle.account_id,
        ],
    );
    state
        .transition_from_public_transaction(&init_tx)
        .expect("initialize_program");

    let pp_id = stablecoin_core::compute_protocol_parameters_pda(Ids::stablecoin_program());
    let acc_id = stablecoin_core::compute_stability_fee_accumulator_pda(Ids::stablecoin_program());

    // Read what the init wrote.
    let acc_before = state.account(acc_id).expect("accumulator present");
    let mut decoded_before =
        stablecoin_core::StabilityFeeAccumulator::try_from(&acc_before.data).expect("decode");

    // Simulate an elapsed dt = 3_600_000ms (1h) by rewinding `last_accrued_at` an hour.
    let dt = 3_600_000_u64;
    decoded_before.last_accrued_at = decoded_before.last_accrued_at.saturating_sub(dt);
    let mut updated_acc = acc_before.clone();
    updated_acc.data = Data::from(&decoded_before);
    state.force_insert(acc_id, updated_acc);

    let old_anchor = decoded_before.accumulated_rate_at_last_accrual;

    // Now run set_stability_fee_per_millisecond.
    let new_fee = stablecoin_core::math::FIXED_POINT_ONE + 5_000_000_000_000_000;
    let tx = public_transaction(
        Ids::stablecoin_program(),
        &stablecoin_core::Instruction::SetStabilityFeePerMillisecond {
            new_stability_fee_per_millisecond: new_fee,
        },
        vec![(admin_account_id, admin.clone())],
        vec![pp_id, acc_id],
    );
    state
        .transition_from_public_transaction(&tx)
        .expect("set_stability_fee_per_millisecond");

    // Read what the setter wrote.
    let acc_after = state.account(acc_id).expect("accumulator after");
    let decoded_after =
        stablecoin_core::StabilityFeeAccumulator::try_from(&acc_after.data).expect("decode");

    // Expected: anchor advanced at the OLD rate.
    let factor_old = stablecoin_core::math::compound_rate(old_fee, dt);
    let expected_anchor = stablecoin_core::math::mul_div(
        old_anchor,
        factor_old,
        stablecoin_core::math::FIXED_POINT_ONE,
    );
    assert_eq!(
        decoded_after.accumulated_rate_at_last_accrual, expected_anchor,
        "accumulator advanced at OLD rate, not new rate",
    );

    // Sanity: had we used the NEW rate, the anchor would be different.
    let factor_new = stablecoin_core::math::compound_rate(new_fee, dt);
    let wrong_anchor = stablecoin_core::math::mul_div(
        old_anchor,
        factor_new,
        stablecoin_core::math::FIXED_POINT_ONE,
    );
    assert_ne!(
        decoded_after.accumulated_rate_at_last_accrual, wrong_anchor,
        "if this fails the test setup is broken — OLD and NEW rate must produce \
         distinct compound factors over the elapsed dt",
    );

    // And ProtocolParameters now stores the new rate.
    let pp = state.account(pp_id).expect("ProtocolParameters present");
    let decoded_pp =
        stablecoin_core::ProtocolParameters::try_from(&pp.data).expect("decode ProtocolParameters");
    assert_eq!(decoded_pp.stability_fee_per_millisecond, new_fee);
}

If the existing programs/integration_tests/tests/stablecoin.rs doesn't already import Account, Nonce, Data, or twap_oracle_core, add them at the top:

use nssa_core::account::{Account, AccountId, AccountWithMetadata, Data, Nonce};
// ... existing imports
use twap_oracle_core;

Confirm twap_oracle_core is a dev-dependency of integration_tests (it should be after Plan 1 issue 08).

  • Step 5: Run the integration tests
RISC0_DEV_MODE=1 cargo test -p integration_tests --test stablecoin -- --nocapture stablecoin_admin_param_sweep
RISC0_DEV_MODE=1 cargo test -p integration_tests --test stablecoin -- --nocapture stablecoin_set_stability_fee_per_millisecond_auto_accrues

Expected: both PASS.

If transition_from_public_transaction returns an error, common causes:

  • Account ordering in the public_transaction mismatches the guest-fn parameter order — the param-sweep test passes [pp_id] (or [pp_id, oracle_id], etc.) and the guest expects the same order.

  • state.account(...) returning None immediately after a transition usually means the transition failed silently — re-check the expect line above.

  • The auto-accrue test fails on the assert_ne! sanity check if old_fee == new_fee — review the constants if so.

  • Step 6: Regenerate the IDL

make idl

Verify:

git diff artifacts/stablecoin-idl.json | head -120

You should see seven new instructions: SetStabilityFeePerMillisecond, SetMinimumCollateralizationRatio, SetControllerGains, SetMarketPriceOracle, SetTimingParameters, SetAdmin, SetFreezeAuthority. Re-run make idl and git diff once more — should be empty (deterministic).

  • Step 7: Full test sweep + lint
make clippy clippy-guest
RISC0_DEV_MODE=1 cargo test --workspace

Expected: green.

  • Step 8: 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): expose admin set_* guest entries + e2e tests

Adds the seven #[instruction] wrappers for set_stability_fee_per_millisecond,
set_minimum_collateralization_ratio, set_controller_gains,
set_market_price_oracle, set_timing_parameters, set_admin, and
set_freeze_authority, each forwarding ctx.self_program_id (and
ctx.now for the stability-fee setter) to the host functions landed in
Plan 4 issues 02-03.

Two end-to-end integration tests through the zkVM (RISC0_DEV_MODE=1):

- stablecoin_admin_param_sweep: initializes the protocol, runs each
  of the seven setters with a valid value (set_admin LAST, so the
  rotated admin doesn't lock out the test), reads ProtocolParameters,
  asserts every mutable field reflects the change AND the immutable
  fields (stablecoin_definition_id, collateral_definition_id,
  is_frozen) are untouched.

- stablecoin_set_stability_fee_per_millisecond_auto_accrues: initializes,
  manually rewinds StabilityFeeAccumulator.last_accrued_at by 3_600_000ms
  via force_insert to simulate elapsed time, runs the setter with a
  new rate, and asserts the new accumulator anchor equals
  old_anchor * compound_rate(OLD_rate, dt) / FIXED_POINT_ONE — NOT
  what would have come from compounding at the new rate. A defensive
  assert_ne! catches a future setup regression where OLD and NEW
  collapse to the same factor.

Regenerated artifacts/stablecoin-idl.json with all seven instructions.

Closes Plan 4 of the stablecoin RFP-013 roadmap. Plan 5 (emergency
freeze / unfreeze) builds on the assert_admin_authorized pattern with
a sibling assert_freeze_authority_authorized helper."

Notes for reviewer / future maintainers

  • set_admin runs LAST in the param-sweep test on purpose: once the admin handle rotates, the test's admin keypair can no longer sign anything (per spec §10.10–10.16). Doing it last lets the test verify the rotation took effect (read-back) without losing the ability to issue earlier setters.
  • The auto-accrue test uses force_insert to rewind last_accrued_at because we have no other way to simulate elapsed time in the test harness — ctx.now inside the guest is derived from the host's clock (Unix milliseconds), which the test doesn't directly control. The rewind is semantically equivalent to "the test has been running for 3_600_000ms (1h) of zkVM-internal time" but completes instantly.
  • The assert_ne! "sanity" assertion in the auto-accrue test guards against a future maintainer picking constants where OLD and NEW rate happen to compound to the same value over dt. If that ever fires, the right fix is to pick a larger gap between old_fee and new_fee.
  • The integration tests deliberately do NOT exercise failure paths. Those live in the host-function unit tests from issues 02–03. The integration test's job is to prove the wired-up flow goes through the zkVM end-to-end and that the Borsh round-trip preserves the data.
  • We do NOT add an integration test for the set_market_price_oracle rotation followed by a update_redemption_rate read against the new oracle, because update_redemption_rate is Plan 2 territory. Once Plans 2 + 4 are both landed, a future cross-plan integration test could exercise the full rotation+consume cycle.
  • The new-oracle account in the set_market_price_oracle step uses program_owner = [9u32; 8] — deliberately NOT a real twap_oracle program id, to confirm the host fn doesn't pin program_owner (per spec §10.13, "any producer that emits an OraclePriceAccount with the right shape works").

Dependencies

Depends on: #184, #185.

Blocks: Plan 5 (#187).

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