Skip to content

[Plan 2 · 06] Pokes guest entries + integration tests + IDL regen #172

Description

@gravityblast

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

  • Step 1: Add the guest entries

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.

  • Step 2: Add the accrue_stability_fee integration test

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.)

  • Step 3: Add the update_redemption_rate integration test

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.)

  • Step 3a: Add the refresh_globals integration test

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.)

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

Expected: PASS for both new tests plus all existing tests.

  • Step 5: Regenerate the IDL
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).

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

Expected: 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 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).

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