From 0a44380a701120f8849d588384c61a060b7c87da Mon Sep 17 00:00:00 2001 From: Andrea Franz Date: Tue, 18 Aug 2026 15:33:32 +0000 Subject: [PATCH] feat(stablecoin): add StabilityFeeAccumulator account type closes #159 --- artifacts/stablecoin-idl.json | 16 +++ programs/stablecoin/core/src/lib.rs | 6 + .../core/src/stability_fee_accumulator.rs | 119 ++++++++++++++++++ 3 files changed, 141 insertions(+) create mode 100644 programs/stablecoin/core/src/stability_fee_accumulator.rs diff --git a/artifacts/stablecoin-idl.json b/artifacts/stablecoin-idl.json index b5341ea..a3c5ddd 100644 --- a/artifacts/stablecoin-idl.json +++ b/artifacts/stablecoin-idl.json @@ -171,6 +171,22 @@ ] } }, + { + "name": "StabilityFeeAccumulator", + "type": { + "kind": "struct", + "fields": [ + { + "name": "accumulated_rate_at_last_accrual", + "type": "u128" + }, + { + "name": "last_accrued_at", + "type": "u64" + } + ] + } + }, { "name": "Position", "type": { diff --git a/programs/stablecoin/core/src/lib.rs b/programs/stablecoin/core/src/lib.rs index 7c06b57..b84a555 100644 --- a/programs/stablecoin/core/src/lib.rs +++ b/programs/stablecoin/core/src/lib.rs @@ -4,6 +4,8 @@ pub mod math; pub mod protocol_parameters; +pub mod stability_fee_accumulator; + use borsh::{BorshDeserialize, BorshSerialize}; use nssa_core::{ account::{AccountId, AccountWithMetadata, Data}, @@ -14,6 +16,10 @@ pub use protocol_parameters::{ }; use serde::{Deserialize, Serialize}; use spel_framework_macros::account_type; +pub use stability_fee_accumulator::{ + compute_stability_fee_accumulator_pda, compute_stability_fee_accumulator_pda_seed, + StabilityFeeAccumulator, +}; // Stable domain-separation tags for the position PDAs; these must stay unchanged for address // compatibility. diff --git a/programs/stablecoin/core/src/stability_fee_accumulator.rs b/programs/stablecoin/core/src/stability_fee_accumulator.rs new file mode 100644 index 0000000..5fec844 --- /dev/null +++ b/programs/stablecoin/core/src/stability_fee_accumulator.rs @@ -0,0 +1,119 @@ +//! Global lazy accumulator for stability fees. +//! +//! Created at [`initialize_program`] time, advanced by `accrue_stability_fee` +//! and by the auto-accrue inline in `set_stability_fee_per_millisecond`. Read by +//! every debt-touching instruction to compute current nominal debt from +//! normalized debt. + +use borsh::{BorshDeserialize, BorshSerialize}; +use nssa_core::{ + account::{AccountId, Data}, + program::{PdaSeed, ProgramId}, +}; +use serde::{Deserialize, Serialize}; +use spel_framework_macros::account_type; + +/// PDA seed domain for the [`StabilityFeeAccumulator`]. +const STABILITY_FEE_ACCUMULATOR_PDA_DOMAIN: &[u8; 32] = b"STABLECOIN__STABILITY_FEE_ACCUM_"; + +/// Compounded stability-fee multiplier, lazy form. +/// +/// The current accumulator at time `now` is +/// `accumulated_rate_at_last_accrual * compound_rate(stability_fee_per_millisecond, now - +/// last_accrued_at) / FIXED_POINT_ONE`. See spec ยง5.3 for the read-side projection. +#[account_type] +#[derive(Debug, PartialEq, Eq, Clone, Serialize, Deserialize, BorshSerialize, BorshDeserialize)] +pub struct StabilityFeeAccumulator { + /// Accumulator value at [`Self::last_accrued_at`], fixed-point. Initialized + /// to `FIXED_POINT_ONE` and monotonically non-decreasing. + pub accumulated_rate_at_last_accrual: u128, + /// Unix milliseconds of the last accrual. + pub last_accrued_at: u64, +} + +impl TryFrom<&Data> for StabilityFeeAccumulator { + type Error = std::io::Error; + + fn try_from(data: &Data) -> Result { + Self::try_from_slice(data.as_ref()) + } +} + +impl From<&StabilityFeeAccumulator> for Data { + fn from(state: &StabilityFeeAccumulator) -> Self { + let len = + borsh::object_length(state).expect("StabilityFeeAccumulator length must be known"); + let mut buf = Vec::with_capacity(len); + BorshSerialize::serialize(state, &mut buf) + .expect("StabilityFeeAccumulator serialization should not fail"); + Self::try_from(buf).expect("StabilityFeeAccumulator encoded data should fit into Data") + } +} + +#[must_use] +pub fn compute_stability_fee_accumulator_pda_seed() -> PdaSeed { + use risc0_zkvm::sha::{Impl, Sha256 as _}; + + let mut out = [0u8; 32]; + out.copy_from_slice(Impl::hash_bytes(STABILITY_FEE_ACCUMULATOR_PDA_DOMAIN).as_bytes()); + PdaSeed::new(out) +} + +#[must_use] +pub fn compute_stability_fee_accumulator_pda(stablecoin_program_id: ProgramId) -> AccountId { + AccountId::for_public_pda( + &stablecoin_program_id, + &compute_stability_fee_accumulator_pda_seed(), + ) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::math::FIXED_POINT_ONE; + + fn sample() -> StabilityFeeAccumulator { + StabilityFeeAccumulator { + accumulated_rate_at_last_accrual: FIXED_POINT_ONE, + last_accrued_at: 1_700_000_000, + } + } + + #[test] + fn borsh_roundtrip_preserves_both_fields() { + let state = sample(); + let data: Data = (&state).into(); + let decoded = StabilityFeeAccumulator::try_from(&data).expect("decode"); + assert_eq!(decoded, state); + } + + #[test] + fn borsh_roundtrip_handles_grown_accumulator() { + let state = StabilityFeeAccumulator { + accumulated_rate_at_last_accrual: FIXED_POINT_ONE * 12345 / 10000, // 1.2345 + last_accrued_at: 2_000_000_000, + }; + let data: Data = (&state).into(); + let decoded = StabilityFeeAccumulator::try_from(&data).expect("decode"); + assert_eq!(decoded, state); + } + + #[test] + fn pda_is_deterministic() { + let program_id: ProgramId = [7u32; 8]; + assert_eq!( + compute_stability_fee_accumulator_pda(program_id), + compute_stability_fee_accumulator_pda(program_id), + ); + } + + #[test] + fn pda_differs_from_protocol_parameters_pda() { + use crate::compute_protocol_parameters_pda; + let program_id: ProgramId = [7u32; 8]; + assert_ne!( + compute_stability_fee_accumulator_pda(program_id), + compute_protocol_parameters_pda(program_id), + ); + } +}