feat(stablecoin): implement accrue_stability_fee host function

closes #169
This commit is contained in:
Andrea Franz
2026-08-21 13:37:08 +00:00
parent f5084a4bed
commit 950a5b834e
3 changed files with 598 additions and 0 deletions
@@ -0,0 +1,374 @@
//! Host-side implementation of `Instruction::AccrueStabilityFee` (spec §10.2).
//!
//! Wall-clock time comes from the system `CLOCK_01` account, same as
//! [`crate::initialize_program`] — the pinned `spel-framework`'s
//! `ProgramContext` exposes no clock.
use clock_core::{ClockAccountData, CLOCK_01_PROGRAM_ACCOUNT_ID};
use nssa_core::{
account::{Account, AccountWithMetadata, Data},
program::{AccountPostState, ChainedCall, ProgramId},
};
use stablecoin_core::{
compute_stability_fee_accumulator_pda, math::compute_current_accumulated_rate,
ProtocolParameters, StabilityFeeAccumulator,
};
/// Advance the global stability-fee accumulator to the clock's timestamp.
///
/// Permissionless and idempotent: there is NO minimum-interval throttle (spec
/// §10.2, §5.3, §6.1). The read-side projection keeps every position current
/// regardless of cadence, so a redundant call is a harmless no-op that just
/// re-stamps `last_accrued_at`. Never blocked by the frozen flag.
///
/// See spec §10.2 for the full account contract and panic conditions.
#[allow(clippy::needless_pass_by_value)]
pub fn accrue_stability_fee(
caller: AccountWithMetadata,
protocol_parameters: AccountWithMetadata,
stability_fee_accumulator: AccountWithMetadata,
clock: AccountWithMetadata,
stablecoin_program_id: ProgramId,
) -> (Vec<AccountPostState>, Vec<ChainedCall>) {
assert!(caller.is_authorized, "Caller authorization is missing");
let (params, accumulator) = decode_fee_accrual_inputs(
&protocol_parameters,
&stability_fee_accumulator,
stablecoin_program_id,
);
let now = read_clock(&clock);
let accumulator_post =
advance_fee_accumulator(&stability_fee_accumulator, &params, &accumulator, now);
let post_states = vec![
AccountPostState::new(caller.account),
AccountPostState::new(protocol_parameters.account),
AccountPostState::new(accumulator_post),
AccountPostState::new(clock.account),
];
(post_states, vec![])
}
/// Validate and decode the two accounts the fee half needs.
///
/// Shared with [`crate::refresh_globals`] so the combined poke can never drift
/// from the standalone one.
pub(crate) fn decode_fee_accrual_inputs(
protocol_parameters: &AccountWithMetadata,
stability_fee_accumulator: &AccountWithMetadata,
stablecoin_program_id: ProgramId,
) -> (ProtocolParameters, StabilityFeeAccumulator) {
assert_ne!(
protocol_parameters.account,
Account::default(),
"ProtocolParameters account must be initialized"
);
assert_eq!(
protocol_parameters.account.program_owner, stablecoin_program_id,
"ProtocolParameters not owned by this stablecoin program"
);
assert_ne!(
stability_fee_accumulator.account,
Account::default(),
"StabilityFeeAccumulator account must be initialized"
);
assert_eq!(
stability_fee_accumulator.account.program_owner, stablecoin_program_id,
"StabilityFeeAccumulator not owned by this stablecoin program"
);
assert_eq!(
stability_fee_accumulator.account_id,
compute_stability_fee_accumulator_pda(stablecoin_program_id),
"StabilityFeeAccumulator account ID does not match expected PDA derivation"
);
let params = ProtocolParameters::try_from(&protocol_parameters.account.data)
.expect("ProtocolParameters must decode");
let accumulator = StabilityFeeAccumulator::try_from(&stability_fee_accumulator.account.data)
.expect("StabilityFeeAccumulator must decode");
(params, accumulator)
}
/// Project the accumulator to `now` and return the updated account.
///
/// Shared with [`crate::refresh_globals`] — the fee half is identical in both.
pub(crate) fn advance_fee_accumulator(
stability_fee_accumulator: &AccountWithMetadata,
params: &ProtocolParameters,
accumulator: &StabilityFeeAccumulator,
now: u64,
) -> Account {
let updated = StabilityFeeAccumulator {
accumulated_rate_at_last_accrual: compute_current_accumulated_rate(
accumulator.accumulated_rate_at_last_accrual,
params.stability_fee_per_millisecond,
accumulator.last_accrued_at,
now,
),
last_accrued_at: now,
};
let mut accumulator_post = stability_fee_accumulator.account.clone();
accumulator_post.data = Data::from(&updated);
accumulator_post
}
/// Read the millisecond wall-clock timestamp from the system `CLOCK_01` account.
///
/// Shared by all three pokes.
pub(crate) fn read_clock(clock: &AccountWithMetadata) -> u64 {
assert_eq!(
clock.account_id, CLOCK_01_PROGRAM_ACCOUNT_ID,
"Clock account must be the system CLOCK_01 account"
);
assert_ne!(
clock.account,
Account::default(),
"Clock account must be initialized"
);
ClockAccountData::from_bytes(clock.account.data.as_ref()).timestamp
}
#[cfg(test)]
#[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"
)]
mod tests {
use nssa_core::account::{AccountId, Nonce};
use stablecoin_core::math::FIXED_POINT_ONE;
use super::*;
use crate::test_support::{
accumulator_account, caller_account, clock_account, protocol_parameters_account,
uninitialized, ParameterOverrides, ACCUMULATOR_ANCHOR, NOW, STABLECOIN_PROGRAM_ID, T0,
TEST_STABILITY_FEE_PER_MILLISECOND,
};
fn invoke() -> (Vec<AccountPostState>, Vec<ChainedCall>) {
accrue_stability_fee(
caller_account(),
protocol_parameters_account(ParameterOverrides::default()),
accumulator_account(ACCUMULATOR_ANCHOR, T0),
clock_account(NOW),
STABLECOIN_PROGRAM_ID,
)
}
#[test]
fn happy_path_returns_four_post_states_and_no_chained_calls() {
let (post_states, chained_calls) = invoke();
assert_eq!(post_states.len(), 4);
assert!(chained_calls.is_empty());
}
#[test]
fn happy_path_advances_accumulator_by_the_projection() {
let (post_states, _) = invoke();
let decoded =
StabilityFeeAccumulator::try_from(&post_states[2].account().data).expect("decode");
let expected = compute_current_accumulated_rate(
ACCUMULATOR_ANCHOR,
TEST_STABILITY_FEE_PER_MILLISECOND,
T0,
NOW,
);
assert_eq!(decoded.accumulated_rate_at_last_accrual, expected);
assert!(decoded.accumulated_rate_at_last_accrual > ACCUMULATOR_ANCHOR);
assert_eq!(decoded.last_accrued_at, NOW);
}
#[test]
fn happy_path_claims_no_pda_and_echoes_the_clock() {
// The accumulator PDA was claimed at initialize_program time; this poke
// only rewrites its data.
let (post_states, _) = invoke();
assert_eq!(post_states[2].required_claim(), None);
assert_eq!(post_states[3].required_claim(), None);
assert_eq!(post_states[3].account(), &clock_account(NOW).account);
}
#[test]
fn no_throttle_zero_elapsed_is_a_noop_restamp() {
// There is NO minimum-interval throttle: calling with
// `last_accrued_at == now` does not panic. Zero elapsed time collapses to
// the compound_rate identity, so the anchor is unchanged.
let (post_states, _) = accrue_stability_fee(
caller_account(),
protocol_parameters_account(ParameterOverrides::default()),
accumulator_account(ACCUMULATOR_ANCHOR, NOW),
clock_account(NOW),
STABLECOIN_PROGRAM_ID,
);
let decoded = StabilityFeeAccumulator::try_from(&post_states[2].account().data).unwrap();
assert_eq!(decoded.accumulated_rate_at_last_accrual, ACCUMULATOR_ANCHOR);
assert_eq!(decoded.last_accrued_at, NOW);
}
#[test]
fn accrual_is_allowed_while_frozen() {
// Pokes are never blocked by the frozen flag (spec §10.2).
let (post_states, _) = accrue_stability_fee(
caller_account(),
protocol_parameters_account(ParameterOverrides {
is_frozen: true,
..ParameterOverrides::default()
}),
accumulator_account(ACCUMULATOR_ANCHOR, T0),
clock_account(NOW),
STABLECOIN_PROGRAM_ID,
);
let decoded = StabilityFeeAccumulator::try_from(&post_states[2].account().data).unwrap();
assert_eq!(decoded.last_accrued_at, NOW);
}
#[test]
#[should_panic(expected = "Caller authorization is missing")]
fn requires_caller_authorization() {
let mut caller = caller_account();
caller.is_authorized = false;
let _ = accrue_stability_fee(
caller,
protocol_parameters_account(ParameterOverrides::default()),
accumulator_account(ACCUMULATOR_ANCHOR, T0),
clock_account(NOW),
STABLECOIN_PROGRAM_ID,
);
}
#[test]
#[should_panic(expected = "ProtocolParameters account must be initialized")]
fn rejects_uninitialized_protocol_parameters() {
let _ = accrue_stability_fee(
caller_account(),
uninitialized(stablecoin_core::compute_protocol_parameters_pda(
STABLECOIN_PROGRAM_ID,
)),
accumulator_account(ACCUMULATOR_ANCHOR, T0),
clock_account(NOW),
STABLECOIN_PROGRAM_ID,
);
}
#[test]
#[should_panic(expected = "ProtocolParameters not owned by this stablecoin program")]
fn rejects_foreign_owned_protocol_parameters() {
let mut parameters = protocol_parameters_account(ParameterOverrides::default());
parameters.account.program_owner = [9u32; 8];
let _ = accrue_stability_fee(
caller_account(),
parameters,
accumulator_account(ACCUMULATOR_ANCHOR, T0),
clock_account(NOW),
STABLECOIN_PROGRAM_ID,
);
}
#[test]
#[should_panic(expected = "StabilityFeeAccumulator account must be initialized")]
fn rejects_uninitialized_accumulator() {
let _ = accrue_stability_fee(
caller_account(),
protocol_parameters_account(ParameterOverrides::default()),
uninitialized(compute_stability_fee_accumulator_pda(STABLECOIN_PROGRAM_ID)),
clock_account(NOW),
STABLECOIN_PROGRAM_ID,
);
}
#[test]
#[should_panic(expected = "StabilityFeeAccumulator not owned by this stablecoin program")]
fn rejects_foreign_owned_accumulator() {
let mut accumulator = accumulator_account(ACCUMULATOR_ANCHOR, T0);
accumulator.account.program_owner = [9u32; 8];
let _ = accrue_stability_fee(
caller_account(),
protocol_parameters_account(ParameterOverrides::default()),
accumulator,
clock_account(NOW),
STABLECOIN_PROGRAM_ID,
);
}
#[test]
#[should_panic(
expected = "StabilityFeeAccumulator account ID does not match expected PDA derivation"
)]
fn rejects_wrong_accumulator_pda() {
let mut accumulator = accumulator_account(ACCUMULATOR_ANCHOR, T0);
accumulator.account_id = AccountId::new([0xDE; 32]);
let _ = accrue_stability_fee(
caller_account(),
protocol_parameters_account(ParameterOverrides::default()),
accumulator,
clock_account(NOW),
STABLECOIN_PROGRAM_ID,
);
}
#[test]
#[should_panic(expected = "Clock account must be the system CLOCK_01 account")]
fn rejects_wrong_clock_account() {
let mut clock = clock_account(NOW);
clock.account_id = AccountId::new([0xC1; 32]);
let _ = accrue_stability_fee(
caller_account(),
protocol_parameters_account(ParameterOverrides::default()),
accumulator_account(ACCUMULATOR_ANCHOR, T0),
clock,
STABLECOIN_PROGRAM_ID,
);
}
#[test]
#[should_panic(expected = "Clock account must be initialized")]
fn rejects_uninitialized_clock_account() {
let clock = AccountWithMetadata {
account: Account::default(),
is_authorized: false,
account_id: CLOCK_01_PROGRAM_ACCOUNT_ID,
};
let _ = accrue_stability_fee(
caller_account(),
protocol_parameters_account(ParameterOverrides::default()),
accumulator_account(ACCUMULATOR_ANCHOR, T0),
clock,
STABLECOIN_PROGRAM_ID,
);
}
#[test]
fn a_zero_fee_rate_leaves_the_anchor_untouched() {
// FIXED_POINT_ONE is "no drift": the accumulator only re-stamps.
let (post_states, _) = accrue_stability_fee(
caller_account(),
protocol_parameters_account(ParameterOverrides {
stability_fee_per_millisecond: FIXED_POINT_ONE,
..ParameterOverrides::default()
}),
accumulator_account(ACCUMULATOR_ANCHOR, T0),
clock_account(NOW),
STABLECOIN_PROGRAM_ID,
);
let decoded = StabilityFeeAccumulator::try_from(&post_states[2].account().data).unwrap();
assert_eq!(decoded.accumulated_rate_at_last_accrual, ACCUMULATOR_ANCHOR);
assert_eq!(decoded.last_accrued_at, NOW);
}
#[test]
fn accumulator_post_state_preserves_owner_and_nonce() {
let (post_states, _) = invoke();
let original = accumulator_account(ACCUMULATOR_ANCHOR, T0);
assert_eq!(
post_states[2].account().program_owner,
original.account.program_owner
);
assert_eq!(post_states[2].account().nonce, Nonce(0));
}
}
+6
View File
@@ -2,6 +2,9 @@
pub use stablecoin_core as core;
/// Permissionless poke: advance the global stability-fee accumulator.
pub mod accrue_stability_fee;
/// Bootstrap the protocol: create the global PDAs and the stablecoin definition.
pub mod initialize_program;
@@ -14,5 +17,8 @@ pub mod repay_debt;
/// Withdraw collateral from an existing position back to a user-controlled holding.
pub mod withdraw_collateral;
#[cfg(test)]
mod test_support;
#[cfg(test)]
mod tests;
+218
View File
@@ -0,0 +1,218 @@
//! Shared account builders for the poke host-function unit tests.
//!
//! The three pokes (`accrue_stability_fee`, `update_redemption_rate`,
//! `refresh_globals`) validate overlapping account sets, so their tests would
//! otherwise repeat the same fixtures three times.
use clock_core::{ClockAccountData, CLOCK_01_PROGRAM_ACCOUNT_ID};
use nssa_core::{
account::{Account, AccountId, AccountWithMetadata, Data, Nonce},
program::ProgramId,
};
use stablecoin_core::{
compute_protocol_parameters_pda, compute_redemption_price_state_pda,
compute_stability_fee_accumulator_pda, math::FIXED_POINT_ONE, ProtocolParameters,
RedemptionPriceState, StabilityFeeAccumulator,
};
use twap_oracle_core::OraclePriceAccount;
pub(crate) const STABLECOIN_PROGRAM_ID: ProgramId = [3u32; 8];
pub(crate) const ORACLE_PROGRAM_ID: ProgramId = [4u32; 8];
pub(crate) const CLOCK_PROGRAM_ID: ProgramId = [5u32; 8];
/// Timestamps are Unix milliseconds, matching the `CLOCK_01` account.
pub(crate) const T0: u64 = 1_700_000_000_000;
/// Ten minutes after `T0` — past the 300_000 ms rate-update interval below.
pub(crate) const NOW: u64 = T0 + 600_000;
/// ~5% annual in per-millisecond fixed-point, the same realistic rate the
/// `initialize_program` tests use.
pub(crate) const TEST_STABILITY_FEE_PER_MILLISECOND: u128 = FIXED_POINT_ONE + 1_500_000_000_000_000;
pub(crate) const TEST_MINIMUM_MILLISECONDS_BETWEEN_RATE_UPDATES: u64 = 300_000;
pub(crate) const TEST_MAXIMUM_ORACLE_PRICE_AGE_MILLISECONDS: u64 = 900_000;
/// Starting accumulator anchor: `1.0`.
pub(crate) const ACCUMULATOR_ANCHOR: u128 = FIXED_POINT_ONE;
/// Starting redemption price: `0.5` collateral per stablecoin.
pub(crate) const REDEMPTION_PRICE_ANCHOR: u128 = FIXED_POINT_ONE / 2;
/// A market price below the redemption anchor, so `error > 0` and a positive
/// `Kp` drives the rate above `FIXED_POINT_ONE`.
pub(crate) const MARKET_PRICE_BELOW_ANCHOR: u128 = FIXED_POINT_ONE / 4;
/// Knobs the poke tests actually vary. Everything else is fixed.
pub(crate) struct ParameterOverrides {
pub(crate) stability_fee_per_millisecond: u128,
pub(crate) controller_proportional_gain: i128,
pub(crate) controller_integral_gain: i128,
pub(crate) is_frozen: bool,
}
impl Default for ParameterOverrides {
fn default() -> Self {
Self {
stability_fee_per_millisecond: TEST_STABILITY_FEE_PER_MILLISECOND,
controller_proportional_gain: FIXED_POINT_ONE as i128,
controller_integral_gain: 0,
is_frozen: false,
}
}
}
pub(crate) fn caller_id() -> AccountId {
AccountId::new([0xCA; 32])
}
pub(crate) fn admin_id() -> AccountId {
AccountId::new([0xA0; 32])
}
pub(crate) fn freeze_authority_id() -> AccountId {
AccountId::new([0xFE; 32])
}
pub(crate) fn stablecoin_definition_id() -> AccountId {
AccountId::new([0x10; 32])
}
pub(crate) fn collateral_definition_id() -> AccountId {
AccountId::new([0x20; 32])
}
pub(crate) fn oracle_id() -> AccountId {
AccountId::new([0x30; 32])
}
pub(crate) fn oracle_source_id() -> AccountId {
AccountId::new([0x31; 32])
}
pub(crate) fn protocol_parameters_id() -> AccountId {
compute_protocol_parameters_pda(STABLECOIN_PROGRAM_ID)
}
pub(crate) fn accumulator_id() -> AccountId {
compute_stability_fee_accumulator_pda(STABLECOIN_PROGRAM_ID)
}
pub(crate) fn redemption_price_state_id() -> AccountId {
compute_redemption_price_state_pda(STABLECOIN_PROGRAM_ID)
}
/// An authorized caller. Pokes are permissionless, so any signer works.
pub(crate) fn caller_account() -> AccountWithMetadata {
AccountWithMetadata {
account: Account::default(),
is_authorized: true,
account_id: caller_id(),
}
}
/// An uninitialized account at `account_id` — the shape every "must be
/// initialized" assertion rejects.
pub(crate) fn uninitialized(account_id: AccountId) -> AccountWithMetadata {
AccountWithMetadata {
account: Account::default(),
is_authorized: false,
account_id,
}
}
pub(crate) fn protocol_parameters_account(overrides: ParameterOverrides) -> AccountWithMetadata {
AccountWithMetadata {
account: Account {
program_owner: STABLECOIN_PROGRAM_ID,
balance: 0,
data: Data::from(&ProtocolParameters {
admin_account_id: admin_id(),
freeze_authority_account_id: freeze_authority_id(),
stablecoin_definition_id: stablecoin_definition_id(),
collateral_definition_id: collateral_definition_id(),
market_price_oracle_id: oracle_id(),
stability_fee_per_millisecond: overrides.stability_fee_per_millisecond,
controller_proportional_gain: overrides.controller_proportional_gain,
controller_integral_gain: overrides.controller_integral_gain,
minimum_collateralization_ratio: FIXED_POINT_ONE * 3 / 2,
minimum_milliseconds_between_rate_updates:
TEST_MINIMUM_MILLISECONDS_BETWEEN_RATE_UPDATES,
maximum_oracle_price_age_milliseconds: TEST_MAXIMUM_ORACLE_PRICE_AGE_MILLISECONDS,
is_frozen: overrides.is_frozen,
}),
nonce: Nonce(0),
},
is_authorized: false,
account_id: protocol_parameters_id(),
}
}
pub(crate) fn accumulator_account(anchor: u128, last_accrued_at: u64) -> AccountWithMetadata {
AccountWithMetadata {
account: Account {
program_owner: STABLECOIN_PROGRAM_ID,
balance: 0,
data: Data::from(&StabilityFeeAccumulator {
accumulated_rate_at_last_accrual: anchor,
last_accrued_at,
}),
nonce: Nonce(0),
},
is_authorized: false,
account_id: accumulator_id(),
}
}
pub(crate) fn redemption_price_state_account(last_updated_at: u64) -> AccountWithMetadata {
redemption_price_state_account_with_integral_term(last_updated_at, 0)
}
pub(crate) fn redemption_price_state_account_with_integral_term(
last_updated_at: u64,
controller_integral_term: i128,
) -> AccountWithMetadata {
AccountWithMetadata {
account: Account {
program_owner: STABLECOIN_PROGRAM_ID,
balance: 0,
data: Data::from(&RedemptionPriceState {
redemption_price_at_last_update: REDEMPTION_PRICE_ANCHOR,
redemption_rate_per_millisecond: FIXED_POINT_ONE,
controller_integral_term,
last_updated_at,
}),
nonce: Nonce(0),
},
is_authorized: false,
account_id: redemption_price_state_id(),
}
}
pub(crate) fn oracle_account(timestamp: u64, price: u128) -> AccountWithMetadata {
AccountWithMetadata {
account: Account {
program_owner: ORACLE_PROGRAM_ID,
balance: 0,
data: Data::from(&OraclePriceAccount {
base_asset: stablecoin_definition_id(),
quote_asset: collateral_definition_id(),
price,
timestamp,
source_id: oracle_source_id(),
confidence_interval: 0,
}),
nonce: Nonce(0),
},
is_authorized: false,
account_id: oracle_id(),
}
}
pub(crate) fn clock_account(timestamp: u64) -> AccountWithMetadata {
AccountWithMetadata {
account: Account {
program_owner: CLOCK_PROGRAM_ID,
balance: 0,
data: Data::try_from(
ClockAccountData {
block_id: 0,
timestamp,
}
.to_bytes(),
)
.expect("clock data fits"),
nonce: Nonce(0),
},
is_authorized: false,
account_id: CLOCK_01_PROGRAM_ACCOUNT_ID,
}
}