From 0410d83ae40bb1c4fdda6884d5b226e0d21d585e Mon Sep 17 00:00:00 2001 From: Andrea Franz Date: Tue, 18 Aug 2026 15:33:33 +0000 Subject: [PATCH] feat(stablecoin): implement initialize_program host function closes #163 --- Cargo.lock | 2 + programs/stablecoin/Cargo.toml | 2 + programs/stablecoin/core/src/lib.rs | 58 ++ programs/stablecoin/methods/guest/Cargo.lock | 11 + programs/stablecoin/src/initialize_program.rs | 865 ++++++++++++++++++ programs/stablecoin/src/lib.rs | 3 + 6 files changed, 941 insertions(+) create mode 100644 programs/stablecoin/src/initialize_program.rs diff --git a/Cargo.lock b/Cargo.lock index 9184b06..0b8a139 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3946,9 +3946,11 @@ dependencies = [ name = "stablecoin_program" version = "0.1.0" dependencies = [ + "clock_core", "lee_core", "stablecoin_core", "token_core", + "twap_oracle_core", ] [[package]] diff --git a/programs/stablecoin/Cargo.toml b/programs/stablecoin/Cargo.toml index 08a18fd..b4e3149 100644 --- a/programs/stablecoin/Cargo.toml +++ b/programs/stablecoin/Cargo.toml @@ -5,5 +5,7 @@ edition = "2021" [dependencies] nssa_core = { git = "https://github.com/logos-blockchain/logos-execution-zone.git", tag = "v0.2.0", features = ["host"], package = "lee_core" } +clock_core = { git = "https://github.com/logos-blockchain/logos-execution-zone.git", tag = "v0.2.0" } 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 dfbbb0f..a1b31de 100644 --- a/programs/stablecoin/core/src/lib.rs +++ b/programs/stablecoin/core/src/lib.rs @@ -31,6 +31,8 @@ pub use stability_fee_accumulator::{ // compatibility. const POSITION_PDA_DOMAIN: &[u8] = b"POSITION"; const POSITION_VAULT_PDA_DOMAIN: &[u8] = b"POSITION_VAULT"; +const STABLECOIN_DEFINITION_PDA_DOMAIN: [u8; 32] = *b"STABLECOIN__DEFINITION__________"; +const STABLECOIN_MASTER_HOLDING_PDA_DOMAIN: [u8; 32] = *b"STABLECOIN__MASTER_HOLDING______"; /// Stablecoin Program Instruction. #[derive(Debug, Serialize, Deserialize)] @@ -299,6 +301,62 @@ pub fn verify_position_vault_and_get_seed( seed } +/// PDA seed for the stablecoin's Token Program-owned `TokenDefinition` +/// account, derived under the stablecoin program id so the stablecoin +/// program's PDA seed authorizes every chained `Token::Mint` / `Token::Burn`. +#[must_use] +pub fn compute_stablecoin_definition_pda_seed() -> PdaSeed { + use risc0_zkvm::sha::{Impl, Sha256 as _}; + + let mut out = [0u8; 32]; + out.copy_from_slice(Impl::hash_bytes(&STABLECOIN_DEFINITION_PDA_DOMAIN).as_bytes()); + PdaSeed::new(out) +} + +#[must_use] +pub fn compute_stablecoin_definition_pda(stablecoin_program_id: ProgramId) -> AccountId { + AccountId::for_public_pda( + &stablecoin_program_id, + &compute_stablecoin_definition_pda_seed(), + ) +} + +/// PDA seed for the empty `TokenHolding` paired with the stablecoin definition +/// at `Token::NewFungibleDefinition` time. The protocol passes `total_supply = 0` +/// so this holding stays empty forever; the PDA contains the artifact at a +/// deterministic address. See spec §3.1 / §10.1 for the full rationale. +#[must_use] +pub fn compute_stablecoin_master_holding_pda_seed() -> PdaSeed { + use risc0_zkvm::sha::{Impl, Sha256 as _}; + + let mut out = [0u8; 32]; + out.copy_from_slice(Impl::hash_bytes(&STABLECOIN_MASTER_HOLDING_PDA_DOMAIN).as_bytes()); + PdaSeed::new(out) +} + +#[must_use] +pub fn compute_stablecoin_master_holding_pda(stablecoin_program_id: ProgramId) -> AccountId { + AccountId::for_public_pda( + &stablecoin_program_id, + &compute_stablecoin_master_holding_pda_seed(), + ) +} + +#[cfg(test)] +mod global_pda_tests { + use super::*; + + #[test] + fn stablecoin_definition_and_master_holding_pdas_are_distinct() { + let program_id: ProgramId = [11u32; 8]; + let def = compute_stablecoin_definition_pda(program_id); + let master = compute_stablecoin_master_holding_pda(program_id); + assert_ne!(def, master); + assert_ne!(def, compute_protocol_parameters_pda(program_id)); + assert_ne!(master, compute_stability_fee_accumulator_pda(program_id)); + } +} + #[cfg(test)] mod instruction_tests { use super::*; diff --git a/programs/stablecoin/methods/guest/Cargo.lock b/programs/stablecoin/methods/guest/Cargo.lock index 8aec810..6a83e9c 100644 --- a/programs/stablecoin/methods/guest/Cargo.lock +++ b/programs/stablecoin/methods/guest/Cargo.lock @@ -688,6 +688,15 @@ dependencies = [ "inout", ] +[[package]] +name = "clock_core" +version = "0.1.0" +source = "git+https://github.com/logos-blockchain/logos-execution-zone.git?tag=v0.2.0#a58fbce2ff48c58b7bb5001b1a27e64b9596ee3a" +dependencies = [ + "borsh", + "lee_core", +] + [[package]] name = "cmov" version = "0.5.4" @@ -2709,9 +2718,11 @@ dependencies = [ name = "stablecoin_program" version = "0.1.0" dependencies = [ + "clock_core", "lee_core", "stablecoin_core", "token_core", + "twap_oracle_core", ] [[package]] diff --git a/programs/stablecoin/src/initialize_program.rs b/programs/stablecoin/src/initialize_program.rs new file mode 100644 index 0000000..39468de --- /dev/null +++ b/programs/stablecoin/src/initialize_program.rs @@ -0,0 +1,865 @@ +//! Host-side implementation of `Instruction::InitializeProgram` (spec §10.1). +//! +//! Wall-clock time is read from the system `CLOCK_01` account passed as the +//! 9th input. The pinned `spel-framework` (v0.3.0) `ProgramContext` exposes no +//! clock, so — like the runtime's own time-locked programs — this reads the +//! millisecond timestamp from a clock account (see `clock_core`). + +use clock_core::{ClockAccountData, CLOCK_01_PROGRAM_ACCOUNT_ID}; +use nssa_core::{ + account::{Account, AccountId, AccountWithMetadata, Data}, + program::{AccountPostState, ChainedCall, Claim, ProgramId}, +}; +use stablecoin_core::{ + compute_protocol_parameters_pda, compute_protocol_parameters_pda_seed, + compute_redemption_price_state_pda, compute_redemption_price_state_pda_seed, + compute_stability_fee_accumulator_pda, compute_stability_fee_accumulator_pda_seed, + compute_stablecoin_definition_pda, compute_stablecoin_definition_pda_seed, + compute_stablecoin_master_holding_pda, compute_stablecoin_master_holding_pda_seed, + math::FIXED_POINT_ONE, ProtocolParameters, RedemptionPriceState, StabilityFeeAccumulator, +}; +use token_core::TokenDefinition; +use twap_oracle_core::OraclePriceAccount; + +// --- Sane-band constants per spec §8 ----------------------------------------- + +const MAX_STABILITY_FEE_PER_MILLISECOND: u128 = FIXED_POINT_ONE * 2; +const MIN_COLLATERALIZATION_RATIO: u128 = FIXED_POINT_ONE * 110 / 100; // 1.1x +const MAX_COLLATERALIZATION_RATIO: u128 = FIXED_POINT_ONE * 10; +// Gain magnitude caps (spec §8; placeholders pending the §15 tuning pass): +// |Kp| <= FIXED_POINT_ONE * 10^3, |Ki| <= FIXED_POINT_ONE. +const MAX_PROPORTIONAL_GAIN_MAGNITUDE: u128 = FIXED_POINT_ONE * 1_000; +const MAX_INTEGRAL_GAIN_MAGNITUDE: u128 = FIXED_POINT_ONE; +const MAX_TIMING_MILLISECONDS: u64 = 86_400_000; // 1 day + +// --- Unpacked numerical parameters ------------------------------------------- + +/// Numerical parameters for [`initialize_program`]. Bundled to keep the host +/// function's argument count manageable; this is a parameter bag, not state. +#[derive(Debug)] +pub struct InitializeProgramParams<'a> { + pub freeze_authority_account_id: AccountId, + pub initial_stability_fee_per_millisecond: u128, + pub initial_controller_proportional_gain: i128, + pub initial_controller_integral_gain: i128, + pub initial_minimum_collateralization_ratio: u128, + pub minimum_milliseconds_between_rate_updates: u64, + pub maximum_oracle_price_age_milliseconds: u64, + pub initial_redemption_price: u128, + pub stablecoin_name: &'a str, +} + +// --- Host function ---------------------------------------------------------- + +/// Bootstrap a fresh stablecoin protocol instance. +/// +/// See spec §10.1 for the full account contract and panic conditions. `now` is +/// read from the `clock` input (the system `CLOCK_01` account); it anchors the +/// stability-fee accumulator and the redemption-price state. +#[allow( + clippy::too_many_arguments, + reason = "the nine account inputs + program id + parameter bag mirror the on-chain instruction ABI; collapsing them further would obscure it" +)] +pub fn initialize_program( + admin: AccountWithMetadata, + protocol_parameters: AccountWithMetadata, + stability_fee_accumulator: AccountWithMetadata, + redemption_price_state: AccountWithMetadata, + stablecoin_definition: AccountWithMetadata, + stablecoin_master_holding: AccountWithMetadata, + collateral_definition: AccountWithMetadata, + market_price_oracle: AccountWithMetadata, + clock: AccountWithMetadata, + stablecoin_program_id: ProgramId, + params: InitializeProgramParams<'_>, +) -> (Vec, Vec) { + // 1. Authorization + assert!(admin.is_authorized, "Admin authorization is missing"); + + // 2. Target PDAs must be uninitialized + assert_eq!( + protocol_parameters.account, + Account::default(), + "ProtocolParameters account must be uninitialized" + ); + assert_eq!( + stability_fee_accumulator.account, + Account::default(), + "StabilityFeeAccumulator account must be uninitialized" + ); + assert_eq!( + redemption_price_state.account, + Account::default(), + "RedemptionPriceState account must be uninitialized" + ); + assert_eq!( + stablecoin_definition.account, + Account::default(), + "StablecoinDefinition account must be uninitialized" + ); + assert_eq!( + stablecoin_master_holding.account, + Account::default(), + "StablecoinMasterHolding account must be uninitialized" + ); + + // 3. PDA address checks + assert_eq!( + protocol_parameters.account_id, + compute_protocol_parameters_pda(stablecoin_program_id), + "ProtocolParameters account ID does not match expected PDA derivation" + ); + assert_eq!( + stability_fee_accumulator.account_id, + compute_stability_fee_accumulator_pda(stablecoin_program_id), + "StabilityFeeAccumulator account ID does not match expected PDA derivation" + ); + assert_eq!( + redemption_price_state.account_id, + compute_redemption_price_state_pda(stablecoin_program_id), + "RedemptionPriceState account ID does not match expected PDA derivation" + ); + assert_eq!( + stablecoin_definition.account_id, + compute_stablecoin_definition_pda(stablecoin_program_id), + "StablecoinDefinition account ID does not match expected PDA derivation" + ); + assert_eq!( + stablecoin_master_holding.account_id, + compute_stablecoin_master_holding_pda(stablecoin_program_id), + "StablecoinMasterHolding account ID does not match expected PDA derivation" + ); + + // 4. Clock account: read the millisecond wall-clock timestamp. + assert_eq!( + clock.account_id, CLOCK_01_PROGRAM_ACCOUNT_ID, + "Clock account must be the system CLOCK_01 account" + ); + assert_ne!( + clock.account, + Account::default(), + "Clock account must be initialized" + ); + let now = ClockAccountData::from_bytes(clock.account.data.as_ref()).timestamp; + + // 5. Collateral definition must be an initialized Fungible TokenDefinition + assert_ne!( + collateral_definition.account, + Account::default(), + "Collateral definition account must be initialized" + ); + let collateral_def = TokenDefinition::try_from(&collateral_definition.account.data) + .expect("Collateral definition must be a valid TokenDefinition"); + assert!( + matches!(collateral_def, TokenDefinition::Fungible { .. }), + "Collateral definition must be Fungible" + ); + + // 6. Market price oracle must be initialized + base/quote match + assert_ne!( + market_price_oracle.account, + Account::default(), + "Market price oracle account must be initialized" + ); + let oracle = OraclePriceAccount::try_from(&market_price_oracle.account.data) + .expect("Market price oracle must decode as OraclePriceAccount"); + assert_eq!( + oracle.base_asset, stablecoin_definition.account_id, + "Oracle base_asset must equal the stablecoin definition's account_id" + ); + assert_eq!( + oracle.quote_asset, collateral_definition.account_id, + "Oracle quote_asset must equal the collateral definition's account_id" + ); + + // 7. Numerical param bounds (spec §8) + assert!( + params.initial_stability_fee_per_millisecond >= FIXED_POINT_ONE, + "initial_stability_fee_per_millisecond below FIXED_POINT_ONE" + ); + assert!( + params.initial_stability_fee_per_millisecond <= MAX_STABILITY_FEE_PER_MILLISECOND, + "initial_stability_fee_per_millisecond above sane upper bound" + ); + assert!( + params.initial_minimum_collateralization_ratio >= MIN_COLLATERALIZATION_RATIO, + "initial_minimum_collateralization_ratio below 1.1x" + ); + assert!( + params.initial_minimum_collateralization_ratio <= MAX_COLLATERALIZATION_RATIO, + "initial_minimum_collateralization_ratio above 10x" + ); + assert!( + params.initial_controller_proportional_gain.unsigned_abs() + <= MAX_PROPORTIONAL_GAIN_MAGNITUDE, + "controller_proportional_gain out of band" + ); + assert!( + params.initial_controller_integral_gain.unsigned_abs() <= MAX_INTEGRAL_GAIN_MAGNITUDE, + "controller_integral_gain out of band" + ); + for &(milliseconds, label) in &[ + ( + params.minimum_milliseconds_between_rate_updates, + "minimum_milliseconds_between_rate_updates", + ), + ( + params.maximum_oracle_price_age_milliseconds, + "maximum_oracle_price_age_milliseconds", + ), + ] { + assert!(milliseconds >= 1, "{label} below minimum 1ms"); + assert!( + milliseconds <= MAX_TIMING_MILLISECONDS, + "{label} above maximum 86_400_000ms" + ); + } + assert!( + params.initial_redemption_price > 0, + "initial_redemption_price must be positive" + ); + assert!( + !params.stablecoin_name.is_empty(), + "stablecoin_name must be non-empty" + ); + + // 8. Build the new global states + let protocol_parameters_value = ProtocolParameters { + admin_account_id: admin.account_id, + freeze_authority_account_id: params.freeze_authority_account_id, + stablecoin_definition_id: stablecoin_definition.account_id, + collateral_definition_id: collateral_definition.account_id, + market_price_oracle_id: market_price_oracle.account_id, + stability_fee_per_millisecond: params.initial_stability_fee_per_millisecond, + controller_proportional_gain: params.initial_controller_proportional_gain, + controller_integral_gain: params.initial_controller_integral_gain, + minimum_collateralization_ratio: params.initial_minimum_collateralization_ratio, + minimum_milliseconds_between_rate_updates: params.minimum_milliseconds_between_rate_updates, + maximum_oracle_price_age_milliseconds: params.maximum_oracle_price_age_milliseconds, + is_frozen: false, + }; + let accumulator_value = StabilityFeeAccumulator { + accumulated_rate_at_last_accrual: FIXED_POINT_ONE, + last_accrued_at: now, + }; + let redemption_value = RedemptionPriceState { + redemption_price_at_last_update: params.initial_redemption_price, + redemption_rate_per_millisecond: FIXED_POINT_ONE, + controller_integral_term: 0, + last_updated_at: now, + }; + + // 9. Post-states (9 accounts) + let mut protocol_parameters_post = protocol_parameters.account; + protocol_parameters_post.data = Data::from(&protocol_parameters_value); + + let mut accumulator_post = stability_fee_accumulator.account; + accumulator_post.data = Data::from(&accumulator_value); + + let mut redemption_post = redemption_price_state.account; + redemption_post.data = Data::from(&redemption_value); + + let protocol_seed = compute_protocol_parameters_pda_seed(); + let accumulator_seed = compute_stability_fee_accumulator_pda_seed(); + let redemption_seed = compute_redemption_price_state_pda_seed(); + let stablecoin_def_seed = compute_stablecoin_definition_pda_seed(); + let master_holding_seed = compute_stablecoin_master_holding_pda_seed(); + + let token_program_id = collateral_definition.account.program_owner; + + // For the chained Token::NewFungibleDefinition we mark both target PDAs + // authorized — the chained call's PDA seeds authorize the claim. + let mut stablecoin_def_authorized = stablecoin_definition.clone(); + stablecoin_def_authorized.is_authorized = true; + let mut master_holding_authorized = stablecoin_master_holding.clone(); + master_holding_authorized.is_authorized = true; + + let post_states = vec![ + AccountPostState::new(admin.account), + AccountPostState::new_claimed(protocol_parameters_post, Claim::Pda(protocol_seed)), + AccountPostState::new_claimed(accumulator_post, Claim::Pda(accumulator_seed)), + AccountPostState::new_claimed(redemption_post, Claim::Pda(redemption_seed)), + AccountPostState::new(stablecoin_definition.account.clone()), + AccountPostState::new(stablecoin_master_holding.account.clone()), + AccountPostState::new(collateral_definition.account.clone()), + AccountPostState::new(market_price_oracle.account.clone()), + AccountPostState::new(clock.account.clone()), + ]; + + let new_definition_call = ChainedCall::new( + token_program_id, + vec![stablecoin_def_authorized, master_holding_authorized], + &token_core::Instruction::NewFungibleDefinition { + name: params.stablecoin_name.to_owned(), + total_supply: 0, + // Self/PDA authority: the definition account is its own mint authority, so + // `generate_debt` / `repay_debt` can mint and burn by presenting the + // stablecoin definition PDA seed in their chained Token calls. + mint_authority: Some(stablecoin_definition.account_id), + }, + ) + .with_pda_seeds(vec![stablecoin_def_seed, master_holding_seed]); + + (post_states, vec![new_definition_call]) +} + +#[cfg(test)] +#[allow( + clippy::indexing_slicing, + clippy::panic, + clippy::unwrap_used, + reason = "tests deliberately panic on bad state via assert!/#[should_panic] and index fixed-size vectors" +)] +mod tests { + use nssa_core::account::Nonce; + use stablecoin_core::compute_protocol_parameters_pda_seed; + + use super::*; + + const STABLECOIN_PROGRAM_ID: ProgramId = [3u32; 8]; + const TOKEN_PROGRAM_ID: ProgramId = [2u32; 8]; + const ORACLE_PROGRAM_ID: ProgramId = [4u32; 8]; + const CLOCK_PROGRAM_ID: ProgramId = [5u32; 8]; + const NOW: u64 = 1_700_000_000; + + fn admin_id() -> AccountId { + AccountId::new([0xA0; 32]) + } + fn freeze_id() -> AccountId { + AccountId::new([0xFE; 32]) + } + fn collateral_definition_id() -> AccountId { + AccountId::new([0x10; 32]) + } + fn oracle_id() -> AccountId { + AccountId::new([0x20; 32]) + } + fn oracle_source_id() -> AccountId { + AccountId::new([0x21; 32]) + } + + fn protocol_parameters_id() -> AccountId { + compute_protocol_parameters_pda(STABLECOIN_PROGRAM_ID) + } + fn accumulator_id() -> AccountId { + compute_stability_fee_accumulator_pda(STABLECOIN_PROGRAM_ID) + } + fn redemption_id() -> AccountId { + compute_redemption_price_state_pda(STABLECOIN_PROGRAM_ID) + } + fn stablecoin_def_id() -> AccountId { + compute_stablecoin_definition_pda(STABLECOIN_PROGRAM_ID) + } + fn master_holding_id() -> AccountId { + compute_stablecoin_master_holding_pda(STABLECOIN_PROGRAM_ID) + } + + fn admin_account() -> AccountWithMetadata { + AccountWithMetadata { + account: Account::default(), + is_authorized: true, + account_id: admin_id(), + } + } + + fn uninit(account_id: AccountId) -> AccountWithMetadata { + AccountWithMetadata { + account: Account::default(), + is_authorized: false, + account_id, + } + } + + fn collateral_definition_account() -> AccountWithMetadata { + AccountWithMetadata { + account: Account { + program_owner: TOKEN_PROGRAM_ID, + balance: 0, + data: Data::from(&TokenDefinition::Fungible { + name: "SNT".to_owned(), + total_supply: 1_000_000, + metadata_id: None, + authority: None, + }), + nonce: Nonce(0), + }, + is_authorized: false, + account_id: collateral_definition_id(), + } + } + + fn oracle_account() -> AccountWithMetadata { + AccountWithMetadata { + account: Account { + program_owner: ORACLE_PROGRAM_ID, + balance: 0, + data: Data::from(&OraclePriceAccount { + base_asset: stablecoin_def_id(), + quote_asset: collateral_definition_id(), + price: FIXED_POINT_ONE / 2, + timestamp: NOW, + source_id: oracle_source_id(), + confidence_interval: 0, + }), + nonce: Nonce(0), + }, + is_authorized: false, + account_id: oracle_id(), + } + } + + fn clock_account() -> AccountWithMetadata { + AccountWithMetadata { + account: Account { + program_owner: CLOCK_PROGRAM_ID, + balance: 0, + data: Data::try_from( + ClockAccountData { + block_id: 0, + timestamp: NOW, + } + .to_bytes(), + ) + .expect("clock data fits"), + nonce: Nonce(0), + }, + is_authorized: false, + account_id: CLOCK_01_PROGRAM_ACCOUNT_ID, + } + } + + fn ok_params() -> InitializeProgramParams<'static> { + InitializeProgramParams { + freeze_authority_account_id: freeze_id(), + initial_stability_fee_per_millisecond: FIXED_POINT_ONE + 1_500_000_000_000_000, + initial_controller_proportional_gain: 0, + initial_controller_integral_gain: 0, + initial_minimum_collateralization_ratio: FIXED_POINT_ONE * 3 / 2, + minimum_milliseconds_between_rate_updates: 300_000, + maximum_oracle_price_age_milliseconds: 900_000, + initial_redemption_price: FIXED_POINT_ONE / 2, + stablecoin_name: "test-stable", + } + } + + fn invoke(params: InitializeProgramParams<'_>) -> (Vec, Vec) { + initialize_program( + admin_account(), + uninit(protocol_parameters_id()), + uninit(accumulator_id()), + uninit(redemption_id()), + uninit(stablecoin_def_id()), + uninit(master_holding_id()), + collateral_definition_account(), + oracle_account(), + clock_account(), + STABLECOIN_PROGRAM_ID, + params, + ) + } + + #[test] + fn happy_path_produces_nine_post_states_and_one_chained_call() { + let (post_states, chained_calls) = invoke(ok_params()); + assert_eq!(post_states.len(), 9); + assert_eq!(chained_calls.len(), 1); + } + + #[test] + fn happy_path_protocol_parameters_post_state_has_expected_fields() { + let (post_states, _) = invoke(ok_params()); + let pp_post = &post_states[1]; + assert_eq!( + pp_post.required_claim(), + Some(Claim::Pda(compute_protocol_parameters_pda_seed())) + ); + let decoded = ProtocolParameters::try_from(&pp_post.account().data).expect("decode"); + assert_eq!(decoded.admin_account_id, admin_id()); + assert_eq!(decoded.freeze_authority_account_id, freeze_id()); + assert_eq!(decoded.stablecoin_definition_id, stablecoin_def_id()); + assert_eq!(decoded.collateral_definition_id, collateral_definition_id()); + assert_eq!(decoded.market_price_oracle_id, oracle_id()); + assert!(!decoded.is_frozen); + } + + #[test] + fn happy_path_accumulator_starts_at_fixed_point_one_with_now() { + let (post_states, _) = invoke(ok_params()); + let acc_post = &post_states[2]; + let decoded = StabilityFeeAccumulator::try_from(&acc_post.account().data).expect("decode"); + assert_eq!(decoded.accumulated_rate_at_last_accrual, FIXED_POINT_ONE); + assert_eq!(decoded.last_accrued_at, NOW); + } + + #[test] + fn happy_path_redemption_state_starts_at_params_initial_redemption_price() { + let (post_states, _) = invoke(ok_params()); + let rp_post = &post_states[3]; + let decoded = RedemptionPriceState::try_from(&rp_post.account().data).expect("decode"); + assert_eq!(decoded.redemption_price_at_last_update, FIXED_POINT_ONE / 2); + assert_eq!(decoded.redemption_rate_per_millisecond, FIXED_POINT_ONE); + assert_eq!(decoded.controller_integral_term, 0); + assert_eq!(decoded.last_updated_at, NOW); + } + + #[test] + fn happy_path_clock_post_state_is_unchanged_and_unclaimed() { + let (post_states, _) = invoke(ok_params()); + let clock_post = &post_states[8]; + assert_eq!(clock_post.required_claim(), None); + assert_eq!(clock_post.account(), &clock_account().account); + } + + #[test] + fn happy_path_chained_call_is_new_fungible_definition_with_total_supply_zero_and_two_seeds() { + let (_, chained_calls) = invoke(ok_params()); + let mut def_authorized = uninit(stablecoin_def_id()); + def_authorized.is_authorized = true; + let mut master_authorized = uninit(master_holding_id()); + master_authorized.is_authorized = true; + let expected = ChainedCall::new( + TOKEN_PROGRAM_ID, + vec![def_authorized, master_authorized], + &token_core::Instruction::NewFungibleDefinition { + name: "test-stable".to_owned(), + total_supply: 0, + mint_authority: Some(stablecoin_def_id()), + }, + ) + .with_pda_seeds(vec![ + compute_stablecoin_definition_pda_seed(), + compute_stablecoin_master_holding_pda_seed(), + ]); + assert_eq!(chained_calls[0], expected); + assert_eq!(chained_calls[0].pda_seeds.len(), 2); + } + + #[test] + #[should_panic(expected = "Admin authorization is missing")] + fn requires_admin_authorization() { + let mut admin = admin_account(); + admin.is_authorized = false; + let _ = initialize_program( + admin, + uninit(protocol_parameters_id()), + uninit(accumulator_id()), + uninit(redemption_id()), + uninit(stablecoin_def_id()), + uninit(master_holding_id()), + collateral_definition_account(), + oracle_account(), + clock_account(), + STABLECOIN_PROGRAM_ID, + ok_params(), + ); + } + + #[test] + #[should_panic(expected = "ProtocolParameters account must be uninitialized")] + fn rejects_initialized_protocol_parameters() { + let mut pp = uninit(protocol_parameters_id()); + pp.account.program_owner = STABLECOIN_PROGRAM_ID; + let _ = initialize_program( + admin_account(), + pp, + uninit(accumulator_id()), + uninit(redemption_id()), + uninit(stablecoin_def_id()), + uninit(master_holding_id()), + collateral_definition_account(), + oracle_account(), + clock_account(), + STABLECOIN_PROGRAM_ID, + ok_params(), + ); + } + + #[test] + #[should_panic(expected = "StabilityFeeAccumulator account must be uninitialized")] + fn rejects_initialized_accumulator() { + let mut acc = uninit(accumulator_id()); + acc.account.program_owner = STABLECOIN_PROGRAM_ID; + let _ = initialize_program( + admin_account(), + uninit(protocol_parameters_id()), + acc, + uninit(redemption_id()), + uninit(stablecoin_def_id()), + uninit(master_holding_id()), + collateral_definition_account(), + oracle_account(), + clock_account(), + STABLECOIN_PROGRAM_ID, + ok_params(), + ); + } + + #[test] + #[should_panic(expected = "RedemptionPriceState account must be uninitialized")] + fn rejects_initialized_redemption_state() { + let mut rp = uninit(redemption_id()); + rp.account.program_owner = STABLECOIN_PROGRAM_ID; + let _ = initialize_program( + admin_account(), + uninit(protocol_parameters_id()), + uninit(accumulator_id()), + rp, + uninit(stablecoin_def_id()), + uninit(master_holding_id()), + collateral_definition_account(), + oracle_account(), + clock_account(), + STABLECOIN_PROGRAM_ID, + ok_params(), + ); + } + + #[test] + #[should_panic(expected = "StablecoinDefinition account must be uninitialized")] + fn rejects_initialized_stablecoin_definition() { + let mut sd = uninit(stablecoin_def_id()); + sd.account.program_owner = TOKEN_PROGRAM_ID; + let _ = initialize_program( + admin_account(), + uninit(protocol_parameters_id()), + uninit(accumulator_id()), + uninit(redemption_id()), + sd, + uninit(master_holding_id()), + collateral_definition_account(), + oracle_account(), + clock_account(), + STABLECOIN_PROGRAM_ID, + ok_params(), + ); + } + + #[test] + #[should_panic(expected = "StablecoinMasterHolding account must be uninitialized")] + fn rejects_initialized_master_holding() { + let mut mh = uninit(master_holding_id()); + mh.account.program_owner = TOKEN_PROGRAM_ID; + let _ = initialize_program( + admin_account(), + uninit(protocol_parameters_id()), + uninit(accumulator_id()), + uninit(redemption_id()), + uninit(stablecoin_def_id()), + mh, + collateral_definition_account(), + oracle_account(), + clock_account(), + STABLECOIN_PROGRAM_ID, + ok_params(), + ); + } + + #[test] + #[should_panic( + expected = "ProtocolParameters account ID does not match expected PDA derivation" + )] + fn rejects_wrong_protocol_parameters_pda() { + let pp = uninit(AccountId::new([0xDE; 32])); + let _ = initialize_program( + admin_account(), + pp, + uninit(accumulator_id()), + uninit(redemption_id()), + uninit(stablecoin_def_id()), + uninit(master_holding_id()), + collateral_definition_account(), + oracle_account(), + clock_account(), + STABLECOIN_PROGRAM_ID, + ok_params(), + ); + } + + #[test] + #[should_panic( + expected = "StabilityFeeAccumulator account ID does not match expected PDA derivation" + )] + fn rejects_wrong_accumulator_pda() { + let _ = initialize_program( + admin_account(), + uninit(protocol_parameters_id()), + uninit(AccountId::new([0xDE; 32])), + uninit(redemption_id()), + uninit(stablecoin_def_id()), + uninit(master_holding_id()), + collateral_definition_account(), + oracle_account(), + clock_account(), + STABLECOIN_PROGRAM_ID, + ok_params(), + ); + } + + #[test] + #[should_panic(expected = "Clock account must be the system CLOCK_01 account")] + fn rejects_wrong_clock_account() { + let mut clock = clock_account(); + clock.account_id = AccountId::new([0xC1; 32]); + let _ = initialize_program( + admin_account(), + uninit(protocol_parameters_id()), + uninit(accumulator_id()), + uninit(redemption_id()), + uninit(stablecoin_def_id()), + uninit(master_holding_id()), + collateral_definition_account(), + oracle_account(), + clock, + STABLECOIN_PROGRAM_ID, + ok_params(), + ); + } + + #[test] + #[should_panic(expected = "Collateral definition account must be initialized")] + fn rejects_uninitialized_collateral_definition() { + let _ = initialize_program( + admin_account(), + uninit(protocol_parameters_id()), + uninit(accumulator_id()), + uninit(redemption_id()), + uninit(stablecoin_def_id()), + uninit(master_holding_id()), + uninit(collateral_definition_id()), + oracle_account(), + clock_account(), + STABLECOIN_PROGRAM_ID, + ok_params(), + ); + } + + #[test] + #[should_panic(expected = "Market price oracle account must be initialized")] + fn rejects_uninitialized_oracle() { + let _ = initialize_program( + admin_account(), + uninit(protocol_parameters_id()), + uninit(accumulator_id()), + uninit(redemption_id()), + uninit(stablecoin_def_id()), + uninit(master_holding_id()), + collateral_definition_account(), + uninit(oracle_id()), + clock_account(), + STABLECOIN_PROGRAM_ID, + ok_params(), + ); + } + + #[test] + #[should_panic( + expected = "Oracle base_asset must equal the stablecoin definition's account_id" + )] + fn rejects_oracle_with_wrong_base_asset() { + let mut oracle = oracle_account(); + let mut decoded = OraclePriceAccount::try_from(&oracle.account.data).unwrap(); + decoded.base_asset = AccountId::new([0xBA; 32]); + oracle.account.data = Data::from(&decoded); + let _ = initialize_program( + admin_account(), + uninit(protocol_parameters_id()), + uninit(accumulator_id()), + uninit(redemption_id()), + uninit(stablecoin_def_id()), + uninit(master_holding_id()), + collateral_definition_account(), + oracle, + clock_account(), + STABLECOIN_PROGRAM_ID, + ok_params(), + ); + } + + #[test] + #[should_panic( + expected = "Oracle quote_asset must equal the collateral definition's account_id" + )] + fn rejects_oracle_with_wrong_quote_asset() { + let mut oracle = oracle_account(); + let mut decoded = OraclePriceAccount::try_from(&oracle.account.data).unwrap(); + decoded.quote_asset = AccountId::new([0xCC; 32]); + oracle.account.data = Data::from(&decoded); + let _ = initialize_program( + admin_account(), + uninit(protocol_parameters_id()), + uninit(accumulator_id()), + uninit(redemption_id()), + uninit(stablecoin_def_id()), + uninit(master_holding_id()), + collateral_definition_account(), + oracle, + clock_account(), + STABLECOIN_PROGRAM_ID, + ok_params(), + ); + } + + #[test] + #[should_panic(expected = "initial_stability_fee_per_millisecond below FIXED_POINT_ONE")] + fn rejects_stability_fee_below_fixed_point_one() { + let mut p = ok_params(); + p.initial_stability_fee_per_millisecond = FIXED_POINT_ONE - 1; + let _ = invoke(p); + } + + #[test] + #[should_panic(expected = "initial_stability_fee_per_millisecond above sane upper bound")] + fn rejects_stability_fee_above_2x() { + let mut p = ok_params(); + p.initial_stability_fee_per_millisecond = FIXED_POINT_ONE * 2 + 1; + let _ = invoke(p); + } + + #[test] + #[should_panic(expected = "initial_minimum_collateralization_ratio below 1.1x")] + fn rejects_collateralization_ratio_too_low() { + let mut p = ok_params(); + p.initial_minimum_collateralization_ratio = FIXED_POINT_ONE; + let _ = invoke(p); + } + + #[test] + #[should_panic(expected = "initial_minimum_collateralization_ratio above 10x")] + fn rejects_collateralization_ratio_too_high() { + let mut p = ok_params(); + p.initial_minimum_collateralization_ratio = FIXED_POINT_ONE * 11; + let _ = invoke(p); + } + + #[test] + #[should_panic(expected = "minimum_milliseconds_between_rate_updates below minimum 1ms")] + fn rejects_zero_rate_update_interval() { + let mut p = ok_params(); + p.minimum_milliseconds_between_rate_updates = 0; + let _ = invoke(p); + } + + #[test] + #[should_panic(expected = "maximum_oracle_price_age_milliseconds above maximum 86_400_000ms")] + fn rejects_oracle_max_age_above_day() { + let mut p = ok_params(); + p.maximum_oracle_price_age_milliseconds = 86_400_001; + let _ = invoke(p); + } + + #[test] + #[should_panic(expected = "initial_redemption_price must be positive")] + fn rejects_zero_initial_redemption_price() { + let mut p = ok_params(); + p.initial_redemption_price = 0; + let _ = invoke(p); + } + + #[test] + #[should_panic(expected = "stablecoin_name must be non-empty")] + fn rejects_empty_stablecoin_name() { + let mut p = ok_params(); + p.stablecoin_name = ""; + let _ = invoke(p); + } +} diff --git a/programs/stablecoin/src/lib.rs b/programs/stablecoin/src/lib.rs index 690024e..dc4388a 100644 --- a/programs/stablecoin/src/lib.rs +++ b/programs/stablecoin/src/lib.rs @@ -2,6 +2,9 @@ pub use stablecoin_core as core; +/// Bootstrap the protocol: create the global PDAs and the stablecoin definition. +pub mod initialize_program; + /// Open a new collateral-only position for a calling owner. pub mod open_position;