Skip to content

[Plan 2 · 05a] refresh_globals host function #171

Description

@gravityblast

Issue 05a — refresh_globals host function

Plan: Stablecoin Plan 2 — Permissionless pokes
Depends on: Plan 2 issues 01 (projections), 02 (controller), 03 (RefreshGlobals variant), 04 (accrue_stability_fee), 05 (update_redemption_rate).
Blocks: Plan 2 issue 06 (guest wiring).

Goal: Implement the refresh_globals host function — the combined best-effort poke that advances both globals in one instruction. It always performs the fee accrual (like accrue_stability_fee) and performs the redemption update (like update_redemption_rate) only if that half's interval is due and the oracle is fresh & non-zero, skipping rather than panicking otherwise.

Spec reference

§10.3a refresh_globals (combined poke). Also §10.2 and §10.3 (the two halves it composes), §5.3 (projections), §6.4 (PI controller).

Why

A LEZ transaction carries exactly one instruction (a Message has one program_id + one instruction_data), so the only way to advance both the fee accumulator and the redemption rate in a single transaction is one instruction that does both. refresh_globals is that instruction — the common-case keeper convenience. The two standalone pokes are kept for the cases where best-effort is the wrong semantics:

  • accrue_stability_fee — the oracle-free minimal path (smaller account set, guaranteed to work during an oracle outage).
  • update_redemption_rate — strict and loud-failing: it panics on a stale / zero oracle, which a keeper may want as an explicit failure signal rather than a silent skip.

refresh_globals trades that strictness for "always make whatever progress is available": it never fails just because the redemption half isn't due.

Architecture

A new programs/stablecoin/src/refresh_globals.rs module with pub fn refresh_globals(...). The function:

  1. Validates the caller and all four program accounts (protocol_parameters read, stability_fee_accumulator write, redemption_price_state write, market_price_oracle read), including the oracle id match.
  2. ALWAYS computes the fee-accumulator advance (the §10.2 math).
  3. Computes whether the redemption half is eligible — interval due and oracle fresh and oracle.price > 0 — and if so runs the PI tick and re-anchors RedemptionPriceState (the §10.3 math); otherwise leaves RedemptionPriceState untouched.
  4. Emits post-states for all accounts. The accumulator post always reflects the advance; the redemption post is the updated state when the half ran, or the unchanged state when it skipped.

No chained calls. It reuses the same projection + controller helpers as issues 04 and 05; consider extracting the shared accrue/update bodies into small internal helpers so the three host functions don't duplicate the math. (If 04 / 05 already expose internal compute_* helpers, call them here; otherwise inline, keeping the logic byte-for-byte identical to the standalone pokes.)

Files

  • Create: programs/stablecoin/src/refresh_globals.rs
  • Modify: programs/stablecoin/src/lib.rspub mod refresh_globals;
  • Create: programs/stablecoin/src/tests/refresh_globals_tests.rs
  • Modify: programs/stablecoin/src/tests/mod.rs — register the new test module.

Acceptance criteria

  • cargo test -p stablecoin_program refresh_globals_tests:: passes.
  • make clippy is green.
  • Both halves run when the redemption interval is due AND the oracle is fresh & non-zero: accumulator advances per §10.2; RedemptionPriceState re-anchors per §10.3 / §6.4; both timestamps become now.
  • Redemption half skipped when interval not elapsed: accumulator still advances; RedemptionPriceState unchanged (last_updated_at stays put); NO panic.
  • Redemption half skipped when oracle stale: accumulator still advances; RedemptionPriceState unchanged; NO panic.
  • Redemption half skipped when oracle.price == 0: accumulator still advances; RedemptionPriceState unchanged; NO panic.
  • Fee half ALWAYS runs (in every test above).
  • Works when frozen (is_frozen = true): both halves still eligible to run.
  • Panics ONLY on: caller not authorized; any of protocol_parameters / stability_fee_accumulator / redemption_price_state uninitialized or wrong owner; oracle id mismatch.

Implementation steps

  • Step 1: Skeleton the host function

programs/stablecoin/src/refresh_globals.rs:

//! Host-side implementation of `Instruction::RefreshGlobals` (spec §10.3a).
//!
//! Best-effort combined poke: ALWAYS advances the stability-fee accumulator
//! (§10.2); advances the redemption price (§10.3 / §6.4) ONLY when its interval
//! is due and the oracle is fresh & non-zero, skipping rather than panicking
//! otherwise. A LEZ transaction carries one instruction, so this is the only way
//! to advance both globals in a single transaction.

use nssa_core::{
    account::{Account, AccountWithMetadata, Data},
    program::{AccountPostState, ChainedCall, ProgramId},
};
use stablecoin_core::{
    compute_redemption_price_state_pda, compute_stability_fee_accumulator_pda,
    controller::{run_pi_tick, PiOutput},
    math::{compute_current_accumulated_rate, compute_current_redemption_price},
    ProtocolParameters, RedemptionPriceState, StabilityFeeAccumulator,
};
use twap_oracle_core::OraclePriceAccount;

#[allow(clippy::needless_pass_by_value)]
pub fn refresh_globals(
    caller: AccountWithMetadata,
    protocol_parameters: AccountWithMetadata,
    stability_fee_accumulator: AccountWithMetadata,
    redemption_price_state: AccountWithMetadata,
    market_price_oracle: AccountWithMetadata,
    stablecoin_program_id: ProgramId,
    now: u64,
) -> (Vec<AccountPostState>, Vec<ChainedCall>) {
    // 1. Authorization
    assert!(caller.is_authorized, "Caller authorization is missing");

    // 2. Program globals initialized + correctly owned (these ALWAYS panic on failure).
    assert_ne!(
        protocol_parameters.account, Account::default(),
        "ProtocolParameters account must be initialized"
    );
    assert_eq!(
        protocol_parameters.account.program_owner, stablecoin_program_id,
        "ProtocolParameters not owned by this stablecoin program"
    );
    assert_ne!(
        stability_fee_accumulator.account, Account::default(),
        "StabilityFeeAccumulator account must be initialized"
    );
    assert_eq!(
        stability_fee_accumulator.account.program_owner, stablecoin_program_id,
        "StabilityFeeAccumulator not owned by this stablecoin program"
    );
    assert_eq!(
        stability_fee_accumulator.account_id,
        compute_stability_fee_accumulator_pda(stablecoin_program_id),
        "StabilityFeeAccumulator account ID does not match expected PDA derivation"
    );
    assert_ne!(
        redemption_price_state.account, Account::default(),
        "RedemptionPriceState account must be initialized"
    );
    assert_eq!(
        redemption_price_state.account.program_owner, stablecoin_program_id,
        "RedemptionPriceState not owned by this stablecoin program"
    );
    assert_eq!(
        redemption_price_state.account_id,
        compute_redemption_price_state_pda(stablecoin_program_id),
        "RedemptionPriceState account ID does not match expected PDA derivation"
    );

    let params = ProtocolParameters::try_from(&protocol_parameters.account.data)
        .expect("ProtocolParameters must decode");
    let accumulator = StabilityFeeAccumulator::try_from(&stability_fee_accumulator.account.data)
        .expect("StabilityFeeAccumulator must decode");
    let redemption = RedemptionPriceState::try_from(&redemption_price_state.account.data)
        .expect("RedemptionPriceState must decode");

    // 3. Oracle: the id MUST match (this panics). Freshness / non-zero are SOFT gates
    //    that only decide whether the redemption half runs — they do NOT panic here.
    assert_ne!(
        market_price_oracle.account, Account::default(),
        "Market price oracle account must be initialized"
    );
    assert_eq!(
        market_price_oracle.account_id, params.market_price_oracle_id,
        "Market price oracle account_id does not match ProtocolParameters.market_price_oracle_id"
    );
    let oracle = OraclePriceAccount::try_from(&market_price_oracle.account.data)
        .expect("Market price oracle must decode as OraclePriceAccount");

    // 4. Fee half — ALWAYS runs (no throttle; idempotent, §10.2).
    let new_anchor = compute_current_accumulated_rate(
        accumulator.accumulated_rate_at_last_accrual,
        params.stability_fee_per_millisecond,
        accumulator.last_accrued_at,
        now,
    );
    let mut accumulator_post = stability_fee_accumulator.account.clone();
    accumulator_post.data = Data::from(&StabilityFeeAccumulator {
        accumulated_rate_at_last_accrual: new_anchor,
        last_accrued_at: now,
    });

    // 5. Redemption half — eligibility gates (interval due AND oracle fresh AND price > 0).
    //    All three are SOFT: failing any of them SKIPS the half (no panic).
    let interval_due =
        now.saturating_sub(redemption.last_updated_at) >= params.minimum_milliseconds_between_rate_updates;
    let oracle_fresh =
        now.saturating_sub(oracle.timestamp) <= params.maximum_oracle_price_age_milliseconds;
    let oracle_nonzero = oracle.price > 0;

    let mut redemption_post = redemption_price_state.account.clone();
    if interval_due && oracle_fresh && oracle_nonzero {
        // Same math as §10.3 / issue 05.
        let dt = now.saturating_sub(redemption.last_updated_at);
        let current_price = compute_current_redemption_price(
            redemption.redemption_price_at_last_update,
            redemption.redemption_rate_per_millisecond,
            redemption.last_updated_at,
            now,
        );
        let PiOutput { new_rate, new_integral } = run_pi_tick(
            current_price,
            oracle.price,
            redemption.controller_integral_term,
            params.controller_proportional_gain,
            params.controller_integral_gain,
            dt,
        );
        redemption_post.data = Data::from(&RedemptionPriceState {
            redemption_price_at_last_update: current_price,
            redemption_rate_per_millisecond: new_rate,
            controller_integral_term: new_integral,
            last_updated_at: now,
        });
    }
    // else: leave redemption_post == the original (unchanged) state.

    let post_states = vec![
        AccountPostState::new(caller.account),
        AccountPostState::new(protocol_parameters.account.clone()),
        AccountPostState::new(accumulator_post),
        AccountPostState::new(redemption_post),
        AccountPostState::new(market_price_oracle.account.clone()),
    ];

    (post_states, vec![])
}
  • Step 2: Wire the module
// programs/stablecoin/src/lib.rs
pub mod refresh_globals;

twap_oracle_core was already added to programs/stablecoin/Cargo.toml in issue 05. Run cargo check -p stablecoin_program. Expected: clean.

  • Step 3: Test scaffolding

Create programs/stablecoin/src/tests/refresh_globals_tests.rs. This reuses the same account-builder shape as the issue 04 / 05 tests; factor shared builders into a test helper if convenient.

#![allow(
    clippy::indexing_slicing,
    clippy::panic,
    clippy::unwrap_used,
    reason = "test deliberately panics on bad state via assert!/#[should_panic]"
)]

use nssa_core::{
    account::{Account, AccountId, AccountWithMetadata, Data, Nonce},
    program::ProgramId,
};
use stablecoin_core::{
    compute_protocol_parameters_pda, compute_redemption_price_state_pda,
    compute_stability_fee_accumulator_pda,
    math::FIXED_POINT_ONE,
    ProtocolParameters, RedemptionPriceState, StabilityFeeAccumulator,
};
use twap_oracle_core::OraclePriceAccount;

use stablecoin_program::refresh_globals::refresh_globals;

const STABLECOIN_PROGRAM_ID: ProgramId = [3u32; 8];
const ORACLE_PROGRAM_ID: ProgramId = [4u32; 8];
// Timestamps are Unix milliseconds (ProgramContext::now).
const T0: u64 = 1_700_000_000_000;
const NOW: u64 = T0 + 600_000; // +10min in ms (> the 300_000ms rate-update interval)

fn caller_id() -> AccountId { AccountId::new([0xCA; 32]) }
fn admin_id() -> AccountId { AccountId::new([0xA0; 32]) }
fn freeze_id() -> AccountId { AccountId::new([0xFE; 32]) }
fn stablecoin_def_id() -> AccountId { AccountId::new([0x10; 32]) }
fn collateral_def_id() -> AccountId { AccountId::new([0x20; 32]) }
fn oracle_id() -> AccountId { AccountId::new([0x30; 32]) }

fn protocol_parameters_id() -> AccountId { compute_protocol_parameters_pda(STABLECOIN_PROGRAM_ID) }
fn accumulator_id() -> AccountId { compute_stability_fee_accumulator_pda(STABLECOIN_PROGRAM_ID) }
fn redemption_id() -> AccountId { compute_redemption_price_state_pda(STABLECOIN_PROGRAM_ID) }

fn caller_account() -> AccountWithMetadata {
    AccountWithMetadata { account: Account::default(), is_authorized: true, account_id: caller_id() }
}

fn protocol_parameters_account(is_frozen: bool) -> AccountWithMetadata {
    AccountWithMetadata {
        account: Account {
            program_owner: STABLECOIN_PROGRAM_ID, balance: 0,
            data: Data::from(&ProtocolParameters {
                admin_account_id: admin_id(),
                freeze_authority_account_id: freeze_id(),
                stablecoin_definition_id: stablecoin_def_id(),
                collateral_definition_id: collateral_def_id(),
                market_price_oracle_id: oracle_id(),
                stability_fee_per_millisecond: FIXED_POINT_ONE + 10u128.pow(20),
                controller_proportional_gain: FIXED_POINT_ONE as i128,
                controller_integral_gain: 0,
                minimum_collateralization_ratio: FIXED_POINT_ONE * 3 / 2,
                minimum_milliseconds_between_rate_updates: 300_000,
                maximum_oracle_price_age_milliseconds: 900_000,
                is_frozen,
            }),
            nonce: Nonce(0),
        },
        is_authorized: false, account_id: protocol_parameters_id(),
    }
}

fn accumulator_account(anchor: u128, last: u64) -> AccountWithMetadata {
    AccountWithMetadata {
        account: Account {
            program_owner: STABLECOIN_PROGRAM_ID, balance: 0,
            data: Data::from(&StabilityFeeAccumulator {
                accumulated_rate_at_last_accrual: anchor,
                last_accrued_at: last,
            }),
            nonce: Nonce(0),
        },
        is_authorized: false, account_id: accumulator_id(),
    }
}

fn redemption_state_account(last_updated_at: u64) -> AccountWithMetadata {
    AccountWithMetadata {
        account: Account {
            program_owner: STABLECOIN_PROGRAM_ID, balance: 0,
            data: Data::from(&RedemptionPriceState {
                redemption_price_at_last_update: FIXED_POINT_ONE,
                redemption_rate_per_millisecond: FIXED_POINT_ONE,
                controller_integral_term: 0,
                last_updated_at,
            }),
            nonce: Nonce(0),
        },
        is_authorized: false, account_id: redemption_id(),
    }
}

fn oracle_account(timestamp: u64, price: u128) -> AccountWithMetadata {
    AccountWithMetadata {
        account: Account {
            program_owner: ORACLE_PROGRAM_ID, balance: 0,
            data: Data::from(&OraclePriceAccount {
                base_asset: stablecoin_def_id(),
                quote_asset: collateral_def_id(),
                price,
                timestamp,
                source_id: "twap".to_owned(),
                confidence_interval: 0,
            }),
            nonce: Nonce(0),
        },
        is_authorized: false, account_id: oracle_id(),
    }
}

fn decode_accumulator(post: &nssa_core::program::AccountPostState) -> StabilityFeeAccumulator {
    StabilityFeeAccumulator::try_from(&post.account().data).expect("decode accumulator")
}
fn decode_redemption(post: &nssa_core::program::AccountPostState) -> RedemptionPriceState {
    RedemptionPriceState::try_from(&post.account().data).expect("decode redemption")
}
  • Step 4: Register the test module
// programs/stablecoin/src/tests/mod.rs
mod existing;
mod initialize_program_tests;
mod accrue_stability_fee_tests;
mod update_redemption_rate_tests;
mod refresh_globals_tests;
  • Step 5: Both-halves-run test

Append to refresh_globals_tests.rs:

#[test]
fn both_halves_run_when_interval_due_and_oracle_fresh() {
    let (post_states, chained) = refresh_globals(
        caller_account(),
        protocol_parameters_account(false),
        accumulator_account(FIXED_POINT_ONE, T0),
        redemption_state_account(T0), // 600_000ms ago >= 300_000ms interval
        oracle_account(NOW, FIXED_POINT_ONE / 2), // fresh, redemption 1.0 > market 0.5
        STABLECOIN_PROGRAM_ID, NOW,
    );
    assert_eq!(post_states.len(), 5);
    assert!(chained.is_empty());

    // Fee half ran.
    let acc = decode_accumulator(&post_states[2]);
    assert!(acc.accumulated_rate_at_last_accrual > FIXED_POINT_ONE);
    assert_eq!(acc.last_accrued_at, NOW);

    // Redemption half ran: error = redemption − market > 0 with positive Kp =>
    // rate ABOVE 1.0 (price rises, market pulled UP — negative feedback).
    let rp = decode_redemption(&post_states[3]);
    assert!(rp.redemption_rate_per_millisecond > FIXED_POINT_ONE);
    assert_eq!(rp.last_updated_at, NOW);
}
  • Step 6: Redemption-half-skips tests (no panic)

Append:

#[test]
fn redemption_half_skipped_when_interval_not_elapsed_but_fee_still_runs() {
    let recent = NOW - 100_000; // 100_000ms < 300_000ms interval
    let (post_states, _) = refresh_globals(
        caller_account(),
        protocol_parameters_account(false),
        accumulator_account(FIXED_POINT_ONE, T0),
        redemption_state_account(recent),
        oracle_account(NOW, FIXED_POINT_ONE / 2), // fresh, but interval not due
        STABLECOIN_PROGRAM_ID, NOW,
    );
    // Fee half ran.
    let acc = decode_accumulator(&post_states[2]);
    assert_eq!(acc.last_accrued_at, NOW);
    // Redemption half skipped: unchanged anchor / rate / timestamp.
    let rp = decode_redemption(&post_states[3]);
    assert_eq!(rp.redemption_rate_per_millisecond, FIXED_POINT_ONE);
    assert_eq!(rp.last_updated_at, recent);
}

#[test]
fn redemption_half_skipped_when_oracle_stale_but_fee_still_runs() {
    let (post_states, _) = refresh_globals(
        caller_account(),
        protocol_parameters_account(false),
        accumulator_account(FIXED_POINT_ONE, T0),
        redemption_state_account(T0), // interval IS due
        oracle_account(NOW - 1_000_000, FIXED_POINT_ONE / 2), // 1_000_000ms > 900_000ms max age
        STABLECOIN_PROGRAM_ID, NOW,
    );
    // Fee half ran.
    let acc = decode_accumulator(&post_states[2]);
    assert_eq!(acc.last_accrued_at, NOW);
    // Redemption half skipped: timestamp stays at the old anchor.
    let rp = decode_redemption(&post_states[3]);
    assert_eq!(rp.last_updated_at, T0);
}

#[test]
fn redemption_half_skipped_when_oracle_price_zero_but_fee_still_runs() {
    let (post_states, _) = refresh_globals(
        caller_account(),
        protocol_parameters_account(false),
        accumulator_account(FIXED_POINT_ONE, T0),
        redemption_state_account(T0), // interval IS due
        oracle_account(NOW, 0),        // fresh but zero price
        STABLECOIN_PROGRAM_ID, NOW,
    );
    let acc = decode_accumulator(&post_states[2]);
    assert_eq!(acc.last_accrued_at, NOW);
    let rp = decode_redemption(&post_states[3]);
    assert_eq!(rp.last_updated_at, T0);
}
  • Step 7: Frozen-still-works test

Append:

#[test]
fn both_halves_run_when_frozen() {
    // Pokes are never blocked when frozen (spec §10.3a). With a fresh oracle and
    // a due interval, both halves still run even with is_frozen = true.
    let (post_states, _) = refresh_globals(
        caller_account(),
        protocol_parameters_account(true), // frozen
        accumulator_account(FIXED_POINT_ONE, T0),
        redemption_state_account(T0),
        oracle_account(NOW, FIXED_POINT_ONE / 2),
        STABLECOIN_PROGRAM_ID, NOW,
    );
    let acc = decode_accumulator(&post_states[2]);
    assert_eq!(acc.last_accrued_at, NOW);
    let rp = decode_redemption(&post_states[3]);
    assert_eq!(rp.last_updated_at, NOW);
}
  • Step 8: Panic-path tests

Append. refresh_globals panics ONLY on auth, uninitialized / wrong-owner globals, and oracle id mismatch — NOT on a stale / zero oracle or a not-due interval (those are covered as skips above).

#[test]
#[should_panic(expected = "Caller authorization is missing")]
fn requires_caller_authorization() {
    let mut caller = caller_account();
    caller.is_authorized = false;
    let _ = refresh_globals(
        caller, protocol_parameters_account(false),
        accumulator_account(FIXED_POINT_ONE, T0),
        redemption_state_account(T0),
        oracle_account(NOW, FIXED_POINT_ONE / 2),
        STABLECOIN_PROGRAM_ID, NOW,
    );
}

#[test]
#[should_panic(expected = "ProtocolParameters account must be initialized")]
fn rejects_uninitialized_protocol_parameters() {
    let pp = AccountWithMetadata { account: Account::default(), is_authorized: false, account_id: protocol_parameters_id() };
    let _ = refresh_globals(
        caller_account(), pp,
        accumulator_account(FIXED_POINT_ONE, T0),
        redemption_state_account(T0),
        oracle_account(NOW, FIXED_POINT_ONE / 2),
        STABLECOIN_PROGRAM_ID, NOW,
    );
}

#[test]
#[should_panic(expected = "StabilityFeeAccumulator account must be initialized")]
fn rejects_uninitialized_accumulator() {
    let acc = AccountWithMetadata { account: Account::default(), is_authorized: false, account_id: accumulator_id() };
    let _ = refresh_globals(
        caller_account(), protocol_parameters_account(false),
        acc, redemption_state_account(T0),
        oracle_account(NOW, FIXED_POINT_ONE / 2),
        STABLECOIN_PROGRAM_ID, NOW,
    );
}

#[test]
#[should_panic(expected = "RedemptionPriceState account must be initialized")]
fn rejects_uninitialized_redemption_state() {
    let rp = AccountWithMetadata { account: Account::default(), is_authorized: false, account_id: redemption_id() };
    let _ = refresh_globals(
        caller_account(), protocol_parameters_account(false),
        accumulator_account(FIXED_POINT_ONE, T0), rp,
        oracle_account(NOW, FIXED_POINT_ONE / 2),
        STABLECOIN_PROGRAM_ID, NOW,
    );
}

#[test]
#[should_panic(expected = "Market price oracle account_id does not match ProtocolParameters.market_price_oracle_id")]
fn rejects_oracle_id_mismatch() {
    let mut oracle = oracle_account(NOW, FIXED_POINT_ONE / 2);
    oracle.account_id = AccountId::new([0xDE; 32]);
    let _ = refresh_globals(
        caller_account(), protocol_parameters_account(false),
        accumulator_account(FIXED_POINT_ONE, T0),
        redemption_state_account(T0),
        oracle, STABLECOIN_PROGRAM_ID, NOW,
    );
}

Run cargo test -p stablecoin_program refresh_globals_tests. Expected: 10 PASS (1 both-halves + 3 skip + 1 frozen + 5 panic).

  • Step 9: Lint + full sweep
make clippy
RISC0_DEV_MODE=1 cargo test --workspace

Expected: green.

  • Step 10: Commit
git add programs/stablecoin/src/refresh_globals.rs \
        programs/stablecoin/src/tests/refresh_globals_tests.rs \
        programs/stablecoin/src/tests/mod.rs \
        programs/stablecoin/src/lib.rs
cargo +nightly fmt --all
git add -u
git commit -m "feat(stablecoin): implement refresh_globals host function

Combined best-effort poke (spec §10.3a) that advances BOTH globals in one
instruction — a LEZ transaction carries exactly one instruction, so this
is the only way to refresh the fee accumulator and the redemption rate in
a single transaction.

ALWAYS performs the fee accrual (like accrue_stability_fee; no throttle).
Performs the redemption update (like update_redemption_rate) ONLY when its
interval is due AND the oracle is fresh AND oracle.price > 0; otherwise
SKIPS that half WITHOUT panicking. Allowed when frozen.

Panics only on: caller not authorized; protocol_parameters /
stability_fee_accumulator / redemption_price_state uninitialized or wrong
owner; oracle id mismatch. Does NOT panic on a not-due interval or a stale
/ zero oracle (those skip the redemption half). The two standalone pokes
are kept: accrue is the oracle-free minimal path; update is the strict,
loud-failing path on a stale oracle.

10 unit tests: both halves run; redemption half skips on not-due interval,
stale oracle, and zero price (fee half still runs in each); both halves
run when frozen; and 5 panic paths (auth, three uninitialized globals,
oracle id mismatch)."

Notes for reviewer / future maintainers

  • The fee half is byte-for-byte the §10.2 math (issue 04); the redemption half is byte-for-byte the §10.3 math (issue 05). Prefer extracting shared internal helpers so a future change to either poke's math automatically flows into refresh_globals — divergence between the standalone and combined paths would be a bug.
  • The three redemption-half gates (interval_due, oracle_fresh, oracle_nonzero) are SOFT here but HARD in standalone update_redemption_rate (issue 05), where the same conditions panic. That asymmetry is intentional (spec §10.3a "Why keep all three"): refresh_globals is best-effort, update_redemption_rate is strict.
  • Oracle id mismatch panics in BOTH this function and update_redemption_rate — passing the wrong oracle account is a caller error, not a transient condition to skip over.
  • Pokes are never blocked when frozen; this function doesn't read is_frozen for control flow (the frozen test only varies the flag to prove it has no effect).

Dependencies

Depends on: #166, #167, #168, #169, #170.

Blocks: #172.

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