Skip to content

[Plan 4 · 02] set_stability_fee_per_millisecond (auto-accrue at OLD rate) #184

Description

@gravityblast

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

  • Step 1: Add the Instruction::SetStabilityFeePerMillisecond variant

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

  • Step 2: Add the bounds module to the admin module shell

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

  • Step 3: Wire the module into stablecoin_program

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.

  • Step 4: Implement the host function

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

    // 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(&params);
    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).

  • Step 5: Set up the test scaffolding

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(&params),
            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,
    )
}
  • Step 6: Happy-path tests

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.

  • Step 7: Auto-accrue test — the load-bearing one

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.

  • Step 8: Authorization panics

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.

  • Step 9: Bound-check panics

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.

  • Step 10: Account ownership panics

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

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

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

Metadata

Metadata

Assignees

No one assigned

    Labels

    Type

    Fields

    No fields configured for Task.

    Projects

    No projects

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions