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:
- Assert
protocol_parameters.account.program_owner == stablecoin_program_id.
- Decode
params: ProtocolParameters from the parameters account.
assert_admin_authorized(&admin, ¶ms).
- Validate the new value(s) against the band(s) in
bounds::* (and, for set_market_price_oracle, validate the new oracle account's shape).
- Write the new field(s) into
params.
- 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
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.
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, ¶ms);
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, ¶ms),
];
(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, ¶ms);
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, ¶ms),
];
(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, ¶ms);
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, ¶ms),
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, ¶ms);
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, ¶ms),
];
(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, ¶ms);
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, ¶ms),
];
(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, ¶ms);
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, ¶ms),
];
(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.
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.
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.
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.
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.
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.
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.
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.
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.
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 +ProtocolParameterswrite, no chained calls).set_market_price_oracleis the only one with a third account input (the new oracle, validated for base/quote shape). The variantInstruction::SetStabilityFeePerMillisecondalready 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_programset 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.rsalongside theset_stability_fee_per_millisecondfunction from issue 02. Each function follows the same six-step recipe:protocol_parameters.account.program_owner == stablecoin_program_id.params: ProtocolParametersfrom the parameters account.assert_admin_authorized(&admin, ¶ms).bounds::*(and, forset_market_price_oracle, validate the new oracle account's shape).params.(post_states, vec![])with three post-states (admin unchanged,ProtocolParametersrewritten, 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-defaultAccountId. We rejectAccountId::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
programs/stablecoin/core/src/lib.rs— add six newInstructionvariants.programs/stablecoin/src/admin.rs— append the six new host functions.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_corestill green.make clippyis green.Implementation steps
InstructionvariantsAppend to
programs/stablecoin/core/src/lib.rsafterSetStabilityFeePerMillisecond:Run
cargo test -p stablecoin_core. Expected: green.admin.rsAppend to
programs/stablecoin/src/admin.rs:Also add the new
twap_oracle_coreimport at the top ofadmin.rs:…or pull it in via a fully-qualified path inline as shown above; either works. Confirm
twap_oracle_coreis inprograms/stablecoin/Cargo.toml's[dependencies](it should be from Plan 1 issue 07).Run
cargo check -p stablecoin_program. Expected: clean.set_minimum_collateralization_ratioAppend to
programs/stablecoin/src/tests/admin_tests.rs:Run
cargo test -p stablecoin_program admin_tests::set_minimum_collateralization_ratio_tests. Expected: 5 PASS.set_controller_gainsAppend:
Run
cargo test -p stablecoin_program admin_tests::set_controller_gains_tests. Expected: 5 PASS.set_market_price_oracleAppend. This is the only setter with a third account input, so the tests need an oracle-account helper:
Run
cargo test -p stablecoin_program admin_tests::set_market_price_oracle_tests. Expected: 5 PASS.set_timing_parametersAppend:
Run
cargo test -p stablecoin_program admin_tests::set_timing_parameters_tests. Expected: 5 PASS.set_adminAppend:
Run
cargo test -p stablecoin_program admin_tests::set_admin_tests. Expected: 4 PASS.set_freeze_authorityAppend:
Run
cargo test -p stablecoin_program admin_tests::set_freeze_authority_tests. Expected: 4 PASS.Expected: green. Total admin_tests count: 11 (from issue 02) + 5 + 5 + 5 + 5 + 4 + 4 = 39.
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
AccountIdrejection inset_admin/set_freeze_authorityis 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_gainsdoes NOT touchRedemptionPriceState.controller_integral_term. The spec explicitly forbids resetting it from this instruction — onlyupdate_redemption_rate(Plan 2) writes the integral term, via the PI math in §6.4.set_market_price_oracleaccepts ANY producer that emits anOraclePriceAccountwith the right base/quote — including future aggregators (the spec's multi-oracle redundancy plan). That's why we don't pinprogram_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).rate-update interval > oracle staleness thresholdwould meanupdate_redemption_rateruns less often than the oracle's freshness budget — survivable, just dumb). Atomic update keeps the pair internally consistent across transactions.decode_protocol_parametershelper takes&AccountWithMetadatato avoid moving the value (we still needprotocol_parameters.accountto build the post-state).write_protocol_parameters_posttakes 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.