feat(stablecoin): add StabilityFeeAccumulator account type

closes #159
This commit is contained in:
Andrea Franz
2026-08-19 11:49:57 +02:00
parent 09f3d594a7
commit 0a44380a70
3 changed files with 141 additions and 0 deletions
+16
View File
@@ -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": {
+6
View File
@@ -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.
@@ -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, Self::Error> {
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),
);
}
}