//! Core data structures and utilities for the Stablecoin Program. pub mod math; use borsh::{BorshDeserialize, BorshSerialize}; use nssa_core::{ account::{AccountId, AccountWithMetadata, Data}, program::{PdaSeed, ProgramId}, }; use serde::{Deserialize, Serialize}; use spel_framework_macros::account_type; // Stable domain-separation tags for the position PDAs; these must stay unchanged for address // compatibility. const POSITION_PDA_DOMAIN: &[u8] = b"POSITION"; const POSITION_VAULT_PDA_DOMAIN: &[u8] = b"POSITION_VAULT"; const REDEMPTION_CONTROLLER_PDA_DOMAIN: &[u8] = b"REDEMPTION_CONTROLLER"; /// Fixed-point denominator for controller gain parameters. /// /// A gain of [`CONTROLLER_GAIN_SCALE`] means `1.0`. pub const CONTROLLER_GAIN_SCALE: u128 = 1_000_000_000; /// Stablecoin Program Instruction. #[derive(Debug, Serialize, Deserialize)] pub enum Instruction { /// Open a new collateral-only [`Position`] for the calling owner. /// /// Required accounts (5): /// - Owner account (authorized) /// - Position account (uninitialized, address must match /// `compute_position_pda(self_program_id, owner, token_definition)`) /// - Position vault token holding account (uninitialized, address must match /// `compute_position_vault_pda(self_program_id, position_id)`) /// - Owner's source token holding for the collateral (authorized, initialized) /// - Token definition account for the collateral (matches the user holding's `definition_id`; /// its `program_owner` determines the Token Program used by the chained `InitializeAccount` /// / `Transfer` calls) OpenPosition { /// Amount of collateral tokens to deposit into the position vault. collateral_amount: u128, }, /// Withdraw `amount` collateral tokens from a position back to a user-controlled holding. /// /// Required accounts (4): /// - Owner account (authorized) /// - Position account (initialized, owned by `self_program_id`) /// - Position vault token holding (address must match /// `compute_position_vault_pda(self_program_id, position_id)`) /// - Destination user collateral holding (initialized, owned by the vault's Token Program, /// `TokenHolding.definition_id == Position.collateral_definition_id`) /// /// `token_program_id` is derived from `vault.account.program_owner`; /// `collateral_definition_id` is read from the decoded [`Position`]. /// /// **Note:** until issues #97/#96/#95 land, this instruction hard-asserts /// `Position.debt_amount == 0` instead of accruing fees and checking the /// collateralization ratio. WithdrawCollateral { /// Amount of collateral tokens to move from the vault back to `destination`. amount: u128, }, /// Repay `amount` of outstanding stablecoin debt against an existing position. /// /// Required accounts (4): /// - Owner account (authorized; binds caller-as-owner via position PDA re-derivation) /// - Position account (initialized, owned by `self_program_id`) /// - Stablecoin token definition account (the definition of the stablecoin being repaid) /// - User's stablecoin holding (authorized, initialized, owned by the same Token Program as /// the definition, with `TokenHolding.definition_id == stablecoin_definition.account_id`) /// /// `token_program_id` is derived from `user_stablecoin_holding.account.program_owner`. /// `collateral_definition_id` (for position PDA verification) is read from the /// decoded [`Position`]. /// /// **Note:** until issue #97 (stability fee accrual) lands, this instruction does /// not accrue fees before reducing debt. A `// TODO(#97)` comment in the host /// function marks where the accrual code will plug in. Today every position has /// `debt_amount = 0` (no `generate_debt` yet), so the precondition is vacuously met. /// /// **Note:** until issue #91 (`generate_debt`) records the stablecoin definition /// into `Position`, this instruction cannot validate that the passed /// `stablecoin_token_definition` is the one this position's debt is denominated /// in. The caller is trusted for that until then. RepayDebt { /// Amount of stablecoin debt to repay (also the amount burned from the user's holding). amount: u128, }, /// Initialize the global redemption-rate feedback controller for one stablecoin/feed pair. /// /// Required accounts (3): /// - Redemption controller account (uninitialized, address must match /// `compute_redemption_controller_pda(self_program_id, stablecoin_definition, price_feed)`) /// - Stablecoin token definition account (initialized fungible token) /// - Oracle price feed account (initialized; must decode as the configured /// stablecoin/collateral market price) /// /// `proportional_gain` and `integral_gain` use [`CONTROLLER_GAIN_SCALE`] fixed-point /// precision. For example, `CONTROLLER_GAIN_SCALE / 10` represents `0.1`. InitializeRedemptionController { /// Collateral token definition that denominates the oracle market price and redemption /// price. collateral_definition_id: AccountId, /// Initial redemption price, in the same units and precision as the oracle price. initial_redemption_price: u128, /// Proportional controller gain, scaled by [`CONTROLLER_GAIN_SCALE`]. proportional_gain: u128, /// Integral controller gain, scaled by [`CONTROLLER_GAIN_SCALE`]. integral_gain: u128, /// Maximum absolute accumulated error before the integral term is clamped. max_integral_error: u128, /// Maximum absolute redemption rate, in price units per timestamp unit. max_redemption_rate: u128, /// Maximum allowed oracle price age. Uses the same timestamp unit as the oracle feed. max_price_feed_age: u64, /// Timestamp used to initialize the controller state. current_timestamp: u64, }, /// Permissionlessly update redemption price and rate from the configured price feed. /// /// Required accounts (2): /// - Redemption controller account (initialized, owned by `self_program_id`) /// - Configured oracle price feed account /// /// If the configured feed is stale or unavailable, the controller account is emitted /// unchanged so redemption updates are paused. UpdateRedemptionController { /// Current block timestamp. The guest constrains output validity to this exact timestamp. current_timestamp: u64, }, } /// Persistent state held by a Stablecoin [`Position`] account. /// /// `debt_amount` is included for forward compatibility with `generate_debt`; until that /// instruction lands `open_position` always initializes it to `0`. #[account_type] #[derive(Debug, PartialEq, Eq, Clone, Serialize, Deserialize, BorshSerialize, BorshDeserialize)] pub struct Position { /// Token holding account (vault PDA) that custodies the collateral backing this position. pub collateral_vault_id: AccountId, /// Token definition for the collateral held in `collateral_vault_id`. pub collateral_definition_id: AccountId, /// Amount of collateral tokens deposited. pub collateral_amount: u128, /// Outstanding stablecoin debt against this position. pub debt_amount: u128, } /// Redemption feedback controller state for a stablecoin/feed pair. /// /// `redemption_rate` is signed price drift per timestamp unit. Positive rates raise the /// redemption price; negative rates lower it. `accumulated_error` stores the integral term /// before gain scaling and is clamped by `max_integral_error`. #[account_type] #[derive(Debug, PartialEq, Eq, Clone, Serialize, Deserialize, BorshSerialize, BorshDeserialize)] pub struct RedemptionController { /// Stablecoin token definition priced by the oracle feed. pub stablecoin_definition_id: AccountId, /// Collateral token definition that denominates oracle prices and redemption price. pub collateral_definition_id: AccountId, /// Configured oracle price feed account. pub price_feed_id: AccountId, /// Current redemption price in oracle price units. pub redemption_price: u128, /// Current redemption rate in price units per timestamp unit. pub redemption_rate: i128, /// Integral controller state, clamped to `max_integral_error`. pub accumulated_error: i128, /// Proportional controller gain, scaled by [`CONTROLLER_GAIN_SCALE`]. pub proportional_gain: u128, /// Integral controller gain, scaled by [`CONTROLLER_GAIN_SCALE`]. pub integral_gain: u128, /// Maximum absolute accumulated error. pub max_integral_error: u128, /// Maximum absolute redemption rate. pub max_redemption_rate: u128, /// Maximum allowed oracle price age. pub max_price_feed_age: u64, /// Last timestamp at which the controller accepted a live oracle reading. pub last_update_timestamp: u64, } impl TryFrom<&Data> for Position { type Error = std::io::Error; fn try_from(data: &Data) -> Result { Self::try_from_slice(data.as_ref()) } } impl From<&Position> for Data { fn from(position: &Position) -> Self { let mut data = Vec::with_capacity(std::mem::size_of_val(position)); BorshSerialize::serialize(position, &mut data) .expect("Serialization to Vec should not fail"); Self::try_from(data).expect("Position encoded data should fit into Data") } } impl TryFrom<&Data> for RedemptionController { type Error = std::io::Error; fn try_from(data: &Data) -> Result { Self::try_from_slice(data.as_ref()) } } impl From<&RedemptionController> for Data { fn from(controller: &RedemptionController) -> Self { let mut data = Vec::with_capacity(std::mem::size_of_val(controller)); BorshSerialize::serialize(controller, &mut data) .expect("Serialization to Vec should not fail"); Self::try_from(data).expect("Redemption controller encoded data should fit into Data") } } /// PDA seed for the [`Position`] account owned by `owner_id` for `collateral_definition_id`. /// /// Derived from the owner and collateral definition addresses with a domain-separation tag /// so one owner can hold separate positions for separate collateral definitions. pub fn compute_position_pda_seed( owner_id: AccountId, collateral_definition_id: AccountId, ) -> PdaSeed { use risc0_zkvm::sha::{Impl, Sha256 as _}; let mut bytes = Vec::new(); bytes.extend_from_slice(&owner_id.to_bytes()); bytes.extend_from_slice(&collateral_definition_id.to_bytes()); bytes.extend_from_slice(POSITION_PDA_DOMAIN); let mut out = [0u8; 32]; out.copy_from_slice(Impl::hash_bytes(&bytes).as_bytes()); PdaSeed::new(out) } /// Account id of the [`Position`] PDA owned by `owner_id` under `stablecoin_program_id`. pub fn compute_position_pda( stablecoin_program_id: ProgramId, owner_id: AccountId, collateral_definition_id: AccountId, ) -> AccountId { AccountId::for_public_pda( &stablecoin_program_id, &compute_position_pda_seed(owner_id, collateral_definition_id), ) } /// PDA seed for the collateral vault token holding bound to a [`Position`]. /// /// Derived from the position's address with a distinct domain-separation tag so the vault /// id cannot collide with the position id even though both PDAs share the same program. pub fn compute_position_vault_pda_seed(position_id: AccountId) -> PdaSeed { use risc0_zkvm::sha::{Impl, Sha256 as _}; let mut bytes = Vec::new(); bytes.extend_from_slice(&position_id.to_bytes()); bytes.extend_from_slice(POSITION_VAULT_PDA_DOMAIN); let mut out = [0u8; 32]; out.copy_from_slice(Impl::hash_bytes(&bytes).as_bytes()); PdaSeed::new(out) } /// Account id of the collateral vault PDA for `position_id` under `stablecoin_program_id`. pub fn compute_position_vault_pda( stablecoin_program_id: ProgramId, position_id: AccountId, ) -> AccountId { AccountId::for_public_pda( &stablecoin_program_id, &compute_position_vault_pda_seed(position_id), ) } /// PDA seed for the [`RedemptionController`] bound to a stablecoin and price feed. pub fn compute_redemption_controller_pda_seed( stablecoin_definition_id: AccountId, price_feed_id: AccountId, ) -> PdaSeed { use risc0_zkvm::sha::{Impl, Sha256 as _}; let mut bytes = Vec::new(); bytes.extend_from_slice(&stablecoin_definition_id.to_bytes()); bytes.extend_from_slice(&price_feed_id.to_bytes()); bytes.extend_from_slice(REDEMPTION_CONTROLLER_PDA_DOMAIN); let mut out = [0u8; 32]; out.copy_from_slice(Impl::hash_bytes(&bytes).as_bytes()); PdaSeed::new(out) } /// Account id of the [`RedemptionController`] PDA for a stablecoin/feed pair. pub fn compute_redemption_controller_pda( stablecoin_program_id: ProgramId, stablecoin_definition_id: AccountId, price_feed_id: AccountId, ) -> AccountId { AccountId::for_public_pda( &stablecoin_program_id, &compute_redemption_controller_pda_seed(stablecoin_definition_id, price_feed_id), ) } /// Verify a redemption controller account address and return its PDA seed. /// /// # Panics /// If `controller.account_id` does not match the configured PDA derivation. pub fn verify_redemption_controller_and_get_seed( controller: &AccountWithMetadata, stablecoin_definition_id: AccountId, price_feed_id: AccountId, stablecoin_program_id: ProgramId, ) -> PdaSeed { let seed = compute_redemption_controller_pda_seed(stablecoin_definition_id, price_feed_id); let expected_id = AccountId::for_public_pda(&stablecoin_program_id, &seed); assert_eq!( controller.account_id, expected_id, "Redemption controller account ID does not match expected derivation" ); seed } /// Verify the position account's address matches /// `(stablecoin_program_id, owner, collateral_definition_id)` and return the [`PdaSeed`] for /// use in post-state claims. /// /// # Panics /// If `position.account_id` does not match the address derived from `owner`, /// `collateral_definition_id`, and `stablecoin_program_id`. pub fn verify_position_and_get_seed( position: &AccountWithMetadata, owner: &AccountWithMetadata, collateral_definition_id: AccountId, stablecoin_program_id: ProgramId, ) -> PdaSeed { let seed = compute_position_pda_seed(owner.account_id, collateral_definition_id); let expected_id = AccountId::for_public_pda(&stablecoin_program_id, &seed); assert_eq!( position.account_id, expected_id, "Position account ID does not match expected derivation" ); seed } /// Verify the vault account's address matches `(stablecoin_program_id, position)` and /// return the [`PdaSeed`] for use in chained calls. /// /// # Panics /// If `vault.account_id` does not match the address derived from `position_id` and /// `stablecoin_program_id`. pub fn verify_position_vault_and_get_seed( vault: &AccountWithMetadata, position_id: AccountId, stablecoin_program_id: ProgramId, ) -> PdaSeed { let seed = compute_position_vault_pda_seed(position_id); let expected_id = AccountId::for_public_pda(&stablecoin_program_id, &seed); assert_eq!( vault.account_id, expected_id, "Position vault account ID does not match expected derivation" ); seed }