Issue 06 — Pokes guest entries + integration tests + IDL regen
Plan: Stablecoin Plan 2 — Permissionless pokes
Depends on: Plan 2 issues 04 + 05 + 5a.
Blocks: Plan 3 lifecycle integration tests (which rely on these pokes running mid-flow).
Goal: Expose accrue_stability_fee, update_redemption_rate, and refresh_globals as guest-callable #[instruction]s, write end-to-end integration tests that run through the zkVM, and regenerate artifacts/stablecoin-idl.json.
Files
- Modify:
programs/stablecoin/methods/guest/src/bin/stablecoin.rs — three new #[instruction] entries.
- Modify:
programs/integration_tests/tests/stablecoin.rs — three new integration tests.
- Modify:
artifacts/stablecoin-idl.json — regenerated by make idl.
Acceptance criteria
make clippy-guest is green.
RISC0_DEV_MODE=1 cargo test -p integration_tests --test stablecoin -- --nocapture passes including the three new tests.
make idl produces deterministic output (running it twice yields no diff).
- The IDL contains
AccrueStabilityFee, UpdateRedemptionRate, and RefreshGlobals instruction entries.
Implementation steps
In programs/stablecoin/methods/guest/src/bin/stablecoin.rs, inside the #[lez_program] module, add (place after initialize_program and before open_position):
/// Permissionless poke: advance the stability-fee accumulator
/// (see spec §10.2; host fn `stablecoin_program::accrue_stability_fee`).
#[instruction]
pub fn accrue_stability_fee(
ctx: ProgramContext,
caller: AccountWithMetadata,
protocol_parameters: AccountWithMetadata,
stability_fee_accumulator: AccountWithMetadata,
) -> SpelResult {
let (post_states, chained_calls) = stablecoin_program::accrue_stability_fee::accrue_stability_fee(
caller,
protocol_parameters,
stability_fee_accumulator,
ctx.self_program_id,
ctx.now,
);
Ok(spel_framework::SpelOutput::execute(post_states, chained_calls))
}
/// Permissionless poke: run one PI controller tick and re-anchor the
/// redemption price (see spec §10.3; host fn
/// `stablecoin_program::update_redemption_rate`).
#[instruction]
pub fn update_redemption_rate(
ctx: ProgramContext,
caller: AccountWithMetadata,
protocol_parameters: AccountWithMetadata,
redemption_price_state: AccountWithMetadata,
market_price_oracle: AccountWithMetadata,
) -> SpelResult {
let (post_states, chained_calls) = stablecoin_program::update_redemption_rate::update_redemption_rate(
caller,
protocol_parameters,
redemption_price_state,
market_price_oracle,
ctx.self_program_id,
ctx.now,
);
Ok(spel_framework::SpelOutput::execute(post_states, chained_calls))
}
/// Permissionless combined poke: always accrue the stability fee, and run
/// the PI controller redemption update too if its interval is due and the
/// oracle is fresh (best-effort, skips rather than panics). A LEZ transaction
/// carries one instruction, so this is the only way to advance both globals
/// at once (see spec §10.3a; host fn `stablecoin_program::refresh_globals`).
#[instruction]
pub fn refresh_globals(
ctx: ProgramContext,
caller: AccountWithMetadata,
protocol_parameters: AccountWithMetadata,
stability_fee_accumulator: AccountWithMetadata,
redemption_price_state: AccountWithMetadata,
market_price_oracle: AccountWithMetadata,
) -> SpelResult {
let (post_states, chained_calls) = stablecoin_program::refresh_globals::refresh_globals(
caller,
protocol_parameters,
stability_fee_accumulator,
redemption_price_state,
market_price_oracle,
ctx.self_program_id,
ctx.now,
);
Ok(spel_framework::SpelOutput::execute(post_states, chained_calls))
}
Run make clippy-guest. Expected: green.
In programs/integration_tests/tests/stablecoin.rs, after the stablecoin_initialize_program_creates_globals_and_stablecoin_definition test from Plan 1 issue 08, append:
#[test]
fn stablecoin_accrue_stability_fee_advances_accumulator() {
let mut state = V03State::new();
// 1. Deploy + initialize (reuse the helpers from the init test).
deploy_and_initialize(&mut state);
// 2. Advance simulated time. The test harness's clock API depends on the
// pinned nssa version; the existing init test demonstrates the pattern.
// accrue has no throttle, so any positive delta works; we use 1h (in ms).
let now_after = state.clock_millis() + 3_600_000;
state.set_clock_millis(now_after);
// 3. Sign a permissionless poke from any authorized account. Reuse the admin
// key for convenience; in production a keeper bot uses its own.
let admin = Keys::admin();
let admin_account_id = AccountId::from(&PublicKey::new_from_private_key(&admin));
let tx = public_transaction(
Ids::stablecoin_program(),
&stablecoin_core::Instruction::AccrueStabilityFee,
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()),
],
);
state.transition_from_public_transaction(&tx).expect("accrue should succeed");
let acc_after = state.account(
stablecoin_core::compute_stability_fee_accumulator_pda(Ids::stablecoin_program()),
).expect("accumulator present");
let decoded = stablecoin_core::StabilityFeeAccumulator::try_from(&acc_after.data).expect("decode");
assert!(decoded.accumulated_rate_at_last_accrual >= stablecoin_core::math::FIXED_POINT_ONE);
assert_eq!(decoded.last_accrued_at, now_after);
}
(deploy_and_initialize, state.clock_millis(), and state.set_clock_millis(...) are helpers — extract deploy_and_initialize from the init test if it isn't already a helper. ProgramContext::now is in milliseconds, so the clock helpers are ms-denominated. The clock API on V03State must be checked against the pinned nssa version; if the field is named differently, adapt.)
Append:
#[test]
fn stablecoin_update_redemption_rate_drifts_redemption_price() {
let mut state = V03State::new();
deploy_and_initialize(&mut state);
// Update the oracle with a fresh observation that diverges from the initial
// redemption price.
let new_oracle = oracle_account_with_price(
stablecoin_core::compute_stablecoin_definition_pda(Ids::stablecoin_program()),
Ids::collateral_definition(),
stablecoin_core::math::FIXED_POINT_ONE / 4, // market = 0.25, target = 0.5 — large gap
state.clock_millis(),
);
state.force_insert(new_oracle.account_id, new_oracle.account.clone());
// Wait the min interval (minimum_milliseconds_between_rate_updates = 300_000ms).
let now_after = state.clock_millis() + 600_000;
state.set_clock_millis(now_after);
let admin = Keys::admin();
let admin_account_id = AccountId::from(&PublicKey::new_from_private_key(&admin));
let tx = public_transaction(
Ids::stablecoin_program(),
&stablecoin_core::Instruction::UpdateRedemptionRate,
vec![(admin_account_id, admin.clone())],
vec![
stablecoin_core::compute_protocol_parameters_pda(Ids::stablecoin_program()),
stablecoin_core::compute_redemption_price_state_pda(Ids::stablecoin_program()),
new_oracle.account_id,
],
);
state.transition_from_public_transaction(&tx).expect("update should succeed");
let rp_after = state.account(
stablecoin_core::compute_redemption_price_state_pda(Ids::stablecoin_program()),
).expect("redemption state present");
let decoded = stablecoin_core::RedemptionPriceState::try_from(&rp_after.data).expect("decode");
// The controller saw redemption > market (0.5 > 0.25) => error > 0 with positive Kp,
// so the new rate is ABOVE FIXED_POINT_ONE: the redemption price rises, pulling the
// market UP toward the target (negative feedback; no negation, matching RAI).
assert!(decoded.redemption_rate_per_millisecond > stablecoin_core::math::FIXED_POINT_ONE);
assert_eq!(decoded.last_updated_at, now_after);
}
(oracle_account_with_price extends the existing Accounts::oracle_init helper to take a custom price + timestamp; add it to the Accounts impl in the same file.)
Append. This test exercises the combined poke twice: once where both halves run, and once with a stale oracle where only the fee half runs (the redemption half skips without panicking).
#[test]
fn stablecoin_refresh_globals_advances_both_then_fee_only_when_oracle_stale() {
let mut state = V03State::new();
deploy_and_initialize(&mut state);
let admin = Keys::admin();
let admin_account_id = AccountId::from(&PublicKey::new_from_private_key(&admin));
// --- Pass 1: fresh oracle, interval due => BOTH halves run. ---
let fresh_oracle = oracle_account_with_price(
stablecoin_core::compute_stablecoin_definition_pda(Ids::stablecoin_program()),
Ids::collateral_definition(),
stablecoin_core::math::FIXED_POINT_ONE / 4, // market 0.25 < target 0.5
state.clock_millis(),
);
state.force_insert(fresh_oracle.account_id, fresh_oracle.account.clone());
let now1 = state.clock_millis() + 600_000; // > rate interval AND fee accrues regardless
state.set_clock_millis(now1);
let tx1 = public_transaction(
Ids::stablecoin_program(),
&stablecoin_core::Instruction::RefreshGlobals,
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()),
fresh_oracle.account_id,
],
);
state.transition_from_public_transaction(&tx1).expect("refresh (both halves) should succeed");
let acc1 = stablecoin_core::StabilityFeeAccumulator::try_from(
&state.account(stablecoin_core::compute_stability_fee_accumulator_pda(Ids::stablecoin_program())).unwrap().data,
).unwrap();
let rp1 = stablecoin_core::RedemptionPriceState::try_from(
&state.account(stablecoin_core::compute_redemption_price_state_pda(Ids::stablecoin_program())).unwrap().data,
).unwrap();
// Fee half ran: accumulator re-stamped to now1.
assert_eq!(acc1.last_accrued_at, now1);
// Redemption half ran: rate moved above 1.0 (redemption > market => error > 0) and re-stamped.
assert!(rp1.redemption_rate_per_millisecond > stablecoin_core::math::FIXED_POINT_ONE);
assert_eq!(rp1.last_updated_at, now1);
// --- Pass 2: STALE oracle => only the fee half runs; redemption half SKIPS (no panic). ---
// Leave the oracle timestamp where it was (now1) and advance the clock well past
// maximum_oracle_price_age_milliseconds so the observation is stale.
let now2 = now1 + 2_000_000; // 2_000_000ms > 900_000ms max age
state.set_clock_millis(now2);
let tx2 = public_transaction(
Ids::stablecoin_program(),
&stablecoin_core::Instruction::RefreshGlobals,
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()),
fresh_oracle.account_id,
],
);
// Best-effort: a stale oracle does NOT make refresh_globals fail.
state.transition_from_public_transaction(&tx2).expect("refresh should still succeed (fee only)");
let acc2 = stablecoin_core::StabilityFeeAccumulator::try_from(
&state.account(stablecoin_core::compute_stability_fee_accumulator_pda(Ids::stablecoin_program())).unwrap().data,
).unwrap();
let rp2 = stablecoin_core::RedemptionPriceState::try_from(
&state.account(stablecoin_core::compute_redemption_price_state_pda(Ids::stablecoin_program())).unwrap().data,
).unwrap();
// Fee half still ran: accumulator advanced to now2.
assert_eq!(acc2.last_accrued_at, now2);
// Redemption half SKIPPED: last_updated_at stayed at now1, unchanged from pass 1.
assert_eq!(rp2.last_updated_at, now1);
}
(Adapt clock + insertion helpers to the pinned nssa version as in the other tests. The key assertions: pass 1 advances BOTH last_accrued_at and last_updated_at; pass 2, with a stale oracle, advances last_accrued_at only — last_updated_at is frozen, proving the redemption half skipped without panicking.)
RISC0_DEV_MODE=1 cargo test -p integration_tests --test stablecoin -- --nocapture
Expected: PASS for both new tests plus all existing tests.
make idl
git diff artifacts/stablecoin-idl.json | head -40
You should see AccrueStabilityFee, UpdateRedemptionRate, and RefreshGlobals added to the instruction list. Run make idl a second time and confirm no further diff (deterministic).
make clippy clippy-guest
RISC0_DEV_MODE=1 cargo test --workspace
Expected: 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): wire poke guest entries + e2e tests
Three new #[instruction] entries (AccrueStabilityFee, UpdateRedemptionRate,
RefreshGlobals) forwarding ctx.self_program_id + ctx.now to the Plan 2
host functions.
Three integration tests through the zkVM (RISC0_DEV_MODE=1):
- stablecoin_accrue_stability_fee_advances_accumulator: deploys +
initializes, fast-forwards 1h, runs the poke, asserts the accumulator
grew and last_accrued_at moved.
- stablecoin_update_redemption_rate_drifts_redemption_price: deploys +
initializes, force-inserts an oracle with market < target, runs the
poke, asserts the new redemption rate is above FIXED_POINT_ONE (the
redemption price rises, pulling the market UP) and last_updated_at moved.
- stablecoin_refresh_globals_advances_both_then_fee_only_when_oracle_stale:
one call advances both globals; a second call with a stale oracle still
accrues the fee but skips the redemption half without panicking.
Regenerated artifacts/stablecoin-idl.json with all three new instructions.
Closes Plan 2 of the stablecoin RFP-013 roadmap."
Notes for reviewer / future maintainers
- The clock API on
V03State (state.clock_millis() / state.set_clock_millis(...)) is illustrative; the actual method names depend on the pinned nssa version. ProgramContext::now is in milliseconds, so the clock helpers and every time delta in these tests are ms-denominated. Confirm during implementation.
deploy_and_initialize should be extracted from Plan 1 issue 08's test into a shared helper if it isn't already — Plan 2, 3, 4, 5 integration tests all need the same warm-start.
- Pokes are NEVER blocked when frozen; all three integration tests run against an unfrozen protocol but the host functions themselves don't read
is_frozen, so a frozen-state version of these tests would also pass.
refresh_globals is best-effort: the stale-oracle pass proves it does NOT panic when the redemption half can't run, only skipping that half while the fee half always advances. This is the opposite of standalone update_redemption_rate, which loud-fails on a stale oracle.
Dependencies
Depends on: #169, #170, #171.
Blocks: Plan 3 lifecycle integration test (#181).
Issue 06 — Pokes guest entries + integration tests + IDL regen
Plan: Stablecoin Plan 2 — Permissionless pokes
Depends on: Plan 2 issues 04 + 05 + 5a.
Blocks: Plan 3 lifecycle integration tests (which rely on these pokes running mid-flow).
Goal: Expose
accrue_stability_fee,update_redemption_rate, andrefresh_globalsas guest-callable#[instruction]s, write end-to-end integration tests that run through the zkVM, and regenerateartifacts/stablecoin-idl.json.Files
programs/stablecoin/methods/guest/src/bin/stablecoin.rs— three new#[instruction]entries.programs/integration_tests/tests/stablecoin.rs— three new integration tests.artifacts/stablecoin-idl.json— regenerated bymake idl.Acceptance criteria
make clippy-guestis green.RISC0_DEV_MODE=1 cargo test -p integration_tests --test stablecoin -- --nocapturepasses including the three new tests.make idlproduces deterministic output (running it twice yields no diff).AccrueStabilityFee,UpdateRedemptionRate, andRefreshGlobalsinstruction entries.Implementation steps
In
programs/stablecoin/methods/guest/src/bin/stablecoin.rs, inside the#[lez_program]module, add (place afterinitialize_programand beforeopen_position):Run
make clippy-guest. Expected: green.accrue_stability_feeintegration testIn
programs/integration_tests/tests/stablecoin.rs, after thestablecoin_initialize_program_creates_globals_and_stablecoin_definitiontest from Plan 1 issue 08, append:(
deploy_and_initialize,state.clock_millis(), andstate.set_clock_millis(...)are helpers — extractdeploy_and_initializefrom the init test if it isn't already a helper.ProgramContext::nowis in milliseconds, so the clock helpers are ms-denominated. The clock API onV03Statemust be checked against the pinnednssaversion; if the field is named differently, adapt.)update_redemption_rateintegration testAppend:
(
oracle_account_with_priceextends the existingAccounts::oracle_inithelper to take a custom price + timestamp; add it to theAccountsimpl in the same file.)refresh_globalsintegration testAppend. This test exercises the combined poke twice: once where both halves run, and once with a stale oracle where only the fee half runs (the redemption half skips without panicking).
(Adapt clock + insertion helpers to the pinned
nssaversion as in the other tests. The key assertions: pass 1 advances BOTHlast_accrued_atandlast_updated_at; pass 2, with a stale oracle, advanceslast_accrued_atonly —last_updated_atis frozen, proving the redemption half skipped without panicking.)RISC0_DEV_MODE=1 cargo test -p integration_tests --test stablecoin -- --nocaptureExpected: PASS for both new tests plus all existing tests.
make idl git diff artifacts/stablecoin-idl.json | head -40You should see
AccrueStabilityFee,UpdateRedemptionRate, andRefreshGlobalsadded to the instruction list. Runmake idla second time and confirm no further diff (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): wire poke guest entries + e2e tests Three new #[instruction] entries (AccrueStabilityFee, UpdateRedemptionRate, RefreshGlobals) forwarding ctx.self_program_id + ctx.now to the Plan 2 host functions. Three integration tests through the zkVM (RISC0_DEV_MODE=1): - stablecoin_accrue_stability_fee_advances_accumulator: deploys + initializes, fast-forwards 1h, runs the poke, asserts the accumulator grew and last_accrued_at moved. - stablecoin_update_redemption_rate_drifts_redemption_price: deploys + initializes, force-inserts an oracle with market < target, runs the poke, asserts the new redemption rate is above FIXED_POINT_ONE (the redemption price rises, pulling the market UP) and last_updated_at moved. - stablecoin_refresh_globals_advances_both_then_fee_only_when_oracle_stale: one call advances both globals; a second call with a stale oracle still accrues the fee but skips the redemption half without panicking. Regenerated artifacts/stablecoin-idl.json with all three new instructions. Closes Plan 2 of the stablecoin RFP-013 roadmap."Notes for reviewer / future maintainers
V03State(state.clock_millis()/state.set_clock_millis(...)) is illustrative; the actual method names depend on the pinnednssaversion.ProgramContext::nowis in milliseconds, so the clock helpers and every time delta in these tests are ms-denominated. Confirm during implementation.deploy_and_initializeshould be extracted from Plan 1 issue 08's test into a shared helper if it isn't already — Plan 2, 3, 4, 5 integration tests all need the same warm-start.is_frozen, so a frozen-state version of these tests would also pass.refresh_globalsis best-effort: the stale-oracle pass proves it does NOT panic when the redemption half can't run, only skipping that half while the fee half always advances. This is the opposite of standaloneupdate_redemption_rate, which loud-fails on a stale oracle.Dependencies
Depends on: #169, #170, #171.
Blocks: Plan 3 lifecycle integration test (#181).