Skip to content

[Plan 4 · 01] Shared admin-authorization helper #183

Description

@gravityblast

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

  • Step 1: Create the helper module

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",
    );
}
  • Step 2: Wire the module into stablecoin_program

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.

  • Step 3: Unit tests

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, &params);
        // 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, &params);
    }

    #[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, &params);
    }
}

Run cargo test -p stablecoin_program checks::tests. Expected: 3 PASS.

  • Step 4: Lint check + full sweep
make clippy
RISC0_DEV_MODE=1 cargo test -p stablecoin_program

Expected: green.

  • Step 5: Commit
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.

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