diff --git a/artifacts/stablecoin-idl.json b/artifacts/stablecoin-idl.json index 1c3d9e8..2703359 100644 --- a/artifacts/stablecoin-idl.json +++ b/artifacts/stablecoin-idl.json @@ -115,6 +115,62 @@ } ], "accounts": [ + { + "name": "ProtocolParameters", + "type": { + "kind": "struct", + "fields": [ + { + "name": "admin_account_id", + "type": "account_id" + }, + { + "name": "freeze_authority_account_id", + "type": "account_id" + }, + { + "name": "stablecoin_definition_id", + "type": "account_id" + }, + { + "name": "collateral_definition_id", + "type": "account_id" + }, + { + "name": "market_price_oracle_id", + "type": "account_id" + }, + { + "name": "stability_fee_per_millisecond", + "type": "u128" + }, + { + "name": "controller_proportional_gain", + "type": "i128" + }, + { + "name": "controller_integral_gain", + "type": "i128" + }, + { + "name": "minimum_collateralization_ratio", + "type": "u128" + }, + { + "name": "minimum_milliseconds_between_rate_updates", + "type": "u64" + }, + { + "name": "maximum_oracle_price_age_milliseconds", + "type": "u64" + }, + { + "name": "is_frozen", + "type": "bool" + } + ] + } + }, { "name": "Position", "type": { diff --git a/programs/stablecoin/core/src/lib.rs b/programs/stablecoin/core/src/lib.rs index d030564..7c06b57 100644 --- a/programs/stablecoin/core/src/lib.rs +++ b/programs/stablecoin/core/src/lib.rs @@ -2,11 +2,16 @@ pub mod math; +pub mod protocol_parameters; + use borsh::{BorshDeserialize, BorshSerialize}; use nssa_core::{ account::{AccountId, AccountWithMetadata, Data}, program::{PdaSeed, ProgramId}, }; +pub use protocol_parameters::{ + compute_protocol_parameters_pda, compute_protocol_parameters_pda_seed, ProtocolParameters, +}; use serde::{Deserialize, Serialize}; use spel_framework_macros::account_type; diff --git a/programs/stablecoin/core/src/protocol_parameters.rs b/programs/stablecoin/core/src/protocol_parameters.rs new file mode 100644 index 0000000..3b6d8ed --- /dev/null +++ b/programs/stablecoin/core/src/protocol_parameters.rs @@ -0,0 +1,162 @@ +//! Single-instance configuration account for the Stablecoin program. +//! +//! Created at [`initialize_program`] time, written by admin `set_*` +//! instructions and by `freeze` / `unfreeze`. Read by nearly every other +//! instruction. + +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 for the single-instance [`ProtocolParameters`] account. +const PROTOCOL_PARAMETERS_PDA_DOMAIN: &[u8; 32] = b"STABLECOIN__PROTOCOL_PARAMS_____"; + +/// Single source of truth for the protocol's configurable knobs. +/// +/// See the spec, §4.1 for field semantics. Immutability comments mirror the +/// rationale documented there. +#[account_type] +#[derive(Debug, PartialEq, Eq, Clone, Serialize, Deserialize, BorshSerialize, BorshDeserialize)] +pub struct ProtocolParameters { + /// Authority required for every parameter-update and admin-rotation + /// instruction. + pub admin_account_id: AccountId, + /// Authority required for `freeze` / `unfreeze`. + pub freeze_authority_account_id: AccountId, + /// The stablecoin's `TokenDefinition` PDA. IMMUTABLE — changing it would + /// break supply accounting against the existing on-chain stablecoin + /// float. + pub stablecoin_definition_id: AccountId, + /// The single accepted collateral's `TokenDefinition`. IMMUTABLE — + /// changing it would orphan every position vault. + pub collateral_definition_id: AccountId, + /// Producer of the market price observation used by + /// `update_redemption_rate` and the oracle staleness gate in + /// `generate_debt`. + pub market_price_oracle_id: AccountId, + /// Per-millisecond stability fee multiplier in fixed-point, stored as + /// `(1 + r_per_millisecond) * FIXED_POINT_ONE`. + pub stability_fee_per_millisecond: u128, + /// PI controller `Kp`. Signed. + pub controller_proportional_gain: i128, + /// PI controller `Ki`. Signed. + pub controller_integral_gain: i128, + /// Minimum collateralization ratio in fixed-point (e.g. + /// `1.5 * FIXED_POINT_ONE` = 150%). + pub minimum_collateralization_ratio: u128, + /// Minimum milliseconds between successful `update_redemption_rate` calls. + pub minimum_milliseconds_between_rate_updates: u64, + /// Reject oracle observations older than this (RFP R3 staleness gate). + pub maximum_oracle_price_age_milliseconds: u64, + /// `true` blocks `open_position`, `generate_debt`, `withdraw_collateral`. + pub is_frozen: bool, +} + +impl TryFrom<&Data> for ProtocolParameters { + type Error = std::io::Error; + + fn try_from(data: &Data) -> Result { + Self::try_from_slice(data.as_ref()) + } +} + +impl From<&ProtocolParameters> for Data { + fn from(params: &ProtocolParameters) -> Self { + let len = borsh::object_length(params).expect("ProtocolParameters length must be known"); + let mut buf = Vec::with_capacity(len); + BorshSerialize::serialize(params, &mut buf) + .expect("ProtocolParameters serialization should not fail"); + Self::try_from(buf).expect("ProtocolParameters encoded data should fit into Data") + } +} + +/// PDA seed for [`ProtocolParameters`] (single-instance — no per-deployment +/// uniqueness needed; the program id already discriminates). +#[must_use] +pub fn compute_protocol_parameters_pda_seed() -> PdaSeed { + use risc0_zkvm::sha::{Impl, Sha256 as _}; + + let mut out = [0u8; 32]; + out.copy_from_slice(Impl::hash_bytes(PROTOCOL_PARAMETERS_PDA_DOMAIN).as_bytes()); + PdaSeed::new(out) +} + +/// Account id of the [`ProtocolParameters`] PDA under the given stablecoin +/// program. +#[must_use] +pub fn compute_protocol_parameters_pda(stablecoin_program_id: ProgramId) -> AccountId { + AccountId::for_public_pda( + &stablecoin_program_id, + &compute_protocol_parameters_pda_seed(), + ) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::math::FIXED_POINT_ONE; + + fn sample_params() -> ProtocolParameters { + ProtocolParameters { + admin_account_id: AccountId::new([0xAA; 32]), + freeze_authority_account_id: AccountId::new([0xFF; 32]), + stablecoin_definition_id: AccountId::new([0x01; 32]), + collateral_definition_id: AccountId::new([0x02; 32]), + market_price_oracle_id: AccountId::new([0x03; 32]), + stability_fee_per_millisecond: FIXED_POINT_ONE + 1_500_000_000_000_000, /* ε ≈ 1.5×10^15 → ~5% annual */ + controller_proportional_gain: -123_456_789, + controller_integral_gain: 987_654_321, + 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, + } + } + + #[test] + fn borsh_roundtrip_preserves_every_field() { + let params = sample_params(); + let data: Data = (¶ms).into(); + let decoded = ProtocolParameters::try_from(&data).expect("decode"); + assert_eq!(decoded, params); + } + + #[test] + fn borsh_roundtrip_with_is_frozen_true() { + let mut params = sample_params(); + params.is_frozen = true; + let data: Data = (¶ms).into(); + let decoded = ProtocolParameters::try_from(&data).expect("decode"); + assert!(decoded.is_frozen); + assert_eq!(decoded, params); + } + + #[test] + fn pda_is_deterministic_for_fixed_program_id() { + let program_id: ProgramId = [42u32; 8]; + let first = compute_protocol_parameters_pda(program_id); + let second = compute_protocol_parameters_pda(program_id); + assert_eq!(first, second); + } + + #[test] + fn pda_differs_for_different_program_ids() { + let id_a: ProgramId = [1u32; 8]; + let id_b: ProgramId = [2u32; 8]; + assert_ne!( + compute_protocol_parameters_pda(id_a), + compute_protocol_parameters_pda(id_b), + ); + } + + #[test] + fn seed_is_deterministic() { + let seed_a = compute_protocol_parameters_pda_seed(); + let seed_b = compute_protocol_parameters_pda_seed(); + assert_eq!(seed_a, seed_b); + } +}