From 4e087e6ff60032f25901c8b7aa12e523471f88e2 Mon Sep 17 00:00:00 2001 From: Andrea Franz Date: Tue, 18 Aug 2026 15:33:32 +0000 Subject: [PATCH] feat(stablecoin): add RedemptionPriceState account type closes #160 --- artifacts/stablecoin-idl.json | 24 ++++ programs/stablecoin/core/src/lib.rs | 6 + .../core/src/redemption_price_state.rs | 128 ++++++++++++++++++ 3 files changed, 158 insertions(+) create mode 100644 programs/stablecoin/core/src/redemption_price_state.rs diff --git a/artifacts/stablecoin-idl.json b/artifacts/stablecoin-idl.json index a3c5ddd..21f71e1 100644 --- a/artifacts/stablecoin-idl.json +++ b/artifacts/stablecoin-idl.json @@ -171,6 +171,30 @@ ] } }, + { + "name": "RedemptionPriceState", + "type": { + "kind": "struct", + "fields": [ + { + "name": "redemption_price_at_last_update", + "type": "u128" + }, + { + "name": "redemption_rate_per_millisecond", + "type": "u128" + }, + { + "name": "controller_integral_term", + "type": "i128" + }, + { + "name": "last_updated_at", + "type": "u64" + } + ] + } + }, { "name": "StabilityFeeAccumulator", "type": { diff --git a/programs/stablecoin/core/src/lib.rs b/programs/stablecoin/core/src/lib.rs index b84a555..719f9c8 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 redemption_price_state; + pub mod stability_fee_accumulator; use borsh::{BorshDeserialize, BorshSerialize}; @@ -14,6 +16,10 @@ use nssa_core::{ pub use protocol_parameters::{ compute_protocol_parameters_pda, compute_protocol_parameters_pda_seed, ProtocolParameters, }; +pub use redemption_price_state::{ + compute_redemption_price_state_pda, compute_redemption_price_state_pda_seed, + RedemptionPriceState, +}; use serde::{Deserialize, Serialize}; use spel_framework_macros::account_type; pub use stability_fee_accumulator::{ diff --git a/programs/stablecoin/core/src/redemption_price_state.rs b/programs/stablecoin/core/src/redemption_price_state.rs new file mode 100644 index 0000000..3efd2d3 --- /dev/null +++ b/programs/stablecoin/core/src/redemption_price_state.rs @@ -0,0 +1,128 @@ +//! Global lazy state for the redemption price and the PI controller. +//! +//! Created at [`initialize_program`] time, advanced by `update_redemption_rate`. +//! Read by debt-touching instructions to compute the current redemption price +//! for collateralization checks. + +use borsh::{BorshDeserialize, BorshSerialize}; +use nssa_core::{ + account::{AccountId, Data}, + program::{PdaSeed, ProgramId}, +}; +use serde::{Deserialize, Serialize}; +use spel_framework_macros::account_type; + +const REDEMPTION_PRICE_STATE_PDA_DOMAIN: &[u8; 32] = b"STABLECOIN__REDEMPTION_PRICE____"; + +/// Redemption-price anchor + PI controller state, lazy form. +/// +/// Current redemption price at time `now` = +/// `redemption_price_at_last_update * compound_rate(redemption_rate_per_millisecond, now - +/// last_updated_at) / FIXED_POINT_ONE`. See spec §5.3 and §6.4. +#[account_type] +#[derive(Debug, PartialEq, Eq, Clone, Serialize, Deserialize, BorshSerialize, BorshDeserialize)] +pub struct RedemptionPriceState { + /// Redemption price at [`Self::last_updated_at`], collateral-per-stablecoin + /// fixed-point. + pub redemption_price_at_last_update: u128, + /// Per-millisecond drift multiplier, fixed-point. Below `FIXED_POINT_ONE` means + /// decay; above means growth. + pub redemption_rate_per_millisecond: u128, + /// Persisted PI integral state, signed. Clamped against windup on every + /// `update_redemption_rate` (anti-windup bounds live in code constants in + /// v1; see spec §8). + pub controller_integral_term: i128, + /// Unix milliseconds of the last `update_redemption_rate` call (or of + /// `initialize_program`, for the initial state). + pub last_updated_at: u64, +} + +impl TryFrom<&Data> for RedemptionPriceState { + type Error = std::io::Error; + + fn try_from(data: &Data) -> Result { + Self::try_from_slice(data.as_ref()) + } +} + +impl From<&RedemptionPriceState> for Data { + fn from(state: &RedemptionPriceState) -> Self { + let len = borsh::object_length(state).expect("RedemptionPriceState length must be known"); + let mut buf = Vec::with_capacity(len); + BorshSerialize::serialize(state, &mut buf) + .expect("RedemptionPriceState serialization should not fail"); + Self::try_from(buf).expect("RedemptionPriceState encoded data should fit into Data") + } +} + +#[must_use] +pub fn compute_redemption_price_state_pda_seed() -> PdaSeed { + use risc0_zkvm::sha::{Impl, Sha256 as _}; + + let mut out = [0u8; 32]; + out.copy_from_slice(Impl::hash_bytes(REDEMPTION_PRICE_STATE_PDA_DOMAIN).as_bytes()); + PdaSeed::new(out) +} + +#[must_use] +pub fn compute_redemption_price_state_pda(stablecoin_program_id: ProgramId) -> AccountId { + AccountId::for_public_pda( + &stablecoin_program_id, + &compute_redemption_price_state_pda_seed(), + ) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::math::FIXED_POINT_ONE; + + fn sample() -> RedemptionPriceState { + RedemptionPriceState { + redemption_price_at_last_update: FIXED_POINT_ONE / 2, // 0.5 col/sc + redemption_rate_per_millisecond: FIXED_POINT_ONE, // no drift + controller_integral_term: 0, + last_updated_at: 1_700_000_000, + } + } + + #[test] + fn borsh_roundtrip_initial_state() { + let state = sample(); + let data: Data = (&state).into(); + let decoded = RedemptionPriceState::try_from(&data).expect("decode"); + assert_eq!(decoded, state); + } + + #[test] + fn borsh_roundtrip_handles_negative_integral_term() { + let state = RedemptionPriceState { + redemption_price_at_last_update: FIXED_POINT_ONE, + redemption_rate_per_millisecond: FIXED_POINT_ONE - 10_000_000_000_000_000_000_000_000, + controller_integral_term: -987_654_321_i128, + last_updated_at: 1_700_000_001, + }; + let data: Data = (&state).into(); + let decoded = RedemptionPriceState::try_from(&data).expect("decode"); + assert_eq!(decoded.controller_integral_term, -987_654_321_i128); + assert_eq!(decoded, state); + } + + #[test] + fn pda_is_deterministic() { + let program_id: ProgramId = [9u32; 8]; + assert_eq!( + compute_redemption_price_state_pda(program_id), + compute_redemption_price_state_pda(program_id), + ); + } + + #[test] + fn pda_distinct_from_other_global_pdas() { + use crate::{compute_protocol_parameters_pda, compute_stability_fee_accumulator_pda}; + let program_id: ProgramId = [9u32; 8]; + let me = compute_redemption_price_state_pda(program_id); + assert_ne!(me, compute_protocol_parameters_pda(program_id)); + assert_ne!(me, compute_stability_fee_accumulator_pda(program_id)); + } +}