diff --git a/Cargo.lock b/Cargo.lock index 9372e45..24a2a29 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1610,6 +1610,7 @@ dependencies = [ "stablecoin_core", "token-methods", "token_core", + "twap_oracle_core", ] [[package]] @@ -3057,6 +3058,7 @@ dependencies = [ "nssa_core", "stablecoin_core", "token_core", + "twap_oracle_core", ] [[package]] diff --git a/artifacts/stablecoin-idl.json b/artifacts/stablecoin-idl.json index 5c601e8..887ed1c 100644 --- a/artifacts/stablecoin-idl.json +++ b/artifacts/stablecoin-idl.json @@ -112,6 +112,86 @@ "type": "u128" } ] + }, + { + "name": "initialize_redemption_controller", + "accounts": [ + { + "name": "controller", + "writable": false, + "signer": false, + "init": false + }, + { + "name": "stablecoin_definition", + "writable": false, + "signer": false, + "init": false + }, + { + "name": "price_feed", + "writable": false, + "signer": false, + "init": false + } + ], + "args": [ + { + "name": "reference_asset_id", + "type": "account_id" + }, + { + "name": "initial_redemption_price", + "type": "u128" + }, + { + "name": "proportional_gain", + "type": "u128" + }, + { + "name": "integral_gain", + "type": "u128" + }, + { + "name": "max_integral_error", + "type": "u128" + }, + { + "name": "max_redemption_rate", + "type": "u128" + }, + { + "name": "max_price_feed_age", + "type": "u64" + }, + { + "name": "current_timestamp", + "type": "u64" + } + ] + }, + { + "name": "update_redemption_controller", + "accounts": [ + { + "name": "controller", + "writable": false, + "signer": false, + "init": false + }, + { + "name": "price_feed", + "writable": false, + "signer": false, + "init": false + } + ], + "args": [ + { + "name": "current_timestamp", + "type": "u64" + } + ] } ], "accounts": [ @@ -139,6 +219,66 @@ ] } }, + { + "name": "RedemptionController", + "type": { + "kind": "struct", + "fields": [ + { + "name": "stablecoin_definition_id", + "type": "account_id" + }, + { + "name": "reference_asset_id", + "type": "account_id" + }, + { + "name": "price_feed_id", + "type": "account_id" + }, + { + "name": "oracle_program_id", + "type": "program_id" + }, + { + "name": "redemption_price", + "type": "u128" + }, + { + "name": "redemption_rate", + "type": "i128" + }, + { + "name": "accumulated_error", + "type": "i128" + }, + { + "name": "proportional_gain", + "type": "u128" + }, + { + "name": "integral_gain", + "type": "u128" + }, + { + "name": "max_integral_error", + "type": "u128" + }, + { + "name": "max_redemption_rate", + "type": "u128" + }, + { + "name": "max_price_feed_age", + "type": "u64" + }, + { + "name": "last_update_timestamp", + "type": "u64" + } + ] + } + }, { "name": "TokenDefinition", "type": { diff --git a/programs/integration_tests/Cargo.toml b/programs/integration_tests/Cargo.toml index f2374fd..c3386e8 100644 --- a/programs/integration_tests/Cargo.toml +++ b/programs/integration_tests/Cargo.toml @@ -13,6 +13,7 @@ amm_core = { workspace = true } token_core = { workspace = true } ata_core = { workspace = true } stablecoin_core = { workspace = true } +twap_oracle_core = { workspace = true } token-methods = { path = "../token/methods" } amm-methods = { path = "../amm/methods" } ata-methods = { path = "../ata/methods" } diff --git a/programs/integration_tests/tests/stablecoin.rs b/programs/integration_tests/tests/stablecoin.rs index 4044d84..abe8265 100644 --- a/programs/integration_tests/tests/stablecoin.rs +++ b/programs/integration_tests/tests/stablecoin.rs @@ -3,8 +3,12 @@ use nssa::{ public_transaction, PrivateKey, PublicKey, PublicTransaction, V03State, }; use nssa_core::account::{Account, AccountId, Data, Nonce}; -use stablecoin_core::{compute_position_pda, compute_position_vault_pda, Position}; +use stablecoin_core::{ + compute_position_pda, compute_position_vault_pda, compute_redemption_controller_pda, Position, + RedemptionController, CONTROLLER_GAIN_SCALE, +}; use token_core::{TokenDefinition, TokenHolding}; +use twap_oracle_core::OraclePriceAccount; struct Keys; struct Ids; @@ -50,6 +54,26 @@ impl Ids { AccountId::new([6; 32]) } + fn reference_asset() -> AccountId { + AccountId::new([7; 32]) + } + + fn price_feed() -> AccountId { + AccountId::new([8; 32]) + } + + fn redemption_controller() -> AccountId { + compute_redemption_controller_pda( + Self::stablecoin_program(), + Self::stablecoin_definition(), + Self::price_feed(), + ) + } + + fn oracle_program() -> nssa_core::program::ProgramId { + [9u32; 8] + } + fn user_stablecoin_holding() -> AccountId { AccountId::from(&PublicKey::new_from_private_key( &Keys::user_stablecoin_holding(), @@ -86,6 +110,14 @@ impl Balances { 1_000 } + fn redemption_price() -> u128 { + 1_000 + } + + fn market_price_below_redemption() -> u128 { + 900 + } + fn user_stablecoin_holding_init() -> u128 { 1_000 } @@ -150,6 +182,22 @@ impl Accounts { } } + fn price_feed_init(price: u128, timestamp: u64) -> Account { + Account { + program_owner: Ids::oracle_program(), + balance: 0_u128, + data: Data::from(&OraclePriceAccount { + base_asset: Ids::stablecoin_definition(), + quote_asset: Ids::reference_asset(), + price, + timestamp, + source_id: String::from("twap"), + confidence_interval: 0, + }), + nonce: Nonce(0), + } + } + fn position_with_debt_init() -> Account { Account { program_owner: stablecoin_methods::STABLECOIN_ID, @@ -217,6 +265,20 @@ fn state_for_stablecoin_repay_tests() -> V03State { state } +fn state_for_stablecoin_redemption_controller_tests(price: u128, timestamp: u64) -> V03State { + let mut state = V03State::new_with_genesis_accounts(&[], vec![], 0); + deploy_programs(&mut state); + state.force_insert_account( + Ids::stablecoin_definition(), + Accounts::stablecoin_definition_init(), + ); + state.force_insert_account( + Ids::price_feed(), + Accounts::price_feed_init(price, timestamp), + ); + state +} + fn assert_position(state: &V03State, expected_collateral: u128) { let position = Position::try_from(&state.get_account_by_id(Ids::position()).data).expect("valid Position"); @@ -396,3 +458,68 @@ fn stablecoin_repay_debt_burns_stablecoins_and_decreases_debt() { } } } + +#[test] +fn stablecoin_redemption_controller_initializes_and_updates_from_price_feed() { + let current_timestamp = 100_u64; + let mut state = state_for_stablecoin_redemption_controller_tests( + Balances::market_price_below_redemption(), + current_timestamp, + ); + + let initialize = stablecoin_core::Instruction::InitializeRedemptionController { + reference_asset_id: Ids::reference_asset(), + initial_redemption_price: Balances::redemption_price(), + proportional_gain: CONTROLLER_GAIN_SCALE, + integral_gain: 0, + max_integral_error: 1_000, + max_redemption_rate: 500, + max_price_feed_age: 10, + current_timestamp, + }; + let message = public_transaction::Message::try_new( + Ids::stablecoin_program(), + vec![ + Ids::redemption_controller(), + Ids::stablecoin_definition(), + Ids::price_feed(), + ], + vec![], + initialize, + ) + .unwrap(); + let witness_set = public_transaction::WitnessSet::for_message(&message, &[]); + let tx = PublicTransaction::new(message, witness_set); + state + .transition_from_public_transaction(&tx, 0, current_timestamp) + .expect("initialize_redemption_controller must succeed"); + + let controller = + RedemptionController::try_from(&state.get_account_by_id(Ids::redemption_controller()).data) + .expect("valid RedemptionController"); + assert_eq!(controller.redemption_price, Balances::redemption_price()); + assert_eq!(controller.redemption_rate, 0); + assert_eq!(controller.oracle_program_id, Ids::oracle_program()); + + let update = stablecoin_core::Instruction::UpdateRedemptionController { current_timestamp }; + let message = public_transaction::Message::try_new( + Ids::stablecoin_program(), + vec![Ids::redemption_controller(), Ids::price_feed()], + vec![], + update, + ) + .unwrap(); + let witness_set = public_transaction::WitnessSet::for_message(&message, &[]); + let tx = PublicTransaction::new(message, witness_set); + state + .transition_from_public_transaction(&tx, 0, current_timestamp) + .expect("update_redemption_controller must succeed"); + + let controller = + RedemptionController::try_from(&state.get_account_by_id(Ids::redemption_controller()).data) + .expect("valid RedemptionController"); + assert_eq!(controller.redemption_price, Balances::redemption_price()); + assert_eq!(controller.redemption_rate, 100); + assert_eq!(controller.accumulated_error, 0); + assert_eq!(controller.last_update_timestamp, current_timestamp); +} diff --git a/programs/stablecoin/Cargo.toml b/programs/stablecoin/Cargo.toml index 83a0155..c6df6c7 100644 --- a/programs/stablecoin/Cargo.toml +++ b/programs/stablecoin/Cargo.toml @@ -7,3 +7,4 @@ edition = "2021" nssa_core = { git = "https://github.com/logos-blockchain/logos-execution-zone.git", tag = "v0.2.0-rc3", features = ["host"] } stablecoin_core = { path = "core" } token_core = { path = "../token/core" } +twap_oracle_core = { path = "../twap_oracle/core" } diff --git a/programs/stablecoin/core/src/lib.rs b/programs/stablecoin/core/src/lib.rs index b5f51ac..acf44c9 100644 --- a/programs/stablecoin/core/src/lib.rs +++ b/programs/stablecoin/core/src/lib.rs @@ -10,6 +10,12 @@ use spel_framework_macros::account_type; const POSITION_PDA_DOMAIN: [u8; 32] = [0; 32]; const POSITION_VAULT_PDA_DOMAIN: [u8; 32] = [1; 32]; +const REDEMPTION_CONTROLLER_PDA_DOMAIN: [u8; 32] = [2; 32]; + +/// 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)] @@ -76,6 +82,47 @@ pub enum Instruction { /// 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; its `program_owner` becomes the configured oracle + /// program) + /// + /// `proportional_gain` and `integral_gain` use [`CONTROLLER_GAIN_SCALE`] fixed-point + /// precision. For example, `CONTROLLER_GAIN_SCALE / 10` represents `0.1`. + InitializeRedemptionController { + /// Asset that denominates both the oracle market price and redemption price. + reference_asset_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. @@ -95,6 +142,42 @@ pub struct Position { pub debt_amount: u128, } +/// Global 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, + /// Asset that denominates both oracle prices and redemption price. + pub reference_asset_id: AccountId, + /// Configured oracle price feed account. + pub price_feed_id: AccountId, + /// Program expected to own `price_feed_id`. + pub oracle_program_id: ProgramId, + /// 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; @@ -112,6 +195,23 @@ impl From<&Position> for 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 @@ -171,6 +271,54 @@ pub fn compute_position_vault_pda( ) } +/// 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 = [0u8; 96]; + bytes[0..32].copy_from_slice(&stablecoin_definition_id.to_bytes()); + bytes[32..64].copy_from_slice(&price_feed_id.to_bytes()); + bytes[64..96].copy_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. diff --git a/programs/stablecoin/methods/guest/Cargo.lock b/programs/stablecoin/methods/guest/Cargo.lock index a382086..39e237e 100644 --- a/programs/stablecoin/methods/guest/Cargo.lock +++ b/programs/stablecoin/methods/guest/Cargo.lock @@ -2960,6 +2960,7 @@ dependencies = [ "nssa_core", "stablecoin_core", "token_core", + "twap_oracle_core", ] [[package]] diff --git a/programs/stablecoin/methods/guest/src/bin/stablecoin.rs b/programs/stablecoin/methods/guest/src/bin/stablecoin.rs index f677ca3..2dcde1d 100644 --- a/programs/stablecoin/methods/guest/src/bin/stablecoin.rs +++ b/programs/stablecoin/methods/guest/src/bin/stablecoin.rs @@ -1,8 +1,7 @@ #![cfg_attr(not(test), no_main)] -use nssa_core::account::AccountWithMetadata; -use spel_framework::context::ProgramContext; -use spel_framework::prelude::*; +use nssa_core::account::{AccountId, AccountWithMetadata}; +use spel_framework::{context::ProgramContext, prelude::*}; #[cfg(not(test))] risc0_zkvm::guest::entry!(main); @@ -102,4 +101,80 @@ mod stablecoin { chained_calls, )) } + + /// Initialize redemption-rate feedback controller state for one stablecoin/feed pair. + /// + /// # Errors + /// Returns the host program's panic-converted error if any precondition + /// fails (see + /// [`stablecoin_program::redemption_controller::initialize_redemption_controller`] + /// for the full list). + #[expect( + clippy::too_many_arguments, + reason = "instruction interface exposes controller configuration explicitly" + )] + #[instruction] + pub fn initialize_redemption_controller( + ctx: ProgramContext, + controller: AccountWithMetadata, + stablecoin_definition: AccountWithMetadata, + price_feed: AccountWithMetadata, + reference_asset_id: AccountId, + initial_redemption_price: u128, + proportional_gain: u128, + integral_gain: u128, + max_integral_error: u128, + max_redemption_rate: u128, + max_price_feed_age: u64, + current_timestamp: u64, + ) -> SpelResult { + let post_states = + stablecoin_program::redemption_controller::initialize_redemption_controller( + controller, + stablecoin_definition, + price_feed, + ctx.self_program_id, + reference_asset_id, + initial_redemption_price, + proportional_gain, + integral_gain, + max_integral_error, + max_redemption_rate, + max_price_feed_age, + current_timestamp, + ); + let validity_end = current_timestamp + .checked_add(1) + .expect("current_timestamp must allow an exact validity window"); + Ok(spel_framework::SpelOutput::execute(post_states, vec![]) + .try_with_timestamp_validity_window(current_timestamp..validity_end) + .expect("exact timestamp validity window must be non-empty")) + } + + /// Update redemption price and redemption rate from the configured price feed. + /// + /// # Errors + /// Returns the host program's panic-converted error if controller state + /// validation fails. Stale or unavailable price feeds pause updates by + /// emitting the controller state unchanged. + #[instruction] + pub fn update_redemption_controller( + ctx: ProgramContext, + controller: AccountWithMetadata, + price_feed: AccountWithMetadata, + current_timestamp: u64, + ) -> SpelResult { + let post_states = stablecoin_program::redemption_controller::update_redemption_controller( + controller, + price_feed, + ctx.self_program_id, + current_timestamp, + ); + let validity_end = current_timestamp + .checked_add(1) + .expect("current_timestamp must allow an exact validity window"); + Ok(spel_framework::SpelOutput::execute(post_states, vec![]) + .try_with_timestamp_validity_window(current_timestamp..validity_end) + .expect("exact timestamp validity window must be non-empty")) + } } diff --git a/programs/stablecoin/src/lib.rs b/programs/stablecoin/src/lib.rs index 690024e..74cbb22 100644 --- a/programs/stablecoin/src/lib.rs +++ b/programs/stablecoin/src/lib.rs @@ -5,6 +5,9 @@ pub use stablecoin_core as core; /// Open a new collateral-only position for a calling owner. pub mod open_position; +/// Initialize and update redemption-rate feedback controller state. +pub mod redemption_controller; + /// Repay outstanding stablecoin debt against an existing position. pub mod repay_debt; diff --git a/programs/stablecoin/src/redemption_controller.rs b/programs/stablecoin/src/redemption_controller.rs new file mode 100644 index 0000000..30cbb02 --- /dev/null +++ b/programs/stablecoin/src/redemption_controller.rs @@ -0,0 +1,531 @@ +use nssa_core::{ + account::{Account, AccountId, AccountWithMetadata, Data}, + program::{AccountPostState, ProgramId}, +}; +use stablecoin_core::{verify_redemption_controller_and_get_seed, RedemptionController}; +use token_core::TokenDefinition; +use twap_oracle_core::OraclePriceAccount; + +const CONTROLLER_GAIN_SCALE_I128: i128 = 1_000_000_000; + +/// Initialize the redemption-rate feedback controller for one stablecoin/feed pair. +/// +/// # Panics +/// - `controller` is already initialized. +/// - `controller.account_id` does not match the stablecoin/feed PDA. +/// - `stablecoin_definition` is uninitialized or not a fungible token definition. +/// - `price_feed` is uninitialized. +/// - Initial price, gains, or clamp limits do not fit controller math bounds. +#[expect( + clippy::too_many_arguments, + reason = "public instruction configuration maps directly to controller parameters" +)] +pub fn initialize_redemption_controller( + controller: AccountWithMetadata, + stablecoin_definition: AccountWithMetadata, + price_feed: AccountWithMetadata, + stablecoin_program_id: ProgramId, + reference_asset_id: AccountId, + initial_redemption_price: u128, + proportional_gain: u128, + integral_gain: u128, + max_integral_error: u128, + max_redemption_rate: u128, + max_price_feed_age: u64, + current_timestamp: u64, +) -> Vec { + assert_eq!( + controller.account, + Account::default(), + "Redemption controller account must be uninitialized" + ); + assert_ne!( + stablecoin_definition.account, + Account::default(), + "Stablecoin definition account must be initialized" + ); + assert_ne!( + price_feed.account, + Account::default(), + "Price feed account must be initialized" + ); + assert_price_value_fits_i128(initial_redemption_price, "Initial redemption price"); + assert_price_value_fits_i128(max_integral_error, "Maximum integral error"); + assert_price_value_fits_i128(max_redemption_rate, "Maximum redemption rate"); + assert_price_value_fits_i128(proportional_gain, "Proportional gain"); + assert_price_value_fits_i128(integral_gain, "Integral gain"); + assert_ne!( + initial_redemption_price, 0, + "Initial redemption price must be nonzero" + ); + assert_ne!( + max_redemption_rate, 0, + "Maximum redemption rate must be nonzero" + ); + + let token_definition = TokenDefinition::try_from(&stablecoin_definition.account.data) + .expect("Stablecoin definition account must hold a valid TokenDefinition"); + assert!( + matches!(token_definition, TokenDefinition::Fungible { .. }), + "Stablecoin definition must be fungible" + ); + + let controller_seed = verify_redemption_controller_and_get_seed( + &controller, + stablecoin_definition.account_id, + price_feed.account_id, + stablecoin_program_id, + ); + let controller_state = RedemptionController { + stablecoin_definition_id: stablecoin_definition.account_id, + reference_asset_id, + price_feed_id: price_feed.account_id, + oracle_program_id: price_feed.account.program_owner, + redemption_price: initial_redemption_price, + redemption_rate: 0, + accumulated_error: 0, + proportional_gain, + integral_gain, + max_integral_error, + max_redemption_rate, + max_price_feed_age, + last_update_timestamp: current_timestamp, + }; + let mut controller_post = controller.account; + controller_post.data = Data::from(&controller_state); + + vec![ + AccountPostState::new_claimed( + controller_post, + nssa_core::program::Claim::Pda(controller_seed), + ), + AccountPostState::new(stablecoin_definition.account), + AccountPostState::new(price_feed.account), + ] +} + +/// Update redemption price and redemption rate from the configured price feed. +/// +/// If the configured feed is stale, unavailable, or malformed, the controller state is emitted +/// unchanged. That makes stale-oracle handling an explicit pause instead of a failed update. +/// +/// # Panics +/// - `controller` is uninitialized, not owned by this program, malformed, or at the wrong PDA. +/// - `current_timestamp` is older than `RedemptionController.last_update_timestamp`. +pub fn update_redemption_controller( + controller: AccountWithMetadata, + price_feed: AccountWithMetadata, + stablecoin_program_id: ProgramId, + current_timestamp: u64, +) -> Vec { + assert_ne!( + controller.account, + Account::default(), + "Redemption controller account must be initialized" + ); + assert_eq!( + controller.account.program_owner, stablecoin_program_id, + "Redemption controller is not owned by this stablecoin program" + ); + + let controller_data = RedemptionController::try_from(&controller.account.data) + .expect("Redemption controller account must hold valid controller state"); + verify_redemption_controller_and_get_seed( + &controller, + controller_data.stablecoin_definition_id, + controller_data.price_feed_id, + stablecoin_program_id, + ); + assert!( + current_timestamp >= controller_data.last_update_timestamp, + "Current timestamp is older than the last controller update" + ); + + let Some(market_price) = live_market_price(&controller_data, &price_feed, current_timestamp) + else { + return vec![ + AccountPostState::new(controller.account), + AccountPostState::new(price_feed.account), + ]; + }; + + let updated_controller = + compute_next_controller_state(&controller_data, market_price, current_timestamp); + let mut controller_post = controller.account; + controller_post.data = Data::from(&updated_controller); + + vec![ + AccountPostState::new(controller_post), + AccountPostState::new(price_feed.account), + ] +} + +fn live_market_price( + controller: &RedemptionController, + price_feed: &AccountWithMetadata, + current_timestamp: u64, +) -> Option { + if price_feed.account_id != controller.price_feed_id + || price_feed.account == Account::default() + || price_feed.account.program_owner != controller.oracle_program_id + { + return None; + } + + let price_account = OraclePriceAccount::try_from(&price_feed.account.data).ok()?; + if price_account.base_asset != controller.stablecoin_definition_id + || price_account.quote_asset != controller.reference_asset_id + || price_account.price == 0 + || price_account.price > i128_max_as_u128() + || price_account.timestamp > current_timestamp + { + return None; + } + + let age = current_timestamp.checked_sub(price_account.timestamp)?; + if age > controller.max_price_feed_age { + return None; + } + + Some(price_account.price) +} + +fn compute_next_controller_state( + controller: &RedemptionController, + market_price: u128, + current_timestamp: u64, +) -> RedemptionController { + let elapsed = current_timestamp + .checked_sub(controller.last_update_timestamp) + .expect("Current timestamp was checked to be monotonic"); + let redemption_price = apply_redemption_rate( + controller.redemption_price, + controller.redemption_rate, + elapsed, + ); + let error = price_error(redemption_price, market_price); + let accumulated_error = clamp_signed( + controller + .accumulated_error + .saturating_add(error.saturating_mul(i128::from(elapsed))), + controller.max_integral_error, + ); + let proportional_term = scaled_term(error, controller.proportional_gain); + let integral_term = scaled_term(accumulated_error, controller.integral_gain); + let redemption_rate = clamp_signed( + proportional_term.saturating_add(integral_term), + controller.max_redemption_rate, + ); + + RedemptionController { + stablecoin_definition_id: controller.stablecoin_definition_id, + reference_asset_id: controller.reference_asset_id, + price_feed_id: controller.price_feed_id, + oracle_program_id: controller.oracle_program_id, + redemption_price, + redemption_rate, + accumulated_error, + proportional_gain: controller.proportional_gain, + integral_gain: controller.integral_gain, + max_integral_error: controller.max_integral_error, + max_redemption_rate: controller.max_redemption_rate, + max_price_feed_age: controller.max_price_feed_age, + last_update_timestamp: current_timestamp, + } +} + +fn apply_redemption_rate(redemption_price: u128, redemption_rate: i128, elapsed: u64) -> u128 { + let drift = redemption_rate.saturating_mul(i128::from(elapsed)); + if drift >= 0 { + redemption_price.saturating_add(u128::try_from(drift).unwrap_or(u128::MAX)) + } else { + let decrease = u128::try_from(drift.saturating_neg()).unwrap_or(u128::MAX); + redemption_price.saturating_sub(decrease).max(1) + } +} + +fn price_error(redemption_price: u128, market_price: u128) -> i128 { + if redemption_price >= market_price { + let difference = redemption_price + .checked_sub(market_price) + .expect("checked by branch"); + i128::try_from(difference).expect("Redemption price difference must fit i128") + } else { + let difference = market_price + .checked_sub(redemption_price) + .expect("checked by branch"); + i128::try_from(difference) + .expect("Market price difference must fit i128") + .saturating_neg() + } +} + +fn scaled_term(value: i128, gain: u128) -> i128 { + let gain = i128::try_from(gain).expect("Controller gain must fit i128"); + value + .saturating_mul(gain) + .checked_div(CONTROLLER_GAIN_SCALE_I128) + .expect("Controller gain scale must be nonzero") +} + +fn clamp_signed(value: i128, max_abs: u128) -> i128 { + let max_abs = i128::try_from(max_abs).expect("Controller clamp must fit i128"); + value.clamp(max_abs.saturating_neg(), max_abs) +} + +fn assert_price_value_fits_i128(value: u128, label: &str) { + assert!(value <= i128_max_as_u128(), "{label} must fit i128"); +} + +fn i128_max_as_u128() -> u128 { + u128::try_from(i128::MAX).expect("i128::MAX must fit u128") +} + +#[cfg(test)] +mod tests { + use nssa_core::{ + account::Nonce, + program::{Claim, PdaSeed}, + }; + use stablecoin_core::{ + compute_redemption_controller_pda, compute_redemption_controller_pda_seed, + CONTROLLER_GAIN_SCALE, + }; + + use super::*; + + const STABLECOIN_PROGRAM_ID: ProgramId = [3u32; 8]; + const TOKEN_PROGRAM_ID: ProgramId = [2u32; 8]; + const ORACLE_PROGRAM_ID: ProgramId = [4u32; 8]; + + fn stablecoin_definition_id() -> AccountId { + AccountId::new([1; 32]) + } + + fn reference_asset_id() -> AccountId { + AccountId::new([2; 32]) + } + + fn price_feed_id() -> AccountId { + AccountId::new([3; 32]) + } + + fn controller_id() -> AccountId { + compute_redemption_controller_pda( + STABLECOIN_PROGRAM_ID, + stablecoin_definition_id(), + price_feed_id(), + ) + } + + fn controller_seed() -> PdaSeed { + compute_redemption_controller_pda_seed(stablecoin_definition_id(), price_feed_id()) + } + + fn stablecoin_definition_account() -> AccountWithMetadata { + AccountWithMetadata { + account: Account { + program_owner: TOKEN_PROGRAM_ID, + balance: 0, + data: Data::from(&TokenDefinition::Fungible { + name: "DAI".to_owned(), + total_supply: 1_000_000, + metadata_id: None, + }), + nonce: Nonce(0), + }, + is_authorized: false, + account_id: stablecoin_definition_id(), + } + } + + fn price_feed_account(price: u128, timestamp: u64) -> AccountWithMetadata { + AccountWithMetadata { + account: Account { + program_owner: ORACLE_PROGRAM_ID, + balance: 0, + data: Data::from(&OraclePriceAccount { + base_asset: stablecoin_definition_id(), + quote_asset: reference_asset_id(), + price, + timestamp, + source_id: "twap".to_owned(), + confidence_interval: 0, + }), + nonce: Nonce(0), + }, + is_authorized: false, + account_id: price_feed_id(), + } + } + + fn uninit_controller_account() -> AccountWithMetadata { + AccountWithMetadata { + account: Account::default(), + is_authorized: false, + account_id: controller_id(), + } + } + + fn controller_account(controller: &RedemptionController) -> AccountWithMetadata { + AccountWithMetadata { + account: Account { + program_owner: STABLECOIN_PROGRAM_ID, + balance: 0, + data: Data::from(controller), + nonce: Nonce(0), + }, + is_authorized: false, + account_id: controller_id(), + } + } + + fn controller_state() -> RedemptionController { + RedemptionController { + stablecoin_definition_id: stablecoin_definition_id(), + reference_asset_id: reference_asset_id(), + price_feed_id: price_feed_id(), + oracle_program_id: ORACLE_PROGRAM_ID, + redemption_price: 1_000, + redemption_rate: 0, + accumulated_error: 0, + proportional_gain: CONTROLLER_GAIN_SCALE, + integral_gain: 0, + max_integral_error: 1_000, + max_redemption_rate: 500, + max_price_feed_age: 10, + last_update_timestamp: 100, + } + } + + #[test] + fn initialize_redemption_controller_claims_pda_and_stores_config() { + let post_states = initialize_redemption_controller( + uninit_controller_account(), + stablecoin_definition_account(), + price_feed_account(1_000, 100), + STABLECOIN_PROGRAM_ID, + reference_asset_id(), + 1_000, + CONTROLLER_GAIN_SCALE, + 0, + 1_000, + 500, + 10, + 100, + ); + + assert_eq!(post_states.len(), 3); + assert_eq!( + post_states[0].required_claim(), + Some(Claim::Pda(controller_seed())) + ); + let controller = + RedemptionController::try_from(&post_states[0].account().data).expect("valid state"); + assert_eq!( + controller.stablecoin_definition_id, + stablecoin_definition_id() + ); + assert_eq!(controller.reference_asset_id, reference_asset_id()); + assert_eq!(controller.price_feed_id, price_feed_id()); + assert_eq!(controller.oracle_program_id, ORACLE_PROGRAM_ID); + assert_eq!(controller.redemption_price, 1_000); + assert_eq!(controller.redemption_rate, 0); + assert_eq!(controller.last_update_timestamp, 100); + } + + #[test] + fn update_redemption_controller_uses_live_price_feed() { + let post_states = update_redemption_controller( + controller_account(&controller_state()), + price_feed_account(900, 100), + STABLECOIN_PROGRAM_ID, + 100, + ); + + assert_eq!(post_states.len(), 2); + let controller = + RedemptionController::try_from(&post_states[0].account().data).expect("valid state"); + assert_eq!(controller.redemption_rate, 100); + assert_eq!(controller.last_update_timestamp, 100); + } + + #[test] + fn update_redemption_controller_pauses_when_price_feed_is_stale() { + let controller = controller_state(); + let post_states = update_redemption_controller( + controller_account(&controller), + price_feed_account(900, 100), + STABLECOIN_PROGRAM_ID, + 111, + ); + + let updated = + RedemptionController::try_from(&post_states[0].account().data).expect("valid state"); + assert_eq!(updated, controller); + } + + #[test] + fn update_redemption_controller_pauses_when_price_feed_is_unavailable() { + let controller = controller_state(); + let unavailable_feed = AccountWithMetadata { + account: Account::default(), + is_authorized: false, + account_id: price_feed_id(), + }; + let post_states = update_redemption_controller( + controller_account(&controller), + unavailable_feed, + STABLECOIN_PROGRAM_ID, + 101, + ); + + let updated = + RedemptionController::try_from(&post_states[0].account().data).expect("valid state"); + assert_eq!(updated, controller); + } + + #[test] + fn controller_sets_positive_rate_when_market_price_is_below_redemption_price() { + let updated = compute_next_controller_state(&controller_state(), 900, 100); + + assert_eq!(updated.redemption_rate, 100); + assert_eq!(updated.accumulated_error, 0); + assert_eq!(updated.redemption_price, 1_000); + } + + #[test] + fn controller_sets_negative_rate_when_market_price_is_above_redemption_price() { + let updated = compute_next_controller_state(&controller_state(), 1_100, 100); + + assert_eq!(updated.redemption_rate, -100); + } + + #[test] + fn controller_applies_existing_redemption_rate_over_elapsed_time() { + let mut controller = controller_state(); + controller.redemption_rate = 2; + controller.proportional_gain = 0; + + let updated = compute_next_controller_state(&controller, 1_010, 105); + + assert_eq!(updated.redemption_price, 1_010); + assert_eq!(updated.redemption_rate, 0); + assert_eq!(updated.last_update_timestamp, 105); + } + + #[test] + fn controller_clamps_accumulated_error_and_redemption_rate() { + let mut controller = controller_state(); + controller.accumulated_error = 90; + controller.proportional_gain = 0; + controller.integral_gain = CONTROLLER_GAIN_SCALE; + controller.max_integral_error = 100; + controller.max_redemption_rate = 80; + + let updated = compute_next_controller_state(&controller, 900, 101); + + assert_eq!(updated.accumulated_error, 100); + assert_eq!(updated.redemption_rate, 80); + } +}