Issue 02 — set_stability_fee_per_millisecond (auto-accrue at OLD rate)
Plan: Stablecoin Plan 4 — Admin parameter updates
Depends on: 01 (admin-auth helper), Plan 1 (ProtocolParameters, StabilityFeeAccumulator, compound_rate, mul_div, FIXED_POINT_ONE), Plan 2 (the accumulator-advance routine shared with accrue_stability_fee).
Blocks: 04 (guest wiring + integration tests).
Goal: Implement the set_stability_fee_per_millisecond instruction's host function. This is the special setter in Plan 4 — unlike the other six, it touches THREE accounts (admin read, ProtocolParameters write, StabilityFeeAccumulator write) because it must advance the global fee accumulator at the OLD rate up to now BEFORE writing the new rate. Otherwise the new rate would apply retroactively to the elapsed gap, which the spec explicitly rejects.
Spec reference
§10.10 set_stability_fee_per_millisecond — auto-accrue paragraph + the row in the shared-skeleton table.
§4.2 StabilityFeeAccumulator — accumulator shape.
§5.3 Current value projections — the accumulator-advance formula.
§7.2 Stability fee accumulator monotonicity — the invariant the new bound check (new_rate >= FIXED_POINT_ONE) preserves.
§8 Bound choices — FIXED_POINT_ONE ≤ new_rate ≤ FIXED_POINT_ONE * 2.
Why
The stability fee compounds against the global accumulator. If set_stability_fee_per_millisecond simply overwrote the rate, then at the next accrue_stability_fee call the elapsed-gap math would compound the NEW rate over the whole interval since the last accrual, retroactively re-pricing accrued interest. That's silently wrong: a borrower who took a loan at 5%/year and looked away while the admin raised the rate to 10%/year would discover that the previous month had been re-priced at 10% too.
The fix: on every set_stability_fee_per_millisecond call, atomically (a) advance the accumulator forward to now at the OLD rate first, (b) re-anchor the accumulator with last_accrued_at = now, then (c) write the new rate into ProtocolParameters. Subsequent accrue_stability_fee calls see dt = 0 and immediately start compounding the new rate. The historical interest is locked in correctly.
Architecture
A new programs/stablecoin/src/admin.rs module hosts every admin setter (this issue and issue 03's six setters). This issue adds the first one. The module declares its bound constants up front in a private bounds submodule that mirrors the constants initialize_program already defines — same numbers, same bands, factored so the two can never drift.
The accumulator-advance math is the same operation Plan 2's accrue_stability_fee performs, MINUS the minimum-interval gate. The cleanest call shape is a shared stablecoin_program::stability_fee::advance_accumulator helper that takes (state, rate, now) -> StabilityFeeAccumulator. If Plan 2 has already landed and exposes such a helper, reuse it. If not, this issue inlines the math (a compound_rate(old_rate, dt) call + a mul_div) and leaves a // TODO(plan-2) to factor out once Plan 2 lands. See "Plan 2 dependency note" in the README.
Files
- Create:
programs/stablecoin/src/admin.rs — module shell + the set_stability_fee_per_millisecond function + bound constants.
- Modify:
programs/stablecoin/core/src/lib.rs — add the Instruction::SetStabilityFeePerMillisecond { new_stability_fee_per_millisecond } variant.
- Modify:
programs/stablecoin/src/lib.rs — pub mod admin;.
- Create:
programs/stablecoin/src/tests/admin_tests.rs (or extend tests/mod.rs if already present after Plan 1 landed). Tests for the set_stability_fee_per_millisecond host function.
Acceptance criteria
cargo test -p stablecoin_program admin_tests::set_stability_fee_per_millisecond_ passes (test list in steps 8–11).
cargo test -p stablecoin_core still green (new Instruction variant must not break Borsh / serde derives).
make clippy is green.
- Happy-path test produces exactly 3 post-states (admin unchanged,
ProtocolParameters written with the new rate, StabilityFeeAccumulator written with the OLD-rate accrual applied + last_accrued_at = now) and 0 chained calls.
- The auto-accrue test (
...advances_accumulator_at_old_rate) confirms the new accumulator value matches old_anchor * compound_rate(OLD_rate, dt) / FIXED_POINT_ONE and NOT what would have come from compounding at the new rate.
Implementation steps
Edit programs/stablecoin/core/src/lib.rs. After the existing Instruction variants, add:
/// Update the protocol's stability fee. Auto-accrues the accumulator
/// forward at the OLD rate to `now` BEFORE writing the new rate, so
/// the new value never applies retroactively to elapsed time.
///
/// Required accounts (3):
/// - Admin account (authorized; `account_id == protocol_parameters.admin_account_id`)
/// - `ProtocolParameters` PDA (initialized, writable)
/// - `StabilityFeeAccumulator` PDA (initialized, writable)
///
/// Panics if `new_stability_fee_per_millisecond` falls outside
/// `[FIXED_POINT_ONE, FIXED_POINT_ONE * 2]` (spec §8).
SetStabilityFeePerMillisecond {
/// Per-millisecond stability-fee multiplier, stored as `(1 + r) * FIXED_POINT_ONE`.
new_stability_fee_per_millisecond: u128,
},
Run cargo test -p stablecoin_core. Expected: green (the existing tests still cover Borsh / serde via the type-level test suite).
Create programs/stablecoin/src/admin.rs:
//! Host-side implementations of the admin-only `set_*` instructions
//! (spec §10.10–10.16). All seven share the same skeleton — admin auth
//! gate via [`crate::checks::assert_admin_authorized`] + a writable
//! `ProtocolParameters` post-state — so they live in one module.
use nssa_core::{
account::{Account, AccountId, AccountWithMetadata, Data},
program::{AccountPostState, ChainedCall, ProgramId},
};
use stablecoin_core::{
math::{compound_rate, mul_div, FIXED_POINT_ONE},
ProtocolParameters, StabilityFeeAccumulator,
};
use crate::checks::assert_admin_authorized;
/// Sane-band constants for the admin setters. Mirrors the bands
/// `initialize_program` enforces; both must agree so the protocol
/// can never reach a configuration `initialize_program` would have
/// rejected via a later admin update. See spec §8.
pub mod bounds {
use stablecoin_core::math::FIXED_POINT_ONE;
pub const MIN_STABILITY_FEE_PER_MILLISECOND: u128 = FIXED_POINT_ONE;
pub const MAX_STABILITY_FEE_PER_MILLISECOND: u128 = FIXED_POINT_ONE * 2;
pub const MIN_COLLATERALIZATION_RATIO: u128 = FIXED_POINT_ONE * 110 / 100; // 1.1x
pub const MAX_COLLATERALIZATION_RATIO: u128 = FIXED_POINT_ONE * 10;
/// Kp magnitude cap (spec §8, rescaled `÷10^3` to per-ms).
pub const MAX_PROPORTIONAL_GAIN_MAGNITUDE: i128 = (FIXED_POINT_ONE as i128) * 1_000;
/// Ki magnitude cap (spec §8, rescaled `÷10^6` to per-ms).
pub const MAX_INTEGRAL_GAIN_MAGNITUDE: i128 = FIXED_POINT_ONE as i128;
pub const MIN_TIMING_MILLISECONDS: u64 = 1;
pub const MAX_TIMING_MILLISECONDS: u64 = 86_400_000;
}
Run cargo check -p stablecoin_program. Expected: clean (the module has no public function yet, only constants).
Edit programs/stablecoin/src/lib.rs:
/// Admin-only `set_*` instructions (spec §10.10–10.16).
pub mod admin;
Run cargo check -p stablecoin_program. Expected: clean.
Append to programs/stablecoin/src/admin.rs:
/// Update the stability fee. Auto-accrues the accumulator at the OLD rate
/// forward to `now` first, so the new rate never re-prices the elapsed gap.
///
/// See spec §10.10 for the full account contract and §8 for the band.
#[allow(clippy::needless_pass_by_value)]
pub fn set_stability_fee_per_millisecond(
admin: AccountWithMetadata,
protocol_parameters: AccountWithMetadata,
stability_fee_accumulator: AccountWithMetadata,
stablecoin_program_id: ProgramId,
now: u64,
new_stability_fee_per_millisecond: u128,
) -> (Vec<AccountPostState>, Vec<ChainedCall>) {
// 1. Decode the two writable globals.
assert_eq!(
protocol_parameters.account.program_owner, stablecoin_program_id,
"ProtocolParameters account must be owned by the stablecoin program",
);
assert_eq!(
stability_fee_accumulator.account.program_owner, stablecoin_program_id,
"StabilityFeeAccumulator account must be owned by the stablecoin program",
);
let mut params = ProtocolParameters::try_from(&protocol_parameters.account.data)
.expect("ProtocolParameters must decode");
let mut accumulator = StabilityFeeAccumulator::try_from(&stability_fee_accumulator.account.data)
.expect("StabilityFeeAccumulator must decode");
// 2. Authorization (signature + handle match).
assert_admin_authorized(&admin, ¶ms);
// 3. Validate the new value (spec §8).
assert!(
new_stability_fee_per_millisecond >= bounds::MIN_STABILITY_FEE_PER_MILLISECOND,
"new_stability_fee_per_millisecond below FIXED_POINT_ONE",
);
assert!(
new_stability_fee_per_millisecond <= bounds::MAX_STABILITY_FEE_PER_MILLISECOND,
"new_stability_fee_per_millisecond above sane upper bound",
);
// 4. Auto-accrue the accumulator at the OLD rate up to `now`.
// See spec §5.3 (current-value projection) and §7.2 (monotonicity).
// NOTE: if Plan 2's `stablecoin_program::stability_fee::advance_accumulator`
// has landed, replace the inline math below with a call to it. The
// semantics are identical (this version omits the minimum-interval
// gate — admin setters always advance, regardless of the spam guard).
let dt = now.saturating_sub(accumulator.last_accrued_at);
let factor = compound_rate(params.stability_fee_per_millisecond, dt);
accumulator.accumulated_rate_at_last_accrual = mul_div(
accumulator.accumulated_rate_at_last_accrual,
factor,
FIXED_POINT_ONE,
);
accumulator.last_accrued_at = now;
// 5. Write the new rate.
params.stability_fee_per_millisecond = new_stability_fee_per_millisecond;
// 6. Post-states (3 accounts).
let mut params_post = protocol_parameters.account.clone();
params_post.data = Data::from(¶ms);
let mut accumulator_post = stability_fee_accumulator.account.clone();
accumulator_post.data = Data::from(&accumulator);
let post_states = vec![
AccountPostState::new(admin.account),
AccountPostState::new(params_post),
AccountPostState::new(accumulator_post),
];
let _ = stablecoin_program_id; // reserved for future PDA-derivation checks
let _ = AccountId::default; // silence unused-import lint if PDA helpers added later
(post_states, Vec::new())
}
Run cargo check -p stablecoin_program. Expected: clean (warnings about the let _ = lines are tolerated by clippy::needless_let_underscore; if clippy is stricter, replace them with #[allow] attributes on the fn).
Create (or extend, if Plan 1 already created it) programs/stablecoin/src/tests/admin_tests.rs. If programs/stablecoin/src/tests/mod.rs already exists from Plan 1, register the new module there:
#[cfg(test)]
mod tests {
mod existing;
mod initialize_program_tests;
mod admin_tests; // new
}
The test file:
#![allow(
clippy::indexing_slicing,
clippy::panic,
clippy::unwrap_used,
reason = "tests deliberately panic on bad state via assert!/#[should_panic] and index fixed-size vectors"
)]
use nssa_core::{
account::{Account, AccountId, AccountWithMetadata, Data, Nonce},
program::ProgramId,
};
use stablecoin_core::{
compute_protocol_parameters_pda, compute_stability_fee_accumulator_pda,
math::{compound_rate, mul_div, FIXED_POINT_ONE},
ProtocolParameters, StabilityFeeAccumulator,
};
use stablecoin_program::admin::set_stability_fee_per_millisecond;
const STABLECOIN_PROGRAM_ID: ProgramId = [3u32; 8];
const NOW: u64 = 1_700_000_000_000;
const ADMIN_BYTES: [u8; 32] = [0xA0; 32];
const STRANGER_BYTES: [u8; 32] = [0xBB; 32];
fn admin_id() -> AccountId { AccountId::new(ADMIN_BYTES) }
fn stranger() -> AccountWithMetadata {
AccountWithMetadata {
account: Account {
program_owner: [0u32; 8],
balance: 0,
data: Data::default(),
nonce: Nonce(0),
},
is_authorized: true,
account_id: AccountId::new(STRANGER_BYTES),
}
}
fn admin_account(is_authorized: bool) -> AccountWithMetadata {
AccountWithMetadata {
account: Account {
program_owner: [0u32; 8],
balance: 0,
data: Data::default(),
nonce: Nonce(0),
},
is_authorized,
account_id: admin_id(),
}
}
/// `last_accrued_at` is well before `NOW` so the auto-accrue test sees a
/// meaningful elapsed delta.
const ACCUMULATOR_LAST_ACCRUED_AT: u64 = NOW - 3_600_000; // 1h ago, in ms
/// Initial rate is `FIXED_POINT_ONE + 10^15` ≈ ~10^-12 per millisecond — slow
/// enough that 3_600_000ms of compounding stays well within `u128` but fast
/// enough to produce a non-trivial accumulator delta after one hour.
const OLD_STABILITY_FEE: u128 = FIXED_POINT_ONE + 1_000_000_000_000_000;
fn sample_parameters() -> ProtocolParameters {
ProtocolParameters {
admin_account_id: 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: OLD_STABILITY_FEE,
controller_proportional_gain: 0,
controller_integral_gain: 0,
minimum_collateralization_ratio: FIXED_POINT_ONE * 3 / 2,
minimum_milliseconds_between_rate_updates: 300_000,
maximum_oracle_price_age_milliseconds: 900_000,
is_frozen: false,
}
}
fn sample_accumulator() -> StabilityFeeAccumulator {
StabilityFeeAccumulator {
accumulated_rate_at_last_accrual: FIXED_POINT_ONE,
last_accrued_at: ACCUMULATOR_LAST_ACCRUED_AT,
}
}
fn protocol_parameters_account(params: ProtocolParameters) -> AccountWithMetadata {
AccountWithMetadata {
account: Account {
program_owner: STABLECOIN_PROGRAM_ID,
balance: 0,
data: Data::from(¶ms),
nonce: Nonce(0),
},
is_authorized: false,
account_id: compute_protocol_parameters_pda(STABLECOIN_PROGRAM_ID),
}
}
fn accumulator_account(state: StabilityFeeAccumulator) -> AccountWithMetadata {
AccountWithMetadata {
account: Account {
program_owner: STABLECOIN_PROGRAM_ID,
balance: 0,
data: Data::from(&state),
nonce: Nonce(0),
},
is_authorized: false,
account_id: compute_stability_fee_accumulator_pda(STABLECOIN_PROGRAM_ID),
}
}
fn invoke(
admin: AccountWithMetadata,
params: ProtocolParameters,
state: StabilityFeeAccumulator,
new_rate: u128,
) -> (
Vec<nssa_core::program::AccountPostState>,
Vec<nssa_core::program::ChainedCall>,
) {
set_stability_fee_per_millisecond(
admin,
protocol_parameters_account(params),
accumulator_account(state),
STABLECOIN_PROGRAM_ID,
NOW,
new_rate,
)
}
Append to programs/stablecoin/src/tests/admin_tests.rs:
#[test]
fn set_stability_fee_per_millisecond_happy_path_produces_three_post_states_and_no_chained_calls() {
let new_rate = FIXED_POINT_ONE + 2_000_000_000_000_000;
let (post_states, chained_calls) = invoke(
admin_account(true),
sample_parameters(),
sample_accumulator(),
new_rate,
);
assert_eq!(post_states.len(), 3);
assert_eq!(chained_calls.len(), 0);
}
#[test]
fn set_stability_fee_per_millisecond_writes_new_rate_to_protocol_parameters() {
let new_rate = FIXED_POINT_ONE + 2_000_000_000_000_000;
let (post_states, _) = invoke(
admin_account(true),
sample_parameters(),
sample_accumulator(),
new_rate,
);
let pp_post = &post_states[1];
let decoded = ProtocolParameters::try_from(&pp_post.account().data).expect("decode");
assert_eq!(decoded.stability_fee_per_millisecond, new_rate);
// Every other field must be unchanged.
let expected = sample_parameters();
assert_eq!(decoded.admin_account_id, expected.admin_account_id);
assert_eq!(
decoded.freeze_authority_account_id,
expected.freeze_authority_account_id,
);
assert_eq!(
decoded.minimum_collateralization_ratio,
expected.minimum_collateralization_ratio,
);
assert_eq!(decoded.is_frozen, expected.is_frozen);
}
Run cargo test -p stablecoin_program admin_tests::set_stability_fee_per_millisecond_happy_path. Expected: PASS.
Append:
#[test]
fn set_stability_fee_per_millisecond_advances_accumulator_at_old_rate_not_new_rate() {
let new_rate = FIXED_POINT_ONE + 5_000_000_000_000_000;
let initial_accumulator = sample_accumulator();
let (post_states, _) = invoke(
admin_account(true),
sample_parameters(),
initial_accumulator.clone(),
new_rate,
);
let acc_post = &post_states[2];
let decoded = StabilityFeeAccumulator::try_from(&acc_post.account().data).expect("decode");
// Expected: anchor × compound_rate(OLD_rate, dt) / FIXED_POINT_ONE.
let dt = NOW - ACCUMULATOR_LAST_ACCRUED_AT;
let factor_old = compound_rate(OLD_STABILITY_FEE, dt);
let expected_anchor = mul_div(
initial_accumulator.accumulated_rate_at_last_accrual,
factor_old,
FIXED_POINT_ONE,
);
assert_eq!(
decoded.accumulated_rate_at_last_accrual, expected_anchor,
"accumulator must be advanced at the OLD rate, not the new one",
);
assert_eq!(decoded.last_accrued_at, NOW);
// Sanity: had we advanced at the new rate, the anchor would be different.
let factor_new = compound_rate(new_rate, dt);
let wrong_anchor = mul_div(
initial_accumulator.accumulated_rate_at_last_accrual,
factor_new,
FIXED_POINT_ONE,
);
assert_ne!(
wrong_anchor, expected_anchor,
"test setup must produce distinct outcomes under OLD vs NEW rate \
— otherwise this test cannot catch the regression it exists to catch",
);
}
#[test]
fn set_stability_fee_per_millisecond_zero_dt_leaves_accumulator_anchor_unchanged() {
let initial_accumulator = StabilityFeeAccumulator {
accumulated_rate_at_last_accrual: FIXED_POINT_ONE * 12345 / 10000,
last_accrued_at: NOW, // already up to date
};
let new_rate = FIXED_POINT_ONE + 1;
let (post_states, _) = invoke(
admin_account(true),
sample_parameters(),
initial_accumulator.clone(),
new_rate,
);
let acc_post = &post_states[2];
let decoded = StabilityFeeAccumulator::try_from(&acc_post.account().data).expect("decode");
assert_eq!(
decoded.accumulated_rate_at_last_accrual,
initial_accumulator.accumulated_rate_at_last_accrual,
);
assert_eq!(decoded.last_accrued_at, NOW);
}
Run cargo test -p stablecoin_program admin_tests::set_stability_fee_per_millisecond_advances. Expected: PASS. The "sanity" assert_ne! is paranoia about a future refactor that picks a rate / dt combination where OLD and NEW compound to the same value (e.g., both at FIXED_POINT_ONE) — if that ever happens, the test will fail loudly and tell the future maintainer to pick a different rate.
Append:
#[test]
#[should_panic(expected = "Admin authorization is missing")]
fn set_stability_fee_per_millisecond_rejects_unauthorized_caller() {
let _ = invoke(
admin_account(false),
sample_parameters(),
sample_accumulator(),
FIXED_POINT_ONE + 1,
);
}
#[test]
#[should_panic(
expected = "Admin account id does not match ProtocolParameters.admin_account_id"
)]
fn set_stability_fee_per_millisecond_rejects_caller_with_wrong_account_id() {
let _ = invoke(
stranger(),
sample_parameters(),
sample_accumulator(),
FIXED_POINT_ONE + 1,
);
}
Run cargo test -p stablecoin_program admin_tests::set_stability_fee_per_millisecond_rejects_un -- --include-ignored. Expected: PASS.
Append:
#[test]
#[should_panic(expected = "new_stability_fee_per_millisecond below FIXED_POINT_ONE")]
fn set_stability_fee_per_millisecond_rejects_rate_below_fixed_point_one() {
let _ = invoke(
admin_account(true),
sample_parameters(),
sample_accumulator(),
FIXED_POINT_ONE - 1,
);
}
#[test]
#[should_panic(expected = "new_stability_fee_per_millisecond above sane upper bound")]
fn set_stability_fee_per_millisecond_rejects_rate_above_2x() {
let _ = invoke(
admin_account(true),
sample_parameters(),
sample_accumulator(),
FIXED_POINT_ONE * 2 + 1,
);
}
#[test]
fn set_stability_fee_per_millisecond_accepts_rate_equal_to_lower_bound() {
let _ = invoke(
admin_account(true),
sample_parameters(),
sample_accumulator(),
FIXED_POINT_ONE,
);
}
#[test]
fn set_stability_fee_per_millisecond_accepts_rate_equal_to_upper_bound() {
let _ = invoke(
admin_account(true),
sample_parameters(),
sample_accumulator(),
FIXED_POINT_ONE * 2,
);
}
Run cargo test -p stablecoin_program admin_tests::set_stability_fee_per_millisecond_rejects_rate. Expected: 2 PASS plus the two boundary-acceptance tests.
Append:
#[test]
#[should_panic(expected = "ProtocolParameters account must be owned by the stablecoin program")]
fn set_stability_fee_per_millisecond_rejects_protocol_parameters_with_wrong_owner() {
let mut pp = protocol_parameters_account(sample_parameters());
pp.account.program_owner = [99u32; 8];
let _ = set_stability_fee_per_millisecond(
admin_account(true),
pp,
accumulator_account(sample_accumulator()),
STABLECOIN_PROGRAM_ID,
NOW,
FIXED_POINT_ONE + 1,
);
}
#[test]
#[should_panic(expected = "StabilityFeeAccumulator account must be owned by the stablecoin program")]
fn set_stability_fee_per_millisecond_rejects_accumulator_with_wrong_owner() {
let mut acc = accumulator_account(sample_accumulator());
acc.account.program_owner = [99u32; 8];
let _ = set_stability_fee_per_millisecond(
admin_account(true),
protocol_parameters_account(sample_parameters()),
acc,
STABLECOIN_PROGRAM_ID,
NOW,
FIXED_POINT_ONE + 1,
);
}
Run cargo test -p stablecoin_program admin_tests::set_stability_fee_per_millisecond. Expected: full suite PASS (~11 tests).
make clippy
RISC0_DEV_MODE=1 cargo test -p stablecoin_program
RISC0_DEV_MODE=1 cargo test -p stablecoin_core
Expected: green.
git add programs/stablecoin/src/admin.rs \
programs/stablecoin/src/tests/admin_tests.rs \
programs/stablecoin/src/tests/mod.rs \
programs/stablecoin/src/lib.rs \
programs/stablecoin/core/src/lib.rs
cargo +nightly fmt --all
git add -u
git commit -m "feat(stablecoin): implement set_stability_fee_per_millisecond (auto-accrue)
The special admin setter: touches three accounts (admin read,
ProtocolParameters write, StabilityFeeAccumulator write) because it
must advance the global accumulator at the OLD rate up to 'now'
before writing the new rate. Otherwise the new rate would
retroactively re-price the elapsed gap, which spec §10.10 explicitly
rejects.
- Adds Instruction::SetStabilityFeePerMillisecond variant to stablecoin_core.
- Adds the admin module shell with the shared bound constants from
spec §8 (so this and the other six setters share one source of
truth with initialize_program).
- 11 unit tests cover: 3 post-states + 0 chained calls (happy path),
the new rate landing in ProtocolParameters, the accumulator
advancing at the OLD rate (load-bearing — explicit sanity assert
that OLD and NEW produce different anchors for the test setup),
zero-dt no-op, both authorization failures via
assert_admin_authorized, both bound violations, both boundary
accept cases, and the two ownership failures."
Notes for reviewer / future maintainers
- The
// NOTE: if Plan 2's [...] has landed, replace the inline math comment in step 4 is load-bearing — once Plan 2 lands a public advance_accumulator helper, this issue's inlined math becomes a redundant copy. A follow-up cleanup PR can DRY it out without changing observable behavior.
- The accumulator auto-accrue here is NOT gated by any minimum-interval throttle — the admin is allowed to advance the accumulator at every parameter update regardless of how recently
accrue_stability_fee last ran. (accrue_stability_fee itself is idempotent and unthrottled too; the fee-accrual throttle was removed from the design.)
- The
_ = stablecoin_program_id line is there because future revisions may add a PDA-derivation check (protocol_parameters.account_id == compute_protocol_parameters_pda(stablecoin_program_id)). The argument is plumbed through the host function signature so the guest entry doesn't need to change later when that's added. If a stricter clippy lint forbids the underscore binding, replace with #[allow(unused_variables)] on the fn.
- We do NOT enforce monotonicity of
new_stability_fee_per_millisecond (e.g., "new rate must be ≥ FIXED_POINT_ONE"). The lower bound FIXED_POINT_ONE from §8 IS exactly that monotonicity guarantee — at FIXED_POINT_ONE the rate is 1.0 / millisecond (no growth, no decay), and anything ≥ FIXED_POINT_ONE keeps the accumulator non-decreasing per the §7.2 invariant.
- The
is_authorized flag on admin.account in the helper input is what assert_admin_authorized checks; we don't separately re-check it.
- A future Plan 5 freeze / unfreeze instruction won't reuse this setter's
bounds module since it has no scalar to validate. That's fine.
Dependencies
Depends on: #183; Plan 1 (#156); Plan 2 (#165, accumulator-advance routine).
Blocks: #186.
Issue 02 —
set_stability_fee_per_millisecond(auto-accrue at OLD rate)Plan: Stablecoin Plan 4 — Admin parameter updates
Depends on: 01 (admin-auth helper), Plan 1 (
ProtocolParameters,StabilityFeeAccumulator,compound_rate,mul_div,FIXED_POINT_ONE), Plan 2 (the accumulator-advance routine shared withaccrue_stability_fee).Blocks: 04 (guest wiring + integration tests).
Goal: Implement the
set_stability_fee_per_millisecondinstruction's host function. This is the special setter in Plan 4 — unlike the other six, it touches THREE accounts (admin read,ProtocolParameterswrite,StabilityFeeAccumulatorwrite) because it must advance the global fee accumulator at the OLD rate up tonowBEFORE writing the new rate. Otherwise the new rate would apply retroactively to the elapsed gap, which the spec explicitly rejects.Spec reference
§10.10 set_stability_fee_per_millisecond— auto-accrue paragraph + the row in the shared-skeleton table.§4.2 StabilityFeeAccumulator— accumulator shape.§5.3 Current value projections— the accumulator-advance formula.§7.2 Stability fee accumulator monotonicity— the invariant the new bound check (new_rate >= FIXED_POINT_ONE) preserves.§8 Bound choices—FIXED_POINT_ONE ≤ new_rate ≤ FIXED_POINT_ONE * 2.Why
The stability fee compounds against the global accumulator. If
set_stability_fee_per_millisecondsimply overwrote the rate, then at the nextaccrue_stability_feecall the elapsed-gap math would compound the NEW rate over the whole interval since the last accrual, retroactively re-pricing accrued interest. That's silently wrong: a borrower who took a loan at 5%/year and looked away while the admin raised the rate to 10%/year would discover that the previous month had been re-priced at 10% too.The fix: on every
set_stability_fee_per_millisecondcall, atomically (a) advance the accumulator forward tonowat the OLD rate first, (b) re-anchor the accumulator withlast_accrued_at = now, then (c) write the new rate intoProtocolParameters. Subsequentaccrue_stability_feecalls seedt = 0and immediately start compounding the new rate. The historical interest is locked in correctly.Architecture
A new
programs/stablecoin/src/admin.rsmodule hosts every admin setter (this issue and issue 03's six setters). This issue adds the first one. The module declares its bound constants up front in a privateboundssubmodule that mirrors the constantsinitialize_programalready defines — same numbers, same bands, factored so the two can never drift.The accumulator-advance math is the same operation Plan 2's
accrue_stability_feeperforms, MINUS the minimum-interval gate. The cleanest call shape is a sharedstablecoin_program::stability_fee::advance_accumulatorhelper that takes(state, rate, now) -> StabilityFeeAccumulator. If Plan 2 has already landed and exposes such a helper, reuse it. If not, this issue inlines the math (acompound_rate(old_rate, dt)call + amul_div) and leaves a// TODO(plan-2)to factor out once Plan 2 lands. See "Plan 2 dependency note" in the README.Files
programs/stablecoin/src/admin.rs— module shell + theset_stability_fee_per_millisecondfunction + bound constants.programs/stablecoin/core/src/lib.rs— add theInstruction::SetStabilityFeePerMillisecond { new_stability_fee_per_millisecond }variant.programs/stablecoin/src/lib.rs—pub mod admin;.programs/stablecoin/src/tests/admin_tests.rs(or extendtests/mod.rsif already present after Plan 1 landed). Tests for theset_stability_fee_per_millisecondhost function.Acceptance criteria
cargo test -p stablecoin_program admin_tests::set_stability_fee_per_millisecond_passes (test list in steps 8–11).cargo test -p stablecoin_corestill green (newInstructionvariant must not break Borsh / serde derives).make clippyis green.ProtocolParameterswritten with the new rate,StabilityFeeAccumulatorwritten with the OLD-rate accrual applied +last_accrued_at = now) and 0 chained calls....advances_accumulator_at_old_rate) confirms the new accumulator value matchesold_anchor * compound_rate(OLD_rate, dt) / FIXED_POINT_ONEand NOT what would have come from compounding at the new rate.Implementation steps
Instruction::SetStabilityFeePerMillisecondvariantEdit
programs/stablecoin/core/src/lib.rs. After the existingInstructionvariants, add:Run
cargo test -p stablecoin_core. Expected: green (the existing tests still cover Borsh / serde via the type-level test suite).Create
programs/stablecoin/src/admin.rs:Run
cargo check -p stablecoin_program. Expected: clean (the module has no public function yet, only constants).stablecoin_programEdit
programs/stablecoin/src/lib.rs:Run
cargo check -p stablecoin_program. Expected: clean.Append to
programs/stablecoin/src/admin.rs:Run
cargo check -p stablecoin_program. Expected: clean (warnings about thelet _ =lines are tolerated byclippy::needless_let_underscore; if clippy is stricter, replace them with#[allow]attributes on the fn).Create (or extend, if Plan 1 already created it)
programs/stablecoin/src/tests/admin_tests.rs. Ifprograms/stablecoin/src/tests/mod.rsalready exists from Plan 1, register the new module there:The test file:
Append to
programs/stablecoin/src/tests/admin_tests.rs:Run
cargo test -p stablecoin_program admin_tests::set_stability_fee_per_millisecond_happy_path. Expected: PASS.Append:
Run
cargo test -p stablecoin_program admin_tests::set_stability_fee_per_millisecond_advances. Expected: PASS. The "sanity"assert_ne!is paranoia about a future refactor that picks a rate / dt combination where OLD and NEW compound to the same value (e.g., both atFIXED_POINT_ONE) — if that ever happens, the test will fail loudly and tell the future maintainer to pick a different rate.Append:
Run
cargo test -p stablecoin_program admin_tests::set_stability_fee_per_millisecond_rejects_un -- --include-ignored. Expected: PASS.Append:
Run
cargo test -p stablecoin_program admin_tests::set_stability_fee_per_millisecond_rejects_rate. Expected: 2 PASS plus the two boundary-acceptance tests.Append:
Run
cargo test -p stablecoin_program admin_tests::set_stability_fee_per_millisecond. Expected: full suite PASS (~11 tests).Expected: green.
git add programs/stablecoin/src/admin.rs \ programs/stablecoin/src/tests/admin_tests.rs \ programs/stablecoin/src/tests/mod.rs \ programs/stablecoin/src/lib.rs \ programs/stablecoin/core/src/lib.rs cargo +nightly fmt --all git add -u git commit -m "feat(stablecoin): implement set_stability_fee_per_millisecond (auto-accrue) The special admin setter: touches three accounts (admin read, ProtocolParameters write, StabilityFeeAccumulator write) because it must advance the global accumulator at the OLD rate up to 'now' before writing the new rate. Otherwise the new rate would retroactively re-price the elapsed gap, which spec §10.10 explicitly rejects. - Adds Instruction::SetStabilityFeePerMillisecond variant to stablecoin_core. - Adds the admin module shell with the shared bound constants from spec §8 (so this and the other six setters share one source of truth with initialize_program). - 11 unit tests cover: 3 post-states + 0 chained calls (happy path), the new rate landing in ProtocolParameters, the accumulator advancing at the OLD rate (load-bearing — explicit sanity assert that OLD and NEW produce different anchors for the test setup), zero-dt no-op, both authorization failures via assert_admin_authorized, both bound violations, both boundary accept cases, and the two ownership failures."Notes for reviewer / future maintainers
// NOTE: if Plan 2's [...] has landed, replace the inline mathcomment in step 4 is load-bearing — once Plan 2 lands a publicadvance_accumulatorhelper, this issue's inlined math becomes a redundant copy. A follow-up cleanup PR can DRY it out without changing observable behavior.accrue_stability_feelast ran. (accrue_stability_feeitself is idempotent and unthrottled too; the fee-accrual throttle was removed from the design.)_ = stablecoin_program_idline is there because future revisions may add a PDA-derivation check (protocol_parameters.account_id == compute_protocol_parameters_pda(stablecoin_program_id)). The argument is plumbed through the host function signature so the guest entry doesn't need to change later when that's added. If a stricter clippy lint forbids the underscore binding, replace with#[allow(unused_variables)]on the fn.new_stability_fee_per_millisecond(e.g., "new rate must be ≥ FIXED_POINT_ONE"). The lower boundFIXED_POINT_ONEfrom §8 IS exactly that monotonicity guarantee — atFIXED_POINT_ONEthe rate is 1.0 / millisecond (no growth, no decay), and anything ≥FIXED_POINT_ONEkeeps the accumulator non-decreasing per the §7.2 invariant.is_authorizedflag onadmin.accountin the helper input is whatassert_admin_authorizedchecks; we don't separately re-check it.boundsmodule since it has no scalar to validate. That's fine.Dependencies
Depends on: #183; Plan 1 (#156); Plan 2 (#165, accumulator-advance routine).
Blocks: #186.