Issue 01 — Shared admin-authorization helper
Plan: Stablecoin Plan 4 — Admin parameter updates
Depends on: Plan 1 (ProtocolParameters account type + decoding via TryFrom<&Data>).
Blocks: 02 (set_stability_fee_per_millisecond), 03 (the other six setters).
Goal: Land a small stablecoin_program::checks module that exposes assert_admin_authorized, the two-part authorization gate (signature + admin-handle match) that every admin setter performs. All seven setters call this helper instead of repeating the assertions inline.
Spec reference
§10.10–10.16 Admin parameter updates — the "Shared skeleton" subsection lists the two-part panic condition every setter shares: admin.is_authorized = false OR admin.account_id ≠ protocol_parameters.admin_account_id. Cf. the matching "Trust assumption" paragraph for why a malicious admin is in-scope of the design but out-of-scope of these specific setters.
Why
Repeating the same two assertions in seven different host functions is mistake-prone (a future setter might forget one of them, opening an authorization bypass). Centralizing the check makes the authorization model auditable in exactly one place: search for assert_admin_authorized and every admin-only call site is listed. The helper is also tiny enough to keep in a checks module that future plans (5 — freeze / unfreeze) can extend with a sibling assert_freeze_authority_authorized without churn.
Architecture
A new programs/stablecoin/src/checks.rs module containing one public function:
pub fn assert_admin_authorized(
admin: &AccountWithMetadata,
protocol_parameters: &ProtocolParameters,
);
Two panic messages — one per failure mode — so test assertions can target each path distinctly via #[should_panic(expected = "...")].
The helper takes a reference to ProtocolParameters (already decoded by the caller from protocol_parameters.account.data) rather than the raw AccountWithMetadata, because every setter must decode the parameters anyway to mutate them, and we don't want to re-decode inside the check.
Files
- Create:
programs/stablecoin/src/checks.rs — the helper + its unit tests.
- Modify:
programs/stablecoin/src/lib.rs — pub mod checks;.
Acceptance criteria
cargo test -p stablecoin_program checks:: passes (3 unit tests: happy path, missing auth, mismatched handle).
make clippy is green.
- Both negative tests use
#[should_panic(expected = "...")] with distinct expected substrings.
Implementation steps
Create programs/stablecoin/src/checks.rs:
//! Authorization-check helpers shared by admin-only instructions.
//!
//! See spec §10.10–10.16 "Shared skeleton" for the two-part admin gate every
//! `set_*` instruction performs: the caller must be authorized AND the caller's
//! account id must match `ProtocolParameters.admin_account_id`. Centralizing
//! the check here keeps the seven setters honest and gives auditors a single
//! grep target.
use nssa_core::account::AccountWithMetadata;
use stablecoin_core::ProtocolParameters;
/// Asserts that `admin` may perform an admin-only `set_*` instruction.
///
/// Two failure modes, each with a distinct panic message so tests can target
/// them via `#[should_panic(expected = "...")]`:
///
/// 1. `admin.is_authorized == false` — the runtime did not see a valid
/// signature for this account. Panics with `"Admin authorization is missing"`.
/// 2. `admin.account_id != protocol_parameters.admin_account_id` — the caller
/// is signed in but is not the configured admin. Panics with
/// `"Admin account id does not match ProtocolParameters.admin_account_id"`.
///
/// # Panics
/// As described above.
pub fn assert_admin_authorized(
admin: &AccountWithMetadata,
protocol_parameters: &ProtocolParameters,
) {
assert!(admin.is_authorized, "Admin authorization is missing");
assert_eq!(
admin.account_id, protocol_parameters.admin_account_id,
"Admin account id does not match ProtocolParameters.admin_account_id",
);
}
Edit programs/stablecoin/src/lib.rs — add the module declaration alongside the existing instruction modules:
/// Authorization-check helpers shared by admin-only instructions.
pub mod checks;
Run cargo check -p stablecoin_program. Expected: clean.
Append to programs/stablecoin/src/checks.rs:
#[cfg(test)]
mod tests {
use super::*;
use nssa_core::account::{Account, AccountId, Data, Nonce};
use stablecoin_core::ProtocolParameters;
const ADMIN_ID: [u8; 32] = [0xA0; 32];
const STRANGER_ID: [u8; 32] = [0xBB; 32];
fn sample_parameters() -> ProtocolParameters {
ProtocolParameters {
admin_account_id: AccountId::new(ADMIN_ID),
freeze_authority_account_id: AccountId::new([0xFE; 32]),
stablecoin_definition_id: AccountId::new([0x11; 32]),
collateral_definition_id: AccountId::new([0x12; 32]),
market_price_oracle_id: AccountId::new([0x13; 32]),
stability_fee_per_millisecond: stablecoin_core::math::FIXED_POINT_ONE,
controller_proportional_gain: 0,
controller_integral_gain: 0,
minimum_collateralization_ratio: stablecoin_core::math::FIXED_POINT_ONE * 3 / 2,
minimum_milliseconds_between_rate_updates: 300,
maximum_oracle_price_age_milliseconds: 900,
is_frozen: false,
}
}
fn admin(account_id: [u8; 32], is_authorized: bool) -> AccountWithMetadata {
AccountWithMetadata {
account: Account {
program_owner: [0u32; 8],
balance: 0,
data: Data::default(),
nonce: Nonce(0),
},
is_authorized,
account_id: AccountId::new(account_id),
}
}
#[test]
fn happy_path_authorized_admin_with_matching_handle() {
let params = sample_parameters();
let caller = admin(ADMIN_ID, true);
assert_admin_authorized(&caller, ¶ms);
// No panic == pass.
}
#[test]
#[should_panic(expected = "Admin authorization is missing")]
fn rejects_unauthorized_caller_even_with_matching_handle() {
let params = sample_parameters();
let caller = admin(ADMIN_ID, false);
assert_admin_authorized(&caller, ¶ms);
}
#[test]
#[should_panic(
expected = "Admin account id does not match ProtocolParameters.admin_account_id"
)]
fn rejects_authorized_caller_with_wrong_handle() {
let params = sample_parameters();
let caller = admin(STRANGER_ID, true);
assert_admin_authorized(&caller, ¶ms);
}
}
Run cargo test -p stablecoin_program checks::tests. Expected: 3 PASS.
make clippy
RISC0_DEV_MODE=1 cargo test -p stablecoin_program
Expected: green.
git add programs/stablecoin/src/checks.rs \
programs/stablecoin/src/lib.rs
cargo +nightly fmt --all
git add -u
git commit -m "feat(stablecoin): add shared assert_admin_authorized helper
Centralizes the two-part admin gate (is_authorized AND
account_id == ProtocolParameters.admin_account_id) used by all seven
admin set_* instructions (spec §10.10-10.16, shared skeleton).
Two distinct panic messages so tests can target each failure mode
independently. 3 unit tests cover happy path, missing-auth, and
mismatched-handle.
Used by every set_* setter landing in Plan 4."
Notes for reviewer / future maintainers
- The helper takes
&ProtocolParameters (decoded), not &AccountWithMetadata for the parameters account. Decoding-into-then-mutating-back-out is what every setter does anyway; this keeps the helper from re-decoding the buffer.
- The two panic messages are deliberately worded so a future Plan 5
assert_freeze_authority_authorized helper can use parallel language ("Freeze authority authorization is missing" / "Freeze authority account id does not match ProtocolParameters.freeze_authority_account_id") without anyone confusing the two during a grep.
- We do not check
protocol_parameters.account.program_owner == stablecoin_program_id here — that's the setter's job (a structural check on the parameters account itself, not on the admin). Keeping the helper narrowly scoped to authorization makes the per-setter validation contract clearer.
- The
AccountWithMetadata reference is intentional — every setter still owns the inputs and constructs the post-state from them, so move-by-value would force a clone.
Dependencies
Depends on: Plan 1 (#156, ProtocolParameters).
Blocks: #184, #185.
Issue 01 — Shared admin-authorization helper
Plan: Stablecoin Plan 4 — Admin parameter updates
Depends on: Plan 1 (
ProtocolParametersaccount type + decoding viaTryFrom<&Data>).Blocks: 02 (
set_stability_fee_per_millisecond), 03 (the other six setters).Goal: Land a small
stablecoin_program::checksmodule that exposesassert_admin_authorized, the two-part authorization gate (signature + admin-handle match) that every admin setter performs. All seven setters call this helper instead of repeating the assertions inline.Spec reference
§10.10–10.16 Admin parameter updates— the "Shared skeleton" subsection lists the two-part panic condition every setter shares:admin.is_authorized = falseORadmin.account_id ≠ protocol_parameters.admin_account_id. Cf. the matching "Trust assumption" paragraph for why a malicious admin is in-scope of the design but out-of-scope of these specific setters.Why
Repeating the same two assertions in seven different host functions is mistake-prone (a future setter might forget one of them, opening an authorization bypass). Centralizing the check makes the authorization model auditable in exactly one place: search for
assert_admin_authorizedand every admin-only call site is listed. The helper is also tiny enough to keep in achecksmodule that future plans (5 —freeze/unfreeze) can extend with a siblingassert_freeze_authority_authorizedwithout churn.Architecture
A new
programs/stablecoin/src/checks.rsmodule containing one public function:Two panic messages — one per failure mode — so test assertions can target each path distinctly via
#[should_panic(expected = "...")].The helper takes a reference to
ProtocolParameters(already decoded by the caller fromprotocol_parameters.account.data) rather than the rawAccountWithMetadata, because every setter must decode the parameters anyway to mutate them, and we don't want to re-decode inside the check.Files
programs/stablecoin/src/checks.rs— the helper + its unit tests.programs/stablecoin/src/lib.rs—pub mod checks;.Acceptance criteria
cargo test -p stablecoin_program checks::passes (3 unit tests: happy path, missing auth, mismatched handle).make clippyis green.#[should_panic(expected = "...")]with distinct expected substrings.Implementation steps
Create
programs/stablecoin/src/checks.rs:stablecoin_programEdit
programs/stablecoin/src/lib.rs— add the module declaration alongside the existing instruction modules:Run
cargo check -p stablecoin_program. Expected: clean.Append to
programs/stablecoin/src/checks.rs:Run
cargo test -p stablecoin_program checks::tests. Expected: 3 PASS.make clippy RISC0_DEV_MODE=1 cargo test -p stablecoin_programExpected: green.
git add programs/stablecoin/src/checks.rs \ programs/stablecoin/src/lib.rs cargo +nightly fmt --all git add -u git commit -m "feat(stablecoin): add shared assert_admin_authorized helper Centralizes the two-part admin gate (is_authorized AND account_id == ProtocolParameters.admin_account_id) used by all seven admin set_* instructions (spec §10.10-10.16, shared skeleton). Two distinct panic messages so tests can target each failure mode independently. 3 unit tests cover happy path, missing-auth, and mismatched-handle. Used by every set_* setter landing in Plan 4."Notes for reviewer / future maintainers
&ProtocolParameters(decoded), not&AccountWithMetadatafor the parameters account. Decoding-into-then-mutating-back-out is what every setter does anyway; this keeps the helper from re-decoding the buffer.assert_freeze_authority_authorizedhelper can use parallel language ("Freeze authority authorization is missing"/"Freeze authority account id does not match ProtocolParameters.freeze_authority_account_id") without anyone confusing the two during a grep.protocol_parameters.account.program_owner == stablecoin_program_idhere — that's the setter's job (a structural check on the parameters account itself, not on the admin). Keeping the helper narrowly scoped to authorization makes the per-setter validation contract clearer.AccountWithMetadatareference is intentional — every setter still owns the inputs and constructs the post-state from them, so move-by-value would force a clone.Dependencies
Depends on: Plan 1 (#156,
ProtocolParameters).Blocks: #184, #185.