You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
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.
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:
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.
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.
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]pubfnset_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]pubfnset_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]pubfnset_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]pubfnset_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]pubfnset_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]pubfnset_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]pubfnset_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]fnstablecoin_admin_param_sweep(){letmut 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. SetStabilityFeePerMillisecondlet 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. SetMinimumCollateralizationRatiolet 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. SetControllerGainslet 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. SetTimingParameterslet 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]fnstablecoin_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`.letmut 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");letmut 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);letmut 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 importsuse 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 testsAdds 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, andset_freeze_authority, each forwarding ctx.self_program_id (andctx.now for the stability-fee setter) to the host functions landed inPlan 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 (emergencyfreeze / unfreeze) builds on the assert_admin_authorized pattern witha 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").
Issue 04 — Guest wiring + integration tests + IDL regen
Plan: Stablecoin Plan 4 — Admin parameter updates
Depends on: 02 (
set_stability_fee_per_millisecondhost fn), 03 (the other six host fns).Blocks: Plan 5 (the
freeze/unfreezeguest 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 underRISC0_DEV_MODE=1, and regenerateartifacts/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: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 readProtocolParametersand confirm every field reflects the latest change.stablecoin_set_stability_fee_per_millisecond_auto_accrues— pins the load-bearing property of the special setter: after an elapsed simulated timedt, 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 forwardsctx.nowcorrectly, the Borsh round-trip survives the zkVM) doesn't break the property.IDL regen keeps
artifacts/stablecoin-idl.jsonin sync; CI checks it.Files
programs/stablecoin/methods/guest/src/bin/stablecoin.rs— add seven#[instruction]entries.programs/integration_tests/tests/stablecoin.rs— two new end-to-end tests + supporting helpers.artifacts/stablecoin-idl.json— regenerated bymake idl.Acceptance criteria
cargo build --manifest-path programs/stablecoin/methods/guest/Cargo.tomlsucceeds.make clippy-guestis green.RISC0_DEV_MODE=1 cargo test -p integration_tests --test stablecoin -- --nocapture stablecoin_admin_param_sweeppasses.RISC0_DEV_MODE=1 cargo test -p integration_tests --test stablecoin -- --nocapture stablecoin_set_stability_fee_per_millisecond_auto_accruespasses.make idlregeneratesartifacts/stablecoin-idl.jsonwithout diff after a clean re-run (deterministic).SetStabilityFeePerMillisecond,SetMinimumCollateralizationRatio,SetControllerGains,SetMarketPriceOracle,SetTimingParameters,SetAdmin,SetFreezeAuthority) with their parameters in the expected order.Implementation steps
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_millisecondfirst, then the rest in §10.11–§10.16 order):(
AccountIdis imported via the fully-qualified pathnssa_core::account::AccountIdto avoid a top-of-fileusechange. If the existing guest file already pullsAccountIdinto scope, use the bare name.)Expected: green.
In
programs/integration_tests/tests/stablecoin.rs, append after the existing tests (this test depends on the Plan 1 issue 08stablecoin_initialize_program_creates_globals_and_stablecoin_definitionhelpers —Keys::admin,Ids::freeze_authority,Accounts::oracle_init):Append:
If the existing
programs/integration_tests/tests/stablecoin.rsdoesn't already importAccount,Nonce,Data, ortwap_oracle_core, add them at the top:Confirm
twap_oracle_coreis a dev-dependency ofintegration_tests(it should be after Plan 1 issue 08).Expected: both PASS.
If
transition_from_public_transactionreturns 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(...)returningNoneimmediately after a transition usually means the transition failed silently — re-check theexpectline above.The auto-accrue test fails on the
assert_ne!sanity check ifold_fee == new_fee— review the constants if so.Step 6: Regenerate the IDL
Verify:
git diff artifacts/stablecoin-idl.json | head -120You should see seven new instructions:
SetStabilityFeePerMillisecond,SetMinimumCollateralizationRatio,SetControllerGains,SetMarketPriceOracle,SetTimingParameters,SetAdmin,SetFreezeAuthority. Re-runmake idlandgit diffonce more — should be empty (deterministic).make clippy clippy-guest RISC0_DEV_MODE=1 cargo test --workspaceExpected: green.
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_adminruns LAST in the param-sweep test on purpose: once the admin handle rotates, the test'sadminkeypair 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.force_insertto rewindlast_accrued_atbecause we have no other way to simulate elapsed time in the test harness —ctx.nowinside 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.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 overdt. If that ever fires, the right fix is to pick a larger gap betweenold_feeandnew_fee.set_market_price_oraclerotation followed by aupdate_redemption_rateread against the new oracle, becauseupdate_redemption_rateis Plan 2 territory. Once Plans 2 + 4 are both landed, a future cross-plan integration test could exercise the full rotation+consume cycle.set_market_price_oraclestep usesprogram_owner = [9u32; 8]— deliberately NOT a realtwap_oracleprogram id, to confirm the host fn doesn't pinprogram_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).