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:
- 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.
- ALWAYS computes the fee-accumulator advance (the §10.2 math).
- 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.
- 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.rs — pub 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
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![])
}
// 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.
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")
}
// 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;
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);
}
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);
}
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);
}
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).
make clippy
RISC0_DEV_MODE=1 cargo test --workspace
Expected: green.
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.
Issue 05a —
refresh_globalshost functionPlan: Stablecoin Plan 2 — Permissionless pokes
Depends on: Plan 2 issues 01 (projections), 02 (controller), 03 (
RefreshGlobalsvariant), 04 (accrue_stability_fee), 05 (update_redemption_rate).Blocks: Plan 2 issue 06 (guest wiring).
Goal: Implement the
refresh_globalshost function — the combined best-effort poke that advances both globals in one instruction. It always performs the fee accrual (likeaccrue_stability_fee) and performs the redemption update (likeupdate_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.2and§10.3(the two halves it composes),§5.3(projections),§6.4(PI controller).Why
A LEZ transaction carries exactly one instruction (a
Messagehas oneprogram_id+ oneinstruction_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_globalsis 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_globalstrades 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.rsmodule withpub fn refresh_globals(...). The function:protocol_parametersread,stability_fee_accumulatorwrite,redemption_price_statewrite,market_price_oracleread), including the oracle id match.oracle.price > 0— and if so runs the PI tick and re-anchorsRedemptionPriceState(the §10.3 math); otherwise leavesRedemptionPriceStateuntouched.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
programs/stablecoin/src/refresh_globals.rsprograms/stablecoin/src/lib.rs—pub mod refresh_globals;programs/stablecoin/src/tests/refresh_globals_tests.rsprograms/stablecoin/src/tests/mod.rs— register the new test module.Acceptance criteria
cargo test -p stablecoin_program refresh_globals_tests::passes.make clippyis green.RedemptionPriceStatere-anchors per §10.3 / §6.4; both timestamps becomenow.RedemptionPriceStateunchanged (last_updated_atstays put); NO panic.RedemptionPriceStateunchanged; NO panic.oracle.price == 0: accumulator still advances;RedemptionPriceStateunchanged; NO panic.is_frozen = true): both halves still eligible to run.protocol_parameters/stability_fee_accumulator/redemption_price_stateuninitialized or wrong owner; oracle id mismatch.Implementation steps
programs/stablecoin/src/refresh_globals.rs:twap_oracle_corewas already added toprograms/stablecoin/Cargo.tomlin issue 05. Runcargo check -p stablecoin_program. Expected: clean.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.Append to
refresh_globals_tests.rs:Append:
Append:
Append.
refresh_globalspanics 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).Run
cargo test -p stablecoin_program refresh_globals_tests. Expected: 10 PASS (1 both-halves + 3 skip + 1 frozen + 5 panic).make clippy RISC0_DEV_MODE=1 cargo test --workspaceExpected: green.
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
refresh_globals— divergence between the standalone and combined paths would be a bug.interval_due,oracle_fresh,oracle_nonzero) are SOFT here but HARD in standaloneupdate_redemption_rate(issue 05), where the same conditions panic. That asymmetry is intentional (spec §10.3a "Why keep all three"):refresh_globalsis best-effort,update_redemption_rateis strict.update_redemption_rate— passing the wrong oracle account is a caller error, not a transient condition to skip over.is_frozenfor control flow (thefrozentest only varies the flag to prove it has no effect).Dependencies
Depends on: #166, #167, #168, #169, #170.
Blocks: #172.