Skip to content

[Plan 4 · 03] The other six admin setters #185

Description

@gravityblast

Issue 03 — The other six admin setters

Plan: Stablecoin Plan 4 — Admin parameter updates
Depends on: 01 (admin-auth helper), Plan 1 (ProtocolParameters).
Blocks: 04 (guest wiring + integration tests).

Goal: Implement the six "skeleton" admin setters — set_minimum_collateralization_ratio, set_controller_gains, set_market_price_oracle, set_timing_parameters, set_admin, set_freeze_authority. All share the same shape (admin auth + ProtocolParameters write, no chained calls). set_market_price_oracle is the only one with a third account input (the new oracle, validated for base/quote shape). The variant Instruction::SetStabilityFeePerMillisecond already landed in issue 02; this issue adds the other six.

Spec reference

§10.10–10.16 Admin parameter updates — the shared-skeleton table maps each setter to the field it writes and its extra account (or lack thereof).
§8 Bound choices — the bands every numerical setter enforces.
§14 Open follow-ups — the "Two-step admin / freeze rotation" entry acknowledging that one-step rotation is what this plan ships.

Why

These six setters are the protocol's tuning knobs once the bootstrap is in place. Without them, every parameter initialize_program set is frozen for the life of the deployment; the admin can't respond to changing market conditions, can't rotate to a healthier oracle if the current one degrades, and can't hand the admin handle off to a multisig once one is set up. They're cheap to land because they all share the same shape; bundling them in one issue keeps the auth-and-validation pattern visible in one diff so reviewers can confirm by inspection that every setter applies the same check in the same order.

Architecture

All six new host functions live in programs/stablecoin/src/admin.rs alongside the set_stability_fee_per_millisecond function from issue 02. Each function follows the same six-step recipe:

  1. Assert protocol_parameters.account.program_owner == stablecoin_program_id.
  2. Decode params: ProtocolParameters from the parameters account.
  3. assert_admin_authorized(&admin, &params).
  4. Validate the new value(s) against the band(s) in bounds::* (and, for set_market_price_oracle, validate the new oracle account's shape).
  5. Write the new field(s) into params.
  6. Return (post_states, vec![]) with three post-states (admin unchanged, ProtocolParameters rewritten, plus the new-oracle account untouched for the oracle setter — two post-states for the others).

For set_admin / set_freeze_authority, the convention chosen is: the caller is trusted to supply a non-default AccountId. We reject AccountId::default() explicitly (cheap insurance against a transcription error that produces all-zero bytes) but don't otherwise constrain the value. Once RFP-001 / RFP-002 land (see spec §13), the entire authority model gets a wrapping layer; we don't pay forward the validation surface now.

Files

  • Modify: programs/stablecoin/core/src/lib.rs — add six new Instruction variants.
  • Modify: programs/stablecoin/src/admin.rs — append the six new host functions.
  • Modify: programs/stablecoin/src/tests/admin_tests.rs — append unit tests for each new setter.

Acceptance criteria

  • cargo test -p stablecoin_program admin_tests::set_minimum_collateralization_ratio_ passes (5 tests).
  • cargo test -p stablecoin_program admin_tests::set_controller_gains_ passes (5 tests).
  • cargo test -p stablecoin_program admin_tests::set_market_price_oracle_ passes (5 tests — includes the new-oracle base/quote validation path).
  • cargo test -p stablecoin_program admin_tests::set_timing_parameters_ passes (5 tests; two timing fields, not three).
  • cargo test -p stablecoin_program admin_tests::set_admin_ passes (4 tests).
  • cargo test -p stablecoin_program admin_tests::set_freeze_authority_ passes (4 tests).
  • cargo test -p stablecoin_core still green.
  • make clippy is green.

Implementation steps

  • Step 1: Add the six new Instruction variants

Append to programs/stablecoin/core/src/lib.rs after SetStabilityFeePerMillisecond:

    /// Update the protocol's minimum collateralization ratio.
    ///
    /// Required accounts (2):
    /// - Admin account (authorized; `account_id == protocol_parameters.admin_account_id`)
    /// - `ProtocolParameters` PDA (initialized, writable)
    ///
    /// Panics if `new_minimum_collateralization_ratio` is outside
    /// `[FIXED_POINT_ONE * 1.1, FIXED_POINT_ONE * 10]` (spec §8).
    SetMinimumCollateralizationRatio {
        /// New minimum ratio in fixed-point.
        new_minimum_collateralization_ratio: u128,
    },
    /// Update both PI controller gains atomically. Does NOT reset the
    /// persisted integral term in `RedemptionPriceState`.
    ///
    /// Required accounts (2):
    /// - Admin account (authorized; `account_id == protocol_parameters.admin_account_id`)
    /// - `ProtocolParameters` PDA (initialized, writable)
    ///
    /// Panics if Kp's magnitude exceeds `FIXED_POINT_ONE * 10^3` or Ki's
    /// magnitude exceeds `FIXED_POINT_ONE` (spec §8).
    SetControllerGains {
        /// New Kp (signed).
        new_controller_proportional_gain: i128,
        /// New Ki (signed).
        new_controller_integral_gain: i128,
    },
    /// Rotate the market price oracle.
    ///
    /// Required accounts (3):
    /// - Admin account (authorized; `account_id == protocol_parameters.admin_account_id`)
    /// - `ProtocolParameters` PDA (initialized, writable)
    /// - New oracle account (read-only; validated as `OraclePriceAccount` with
    ///   `base_asset == stablecoin_definition_id` and
    ///   `quote_asset == collateral_definition_id`)
    ///
    /// `program_owner` of the new oracle is NOT pinned; any producer emitting the
    /// expected struct shape is accepted.
    SetMarketPriceOracle,
    /// Update both timing parameters atomically.
    ///
    /// Required accounts (2):
    /// - Admin account (authorized; `account_id == protocol_parameters.admin_account_id`)
    /// - `ProtocolParameters` PDA (initialized, writable)
    ///
    /// Panics if either of the two values is outside `[1, 86_400_000]` (spec §8).
    SetTimingParameters {
        /// Min milliseconds between successful `update_redemption_rate` calls.
        new_minimum_milliseconds_between_rate_updates: u64,
        /// Reject oracle observations older than this.
        new_maximum_oracle_price_age_milliseconds: u64,
    },
    /// Rotate the admin handle (one-step rotation; see spec §14 follow-up for
    /// the deferred two-step variant).
    ///
    /// Required accounts (2):
    /// - Admin account (authorized; `account_id == protocol_parameters.admin_account_id`)
    /// - `ProtocolParameters` PDA (initialized, writable)
    ///
    /// Panics if `new_admin_account_id == AccountId::default()`.
    SetAdmin {
        /// The new admin handle.
        new_admin_account_id: AccountId,
    },
    /// Rotate the freeze authority handle (one-step rotation; see spec §14
    /// follow-up for the deferred two-step variant).
    ///
    /// Required accounts (2):
    /// - Admin account (authorized; `account_id == protocol_parameters.admin_account_id`)
    /// - `ProtocolParameters` PDA (initialized, writable)
    ///
    /// Panics if `new_freeze_authority_account_id == AccountId::default()`.
    SetFreezeAuthority {
        /// The new freeze authority handle.
        new_freeze_authority_account_id: AccountId,
    },

Run cargo test -p stablecoin_core. Expected: green.

  • Step 2: Append the host functions to admin.rs

Append to programs/stablecoin/src/admin.rs:

// --- Shared helper -----------------------------------------------------------

fn decode_protocol_parameters(
    protocol_parameters: &AccountWithMetadata,
    stablecoin_program_id: ProgramId,
) -> ProtocolParameters {
    assert_eq!(
        protocol_parameters.account.program_owner, stablecoin_program_id,
        "ProtocolParameters account must be owned by the stablecoin program",
    );
    ProtocolParameters::try_from(&protocol_parameters.account.data)
        .expect("ProtocolParameters must decode")
}

fn write_protocol_parameters_post(
    protocol_parameters: AccountWithMetadata,
    params: &ProtocolParameters,
) -> AccountPostState {
    let mut acc = protocol_parameters.account;
    acc.data = Data::from(params);
    AccountPostState::new(acc)
}

// --- set_minimum_collateralization_ratio (spec §10.11) -----------------------

/// Update the minimum collateralization ratio.
///
/// Tightening leaves existing positions retroactively under-collateralized;
/// they cannot increase debt or withdraw collateral until back above the new
/// threshold but can deposit / repay to recover. See spec §10.11.
#[allow(clippy::needless_pass_by_value)]
pub fn set_minimum_collateralization_ratio(
    admin: AccountWithMetadata,
    protocol_parameters: AccountWithMetadata,
    stablecoin_program_id: ProgramId,
    new_minimum_collateralization_ratio: u128,
) -> (Vec<AccountPostState>, Vec<ChainedCall>) {
    let mut params = decode_protocol_parameters(&protocol_parameters, stablecoin_program_id);
    assert_admin_authorized(&admin, &params);
    assert!(
        new_minimum_collateralization_ratio >= bounds::MIN_COLLATERALIZATION_RATIO,
        "new_minimum_collateralization_ratio below 1.1x",
    );
    assert!(
        new_minimum_collateralization_ratio <= bounds::MAX_COLLATERALIZATION_RATIO,
        "new_minimum_collateralization_ratio above 10x",
    );
    params.minimum_collateralization_ratio = new_minimum_collateralization_ratio;
    let post_states = vec![
        AccountPostState::new(admin.account),
        write_protocol_parameters_post(protocol_parameters, &params),
    ];
    (post_states, Vec::new())
}

// --- set_controller_gains (spec §10.12) --------------------------------------

/// Update both PI controller gains atomically. Does NOT reset the persisted
/// integral term (`RedemptionPriceState.controller_integral_term`). See spec §10.12.
#[allow(clippy::needless_pass_by_value)]
pub fn set_controller_gains(
    admin: AccountWithMetadata,
    protocol_parameters: AccountWithMetadata,
    stablecoin_program_id: ProgramId,
    new_controller_proportional_gain: i128,
    new_controller_integral_gain: i128,
) -> (Vec<AccountPostState>, Vec<ChainedCall>) {
    let mut params = decode_protocol_parameters(&protocol_parameters, stablecoin_program_id);
    assert_admin_authorized(&admin, &params);
    assert!(
        new_controller_proportional_gain.unsigned_abs()
            <= bounds::MAX_PROPORTIONAL_GAIN_MAGNITUDE as u128,
        "new_controller_proportional_gain out of band",
    );
    assert!(
        new_controller_integral_gain.unsigned_abs()
            <= bounds::MAX_INTEGRAL_GAIN_MAGNITUDE as u128,
        "new_controller_integral_gain out of band",
    );
    params.controller_proportional_gain = new_controller_proportional_gain;
    params.controller_integral_gain = new_controller_integral_gain;
    let post_states = vec![
        AccountPostState::new(admin.account),
        write_protocol_parameters_post(protocol_parameters, &params),
    ];
    (post_states, Vec::new())
}

// --- set_market_price_oracle (spec §10.13) -----------------------------------

/// Rotate the market price oracle. Validates the new oracle's `base_asset` /
/// `quote_asset` against the bound stablecoin and collateral definitions; does
/// NOT pin the oracle's `program_owner` (any producer emitting the expected
/// struct shape works). See spec §10.13.
#[allow(clippy::needless_pass_by_value)]
pub fn set_market_price_oracle(
    admin: AccountWithMetadata,
    protocol_parameters: AccountWithMetadata,
    new_oracle: AccountWithMetadata,
    stablecoin_program_id: ProgramId,
) -> (Vec<AccountPostState>, Vec<ChainedCall>) {
    let mut params = decode_protocol_parameters(&protocol_parameters, stablecoin_program_id);
    assert_admin_authorized(&admin, &params);

    assert_ne!(
        new_oracle.account, Account::default(),
        "New oracle account must be initialized",
    );
    let oracle = twap_oracle_core::OraclePriceAccount::try_from(&new_oracle.account.data)
        .expect("New oracle must decode as OraclePriceAccount");
    assert_eq!(
        oracle.base_asset, params.stablecoin_definition_id,
        "New oracle base_asset must equal ProtocolParameters.stablecoin_definition_id",
    );
    assert_eq!(
        oracle.quote_asset, params.collateral_definition_id,
        "New oracle quote_asset must equal ProtocolParameters.collateral_definition_id",
    );

    params.market_price_oracle_id = new_oracle.account_id;

    let post_states = vec![
        AccountPostState::new(admin.account),
        write_protocol_parameters_post(protocol_parameters, &params),
        AccountPostState::new(new_oracle.account),
    ];
    (post_states, Vec::new())
}

// --- set_timing_parameters (spec §10.14) -------------------------------------

/// Update both timing parameters atomically. See spec §10.14.
#[allow(clippy::needless_pass_by_value)]
pub fn set_timing_parameters(
    admin: AccountWithMetadata,
    protocol_parameters: AccountWithMetadata,
    stablecoin_program_id: ProgramId,
    new_minimum_milliseconds_between_rate_updates: u64,
    new_maximum_oracle_price_age_milliseconds: u64,
) -> (Vec<AccountPostState>, Vec<ChainedCall>) {
    let mut params = decode_protocol_parameters(&protocol_parameters, stablecoin_program_id);
    assert_admin_authorized(&admin, &params);
    for &(milliseconds, label) in &[
        (
            new_minimum_milliseconds_between_rate_updates,
            "new_minimum_milliseconds_between_rate_updates",
        ),
        (
            new_maximum_oracle_price_age_milliseconds,
            "new_maximum_oracle_price_age_milliseconds",
        ),
    ] {
        assert!(milliseconds >= bounds::MIN_TIMING_MILLISECONDS, "{label} below minimum 1ms");
        assert!(milliseconds <= bounds::MAX_TIMING_MILLISECONDS, "{label} above maximum 86400000ms");
    }
    params.minimum_milliseconds_between_rate_updates = new_minimum_milliseconds_between_rate_updates;
    params.maximum_oracle_price_age_milliseconds = new_maximum_oracle_price_age_milliseconds;
    let post_states = vec![
        AccountPostState::new(admin.account),
        write_protocol_parameters_post(protocol_parameters, &params),
    ];
    (post_states, Vec::new())
}

// --- set_admin (spec §10.15) -------------------------------------------------

/// Rotate the admin handle in one step. The new admin takes effect immediately;
/// the old admin can no longer call `set_*`. See spec §10.15 and §14
/// follow-up for the deferred two-step variant.
#[allow(clippy::needless_pass_by_value)]
pub fn set_admin(
    admin: AccountWithMetadata,
    protocol_parameters: AccountWithMetadata,
    stablecoin_program_id: ProgramId,
    new_admin_account_id: AccountId,
) -> (Vec<AccountPostState>, Vec<ChainedCall>) {
    let mut params = decode_protocol_parameters(&protocol_parameters, stablecoin_program_id);
    assert_admin_authorized(&admin, &params);
    assert_ne!(
        new_admin_account_id, AccountId::default(),
        "new_admin_account_id must not be the default (all-zero) account id",
    );
    params.admin_account_id = new_admin_account_id;
    let post_states = vec![
        AccountPostState::new(admin.account),
        write_protocol_parameters_post(protocol_parameters, &params),
    ];
    (post_states, Vec::new())
}

// --- set_freeze_authority (spec §10.16) --------------------------------------

/// Rotate the freeze authority handle in one step. See spec §10.16.
#[allow(clippy::needless_pass_by_value)]
pub fn set_freeze_authority(
    admin: AccountWithMetadata,
    protocol_parameters: AccountWithMetadata,
    stablecoin_program_id: ProgramId,
    new_freeze_authority_account_id: AccountId,
) -> (Vec<AccountPostState>, Vec<ChainedCall>) {
    let mut params = decode_protocol_parameters(&protocol_parameters, stablecoin_program_id);
    assert_admin_authorized(&admin, &params);
    assert_ne!(
        new_freeze_authority_account_id, AccountId::default(),
        "new_freeze_authority_account_id must not be the default (all-zero) account id",
    );
    params.freeze_authority_account_id = new_freeze_authority_account_id;
    let post_states = vec![
        AccountPostState::new(admin.account),
        write_protocol_parameters_post(protocol_parameters, &params),
    ];
    (post_states, Vec::new())
}

Also add the new twap_oracle_core import at the top of admin.rs:

// add to existing use list
use twap_oracle_core; // for set_market_price_oracle's OraclePriceAccount decode

…or pull it in via a fully-qualified path inline as shown above; either works. Confirm twap_oracle_core is in programs/stablecoin/Cargo.toml's [dependencies] (it should be from Plan 1 issue 07).

Run cargo check -p stablecoin_program. Expected: clean.

  • Step 3: Tests — set_minimum_collateralization_ratio

Append to programs/stablecoin/src/tests/admin_tests.rs:

mod set_minimum_collateralization_ratio_tests {
    use super::*;
    use stablecoin_program::admin::set_minimum_collateralization_ratio;

    fn invoke(
        admin: AccountWithMetadata,
        params: ProtocolParameters,
        new_ratio: u128,
    ) -> (
        Vec<nssa_core::program::AccountPostState>,
        Vec<nssa_core::program::ChainedCall>,
    ) {
        set_minimum_collateralization_ratio(
            admin,
            protocol_parameters_account(params),
            STABLECOIN_PROGRAM_ID,
            new_ratio,
        )
    }

    #[test]
    fn happy_path_writes_new_ratio() {
        let new_ratio = FIXED_POINT_ONE * 2;
        let (post_states, chained) =
            invoke(admin_account(true), sample_parameters(), new_ratio);
        assert_eq!(post_states.len(), 2);
        assert_eq!(chained.len(), 0);
        let decoded = ProtocolParameters::try_from(&post_states[1].account().data).unwrap();
        assert_eq!(decoded.minimum_collateralization_ratio, new_ratio);
    }

    #[test]
    #[should_panic(expected = "Admin authorization is missing")]
    fn rejects_unauthorized() {
        let _ = invoke(admin_account(false), sample_parameters(), FIXED_POINT_ONE * 2);
    }

    #[test]
    #[should_panic(
        expected = "Admin account id does not match ProtocolParameters.admin_account_id"
    )]
    fn rejects_wrong_handle() {
        let _ = invoke(stranger(), sample_parameters(), FIXED_POINT_ONE * 2);
    }

    #[test]
    #[should_panic(expected = "new_minimum_collateralization_ratio below 1.1x")]
    fn rejects_ratio_below_lower_bound() {
        let _ = invoke(admin_account(true), sample_parameters(), FIXED_POINT_ONE);
    }

    #[test]
    #[should_panic(expected = "new_minimum_collateralization_ratio above 10x")]
    fn rejects_ratio_above_upper_bound() {
        let _ = invoke(admin_account(true), sample_parameters(), FIXED_POINT_ONE * 11);
    }
}

Run cargo test -p stablecoin_program admin_tests::set_minimum_collateralization_ratio_tests. Expected: 5 PASS.

  • Step 4: Tests — set_controller_gains

Append:

mod set_controller_gains_tests {
    use super::*;
    use stablecoin_program::admin::set_controller_gains;

    fn invoke(
        admin: AccountWithMetadata,
        params: ProtocolParameters,
        kp: i128,
        ki: i128,
    ) -> (
        Vec<nssa_core::program::AccountPostState>,
        Vec<nssa_core::program::ChainedCall>,
    ) {
        set_controller_gains(
            admin,
            protocol_parameters_account(params),
            STABLECOIN_PROGRAM_ID,
            kp,
            ki,
        )
    }

    const MAX_KP: i128 = (FIXED_POINT_ONE as i128) * 1_000;
    const MAX_KI: i128 = FIXED_POINT_ONE as i128;

    #[test]
    fn happy_path_writes_both_gains() {
        let kp = 12345;
        let ki = -67890;
        let (post_states, chained) =
            invoke(admin_account(true), sample_parameters(), kp, ki);
        assert_eq!(post_states.len(), 2);
        assert_eq!(chained.len(), 0);
        let decoded = ProtocolParameters::try_from(&post_states[1].account().data).unwrap();
        assert_eq!(decoded.controller_proportional_gain, kp);
        assert_eq!(decoded.controller_integral_gain, ki);
    }

    #[test]
    #[should_panic(expected = "Admin authorization is missing")]
    fn rejects_unauthorized() {
        let _ = invoke(admin_account(false), sample_parameters(), 0, 0);
    }

    #[test]
    #[should_panic(expected = "new_controller_proportional_gain out of band")]
    fn rejects_kp_above_band() {
        let _ = invoke(admin_account(true), sample_parameters(), MAX_KP + 1, 0);
    }

    #[test]
    #[should_panic(expected = "new_controller_integral_gain out of band")]
    fn rejects_ki_below_negative_band() {
        let _ = invoke(admin_account(true), sample_parameters(), 0, -MAX_KI - 1);
    }

    #[test]
    fn accepts_gains_at_boundary() {
        let _ = invoke(admin_account(true), sample_parameters(), MAX_KP, -MAX_KI);
    }
}

Run cargo test -p stablecoin_program admin_tests::set_controller_gains_tests. Expected: 5 PASS.

  • Step 5: Tests — set_market_price_oracle

Append. This is the only setter with a third account input, so the tests need an oracle-account helper:

mod set_market_price_oracle_tests {
    use super::*;
    use stablecoin_program::admin::set_market_price_oracle;
    use twap_oracle_core::OraclePriceAccount;

    fn make_oracle(base: AccountId, quote: AccountId) -> AccountWithMetadata {
        AccountWithMetadata {
            account: Account {
                program_owner: [4u32; 8],
                balance: 0,
                data: Data::from(&OraclePriceAccount {
                    base_asset: base,
                    quote_asset: quote,
                    price: FIXED_POINT_ONE / 2,
                    timestamp: NOW,
                    source_id: "twap".to_owned(),
                    confidence_interval: 0,
                }),
                nonce: Nonce(0),
            },
            is_authorized: false,
            account_id: AccountId::new([0xCA; 32]),
        }
    }

    fn invoke(
        admin: AccountWithMetadata,
        params: ProtocolParameters,
        oracle: AccountWithMetadata,
    ) -> (
        Vec<nssa_core::program::AccountPostState>,
        Vec<nssa_core::program::ChainedCall>,
    ) {
        set_market_price_oracle(
            admin,
            protocol_parameters_account(params),
            oracle,
            STABLECOIN_PROGRAM_ID,
        )
    }

    #[test]
    fn happy_path_rotates_oracle_id() {
        let params = sample_parameters();
        let oracle = make_oracle(
            params.stablecoin_definition_id,
            params.collateral_definition_id,
        );
        let new_oracle_id = oracle.account_id;
        let (post_states, chained) = invoke(admin_account(true), params, oracle);
        assert_eq!(post_states.len(), 3);
        assert_eq!(chained.len(), 0);
        let decoded = ProtocolParameters::try_from(&post_states[1].account().data).unwrap();
        assert_eq!(decoded.market_price_oracle_id, new_oracle_id);
    }

    #[test]
    #[should_panic(expected = "Admin authorization is missing")]
    fn rejects_unauthorized() {
        let params = sample_parameters();
        let oracle = make_oracle(
            params.stablecoin_definition_id,
            params.collateral_definition_id,
        );
        let _ = invoke(admin_account(false), params, oracle);
    }

    #[test]
    #[should_panic(expected = "New oracle account must be initialized")]
    fn rejects_uninitialized_oracle() {
        let params = sample_parameters();
        let oracle = AccountWithMetadata {
            account: Account::default(),
            is_authorized: false,
            account_id: AccountId::new([0xCA; 32]),
        };
        let _ = invoke(admin_account(true), params, oracle);
    }

    #[test]
    #[should_panic(
        expected = "New oracle base_asset must equal ProtocolParameters.stablecoin_definition_id"
    )]
    fn rejects_oracle_with_wrong_base_asset() {
        let params = sample_parameters();
        let oracle = make_oracle(AccountId::new([0xBA; 32]), params.collateral_definition_id);
        let _ = invoke(admin_account(true), params, oracle);
    }

    #[test]
    #[should_panic(
        expected = "New oracle quote_asset must equal ProtocolParameters.collateral_definition_id"
    )]
    fn rejects_oracle_with_wrong_quote_asset() {
        let params = sample_parameters();
        let oracle = make_oracle(params.stablecoin_definition_id, AccountId::new([0xCC; 32]));
        let _ = invoke(admin_account(true), params, oracle);
    }
}

Run cargo test -p stablecoin_program admin_tests::set_market_price_oracle_tests. Expected: 5 PASS.

  • Step 6: Tests — set_timing_parameters

Append:

mod set_timing_parameters_tests {
    use super::*;
    use stablecoin_program::admin::set_timing_parameters;

    fn invoke(
        admin: AccountWithMetadata,
        params: ProtocolParameters,
        a: u64,
        b: u64,
    ) -> (
        Vec<nssa_core::program::AccountPostState>,
        Vec<nssa_core::program::ChainedCall>,
    ) {
        set_timing_parameters(
            admin,
            protocol_parameters_account(params),
            STABLECOIN_PROGRAM_ID,
            a,
            b,
        )
    }

    #[test]
    fn happy_path_writes_both_fields() {
        let (post_states, chained) =
            invoke(admin_account(true), sample_parameters(), 600_000, 1_200_000);
        assert_eq!(post_states.len(), 2);
        assert_eq!(chained.len(), 0);
        let decoded = ProtocolParameters::try_from(&post_states[1].account().data).unwrap();
        assert_eq!(decoded.minimum_milliseconds_between_rate_updates, 600_000);
        assert_eq!(decoded.maximum_oracle_price_age_milliseconds, 1_200_000);
    }

    #[test]
    #[should_panic(expected = "Admin authorization is missing")]
    fn rejects_unauthorized() {
        let _ = invoke(admin_account(false), sample_parameters(), 600_000, 1_200_000);
    }

    #[test]
    #[should_panic(expected = "new_minimum_milliseconds_between_rate_updates below minimum 1ms")]
    fn rejects_zero_rate_update_interval() {
        let _ = invoke(admin_account(true), sample_parameters(), 0, 1_200_000);
    }

    #[test]
    #[should_panic(expected = "new_maximum_oracle_price_age_milliseconds above maximum 86400000ms")]
    fn rejects_oracle_max_age_above_day() {
        let _ = invoke(admin_account(true), sample_parameters(), 600_000, 86_400_001);
    }

    #[test]
    fn accepts_both_at_boundaries() {
        let _ = invoke(admin_account(true), sample_parameters(), 86_400_000, 1);
    }
}

Run cargo test -p stablecoin_program admin_tests::set_timing_parameters_tests. Expected: 5 PASS.

  • Step 7: Tests — set_admin

Append:

mod set_admin_tests {
    use super::*;
    use stablecoin_program::admin::set_admin;

    fn invoke(
        admin: AccountWithMetadata,
        params: ProtocolParameters,
        new_admin_account_id: AccountId,
    ) -> (
        Vec<nssa_core::program::AccountPostState>,
        Vec<nssa_core::program::ChainedCall>,
    ) {
        set_admin(
            admin,
            protocol_parameters_account(params),
            STABLECOIN_PROGRAM_ID,
            new_admin_account_id,
        )
    }

    #[test]
    fn happy_path_rotates_admin_handle() {
        let new_admin = AccountId::new([0xCC; 32]);
        let (post_states, chained) =
            invoke(admin_account(true), sample_parameters(), new_admin);
        assert_eq!(post_states.len(), 2);
        assert_eq!(chained.len(), 0);
        let decoded = ProtocolParameters::try_from(&post_states[1].account().data).unwrap();
        assert_eq!(decoded.admin_account_id, new_admin);
    }

    #[test]
    #[should_panic(expected = "Admin authorization is missing")]
    fn rejects_unauthorized() {
        let _ = invoke(admin_account(false), sample_parameters(), AccountId::new([1; 32]));
    }

    #[test]
    #[should_panic(
        expected = "Admin account id does not match ProtocolParameters.admin_account_id"
    )]
    fn rejects_wrong_handle() {
        let _ = invoke(stranger(), sample_parameters(), AccountId::new([1; 32]));
    }

    #[test]
    #[should_panic(
        expected = "new_admin_account_id must not be the default (all-zero) account id"
    )]
    fn rejects_default_account_id() {
        let _ = invoke(admin_account(true), sample_parameters(), AccountId::default());
    }
}

Run cargo test -p stablecoin_program admin_tests::set_admin_tests. Expected: 4 PASS.

  • Step 8: Tests — set_freeze_authority

Append:

mod set_freeze_authority_tests {
    use super::*;
    use stablecoin_program::admin::set_freeze_authority;

    fn invoke(
        admin: AccountWithMetadata,
        params: ProtocolParameters,
        new_fa: AccountId,
    ) -> (
        Vec<nssa_core::program::AccountPostState>,
        Vec<nssa_core::program::ChainedCall>,
    ) {
        set_freeze_authority(
            admin,
            protocol_parameters_account(params),
            STABLECOIN_PROGRAM_ID,
            new_fa,
        )
    }

    #[test]
    fn happy_path_rotates_freeze_authority_handle() {
        let new_fa = AccountId::new([0xEE; 32]);
        let (post_states, chained) =
            invoke(admin_account(true), sample_parameters(), new_fa);
        assert_eq!(post_states.len(), 2);
        assert_eq!(chained.len(), 0);
        let decoded = ProtocolParameters::try_from(&post_states[1].account().data).unwrap();
        assert_eq!(decoded.freeze_authority_account_id, new_fa);
    }

    #[test]
    #[should_panic(expected = "Admin authorization is missing")]
    fn rejects_unauthorized() {
        let _ = invoke(admin_account(false), sample_parameters(), AccountId::new([1; 32]));
    }

    #[test]
    #[should_panic(
        expected = "Admin account id does not match ProtocolParameters.admin_account_id"
    )]
    fn rejects_wrong_handle() {
        let _ = invoke(stranger(), sample_parameters(), AccountId::new([1; 32]));
    }

    #[test]
    #[should_panic(
        expected = "new_freeze_authority_account_id must not be the default (all-zero) account id"
    )]
    fn rejects_default_account_id() {
        let _ = invoke(admin_account(true), sample_parameters(), AccountId::default());
    }
}

Run cargo test -p stablecoin_program admin_tests::set_freeze_authority_tests. Expected: 4 PASS.

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

Expected: green. Total admin_tests count: 11 (from issue 02) + 5 + 5 + 5 + 5 + 4 + 4 = 39.

  • Step 10: Commit
git add programs/stablecoin/src/admin.rs \
        programs/stablecoin/src/tests/admin_tests.rs \
        programs/stablecoin/core/src/lib.rs
cargo +nightly fmt --all
git add -u
git commit -m "feat(stablecoin): implement the six skeleton admin setters

set_minimum_collateralization_ratio (§10.11), set_controller_gains
(§10.12, atomic Kp+Ki, integral term NOT reset),
set_market_price_oracle (§10.13, validates new oracle base/quote and
does NOT pin program_owner), set_timing_parameters (§10.14, atomic
two-tuple), set_admin (§10.15, one-step rotation),
set_freeze_authority (§10.16, one-step rotation).

All six share the same skeleton via decode_protocol_parameters +
write_protocol_parameters_post helpers and the
assert_admin_authorized check from issue 01. Bound constants are
shared with initialize_program via the admin::bounds module from
issue 02 so the two can never drift.

28 unit tests cover, for each setter: happy path (field written, no
chained calls), both authorization failures, and out-of-band /
oracle-shape / default-account rejection per spec §8. set_admin and
set_freeze_authority guard against the default (all-zero)
AccountId as cheap insurance against a transcription error."

Notes for reviewer / future maintainers

  • The default-AccountId rejection in set_admin / set_freeze_authority is a defense-in-depth check, not a security boundary. A malicious admin could still rotate to a key they control; the spec's "Trust assumption" paragraph in §10.10–10.16 acknowledges this. The check exists only to make accidental rotation to the all-zero handle (which would brick the protocol) impossible without intent.
  • set_controller_gains does NOT touch RedemptionPriceState.controller_integral_term. The spec explicitly forbids resetting it from this instruction — only update_redemption_rate (Plan 2) writes the integral term, via the PI math in §6.4.
  • set_market_price_oracle accepts ANY producer that emits an OraclePriceAccount with the right base/quote — including future aggregators (the spec's multi-oracle redundancy plan). That's why we don't pin program_owner. The cost is a small surface for a malicious admin to point at a malicious oracle, but: (a) the spec explicitly accepts this trade-off, and (b) the freeze authority can stop the bleeding while the admin recovers (§16.2 walkthrough).
  • The two timing parameters live in one instruction even though they're independent. Bundling them prevents the admin from re-pricing the timing constants one-at-a-time and producing an intermediate combination that's internally inconsistent (e.g., rate-update interval > oracle staleness threshold would mean update_redemption_rate runs less often than the oracle's freshness budget — survivable, just dumb). Atomic update keeps the pair internally consistent across transactions.
  • The decode_protocol_parameters helper takes &AccountWithMetadata to avoid moving the value (we still need protocol_parameters.account to build the post-state). write_protocol_parameters_post takes ownership for the post-state. Splitting them this way avoids forcing every caller to manually .clone() the account.

Dependencies

Depends on: #183; Plan 1 (#156).

Blocks: #186.

Metadata

Metadata

Assignees

No one assigned

    Labels

    Type

    Projects

    No projects

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions