diff --git a/artifacts/stablecoin-idl.json b/artifacts/stablecoin-idl.json index c8cb135..e65db38 100644 --- a/artifacts/stablecoin-idl.json +++ b/artifacts/stablecoin-idl.json @@ -2,6 +2,103 @@ "version": "0.1.0", "name": "stablecoin", "instructions": [ + { + "name": "initialize_program", + "accounts": [ + { + "name": "admin", + "writable": false, + "signer": false, + "init": false + }, + { + "name": "protocol_parameters", + "writable": false, + "signer": false, + "init": false + }, + { + "name": "stability_fee_accumulator", + "writable": false, + "signer": false, + "init": false + }, + { + "name": "redemption_price_state", + "writable": false, + "signer": false, + "init": false + }, + { + "name": "stablecoin_definition", + "writable": false, + "signer": false, + "init": false + }, + { + "name": "stablecoin_master_holding", + "writable": false, + "signer": false, + "init": false + }, + { + "name": "collateral_definition", + "writable": false, + "signer": false, + "init": false + }, + { + "name": "market_price_oracle", + "writable": false, + "signer": false, + "init": false + }, + { + "name": "clock", + "writable": false, + "signer": false, + "init": false + } + ], + "args": [ + { + "name": "freeze_authority_account_id", + "type": "account_id" + }, + { + "name": "initial_stability_fee_per_millisecond", + "type": "u128" + }, + { + "name": "initial_controller_proportional_gain", + "type": "i128" + }, + { + "name": "initial_controller_integral_gain", + "type": "i128" + }, + { + "name": "initial_minimum_collateralization_ratio", + "type": "u128" + }, + { + "name": "minimum_milliseconds_between_rate_updates", + "type": "u64" + }, + { + "name": "maximum_oracle_price_age_milliseconds", + "type": "u64" + }, + { + "name": "initial_redemption_price", + "type": "u128" + }, + { + "name": "stablecoin_name", + "type": "string" + } + ] + }, { "name": "open_position", "accounts": [ diff --git a/programs/integration_tests/tests/stablecoin.rs b/programs/integration_tests/tests/stablecoin.rs index 653643a..6614a4f 100644 --- a/programs/integration_tests/tests/stablecoin.rs +++ b/programs/integration_tests/tests/stablecoin.rs @@ -1,9 +1,14 @@ +use clock_core::{ClockAccountData, CLOCK_01_PROGRAM_ACCOUNT_ID}; use nssa::{ program_deployment_transaction::{self, ProgramDeploymentTransaction}, 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_protocol_parameters_pda, + compute_redemption_price_state_pda, compute_stability_fee_accumulator_pda, + compute_stablecoin_definition_pda, compute_stablecoin_master_holding_pda, Position, +}; use token_core::{TokenDefinition, TokenHolding}; struct Keys; @@ -23,6 +28,10 @@ impl Keys { fn user_stablecoin_holding() -> PrivateKey { PrivateKey::try_new([43; 32]).expect("valid private key") } + + fn admin() -> PrivateKey { + PrivateKey::try_new([44; 32]).expect("valid private key") + } } impl Ids { @@ -71,6 +80,25 @@ impl Ids { fn vault() -> AccountId { compute_position_vault_pda(Self::stablecoin_program(), Self::position()) } + + fn admin() -> AccountId { + AccountId::from(&PublicKey::new_from_private_key(&Keys::admin())) + } + + fn freeze_authority() -> AccountId { + AccountId::new([0xFE; 32]) + } + + fn oracle() -> AccountId { + AccountId::new([0x70; 32]) + } + + /// The stablecoin's `TokenDefinition` PDA created by `initialize_program` + /// (distinct from `stablecoin_definition`, the externally-owned definition + /// the repay test uses). + fn stablecoin_definition_pda() -> AccountId { + compute_stablecoin_definition_pda(Self::stablecoin_program()) + } } impl Balances { @@ -171,6 +199,37 @@ impl Accounts { nonce: Nonce(0), } } + + fn oracle_init(base_asset: AccountId, quote_asset: AccountId) -> Account { + Account { + program_owner: [9u32; 8], + balance: 0_u128, + data: Data::from(&twap_oracle_core::OraclePriceAccount { + base_asset, + quote_asset, + price: stablecoin_core::math::FIXED_POINT_ONE / 2, + timestamp: 0, + source_id: Ids::oracle(), + confidence_interval: 0, + }), + nonce: Nonce(0), + } + } +} + +/// Seeds the canonical `CLOCK_01` account at `timestamp`. `V03State::new()` no longer +/// auto-creates it, and `initialize_program` reads it for wall-clock time. +fn seed_clock(state: &mut V03State, timestamp: u64) { + let data = ClockAccountData { + block_id: 0, + timestamp, + } + .to_bytes(); + let clock_account = Account { + data: Data::try_from(data).expect("clock account data fits"), + ..Account::default() + }; + state.force_insert_account(CLOCK_01_PROGRAM_ACCOUNT_ID, clock_account); } fn deploy_programs(state: &mut V03State) { @@ -403,3 +462,151 @@ fn stablecoin_repay_debt_burns_stablecoins_and_decreases_debt() { } } } + +#[test] +fn stablecoin_initialize_program_creates_globals_and_stablecoin_definition() { + use stablecoin_core::math::FIXED_POINT_ONE; + + // `V03State::new()` no longer auto-creates the clock account; seed CLOCK_01 with this + // timestamp, which initialize_program reads as `now` to anchor the accumulator and + // redemption-price state. + let now: u64 = 1_700_000_000; + let mut state = V03State::new(); + seed_clock(&mut state, now); + deploy_programs(&mut state); + + // Externally-created collateral definition + market-price oracle. + state.force_insert_account( + Ids::collateral_definition(), + Accounts::collateral_definition_init(), + ); + state.force_insert_account( + Ids::oracle(), + Accounts::oracle_init( + Ids::stablecoin_definition_pda(), + Ids::collateral_definition(), + ), + ); + + let instruction = stablecoin_core::Instruction::InitializeProgram { + freeze_authority_account_id: Ids::freeze_authority(), + 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: String::from("test-stable"), + }; + + let message = public_transaction::Message::try_new( + Ids::stablecoin_program(), + vec![ + Ids::admin(), + compute_protocol_parameters_pda(Ids::stablecoin_program()), + compute_stability_fee_accumulator_pda(Ids::stablecoin_program()), + compute_redemption_price_state_pda(Ids::stablecoin_program()), + Ids::stablecoin_definition_pda(), + compute_stablecoin_master_holding_pda(Ids::stablecoin_program()), + Ids::collateral_definition(), + Ids::oracle(), + CLOCK_01_PROGRAM_ACCOUNT_ID, + ], + vec![current_nonce(&state, Ids::admin())], + instruction, + ) + .unwrap(); + let witness_set = public_transaction::WitnessSet::for_message(&message, &[&Keys::admin()]); + let tx = PublicTransaction::new(message, witness_set); + state + .transition_from_public_transaction(&tx, 1, now) + .expect("initialize_program must succeed"); + + // ProtocolParameters claimed with the expected handles. + let pp = stablecoin_core::ProtocolParameters::try_from( + &state + .get_account_by_id(compute_protocol_parameters_pda(Ids::stablecoin_program())) + .data, + ) + .expect("valid ProtocolParameters"); + assert_eq!(pp.admin_account_id, Ids::admin()); + assert_eq!(pp.freeze_authority_account_id, Ids::freeze_authority()); + assert_eq!( + pp.stablecoin_definition_id, + Ids::stablecoin_definition_pda() + ); + assert_eq!(pp.collateral_definition_id, Ids::collateral_definition()); + assert_eq!(pp.market_price_oracle_id, Ids::oracle()); + assert!(!pp.is_frozen); + + // Accumulator anchored at FIXED_POINT_ONE / now. + let acc = stablecoin_core::StabilityFeeAccumulator::try_from( + &state + .get_account_by_id(compute_stability_fee_accumulator_pda( + Ids::stablecoin_program(), + )) + .data, + ) + .expect("valid StabilityFeeAccumulator"); + assert_eq!(acc.accumulated_rate_at_last_accrual, FIXED_POINT_ONE); + assert_eq!(acc.last_accrued_at, now); + + // Redemption price anchored at the initial value / now. + let rp = stablecoin_core::RedemptionPriceState::try_from( + &state + .get_account_by_id(compute_redemption_price_state_pda(Ids::stablecoin_program())) + .data, + ) + .expect("valid RedemptionPriceState"); + assert_eq!(rp.redemption_price_at_last_update, FIXED_POINT_ONE / 2); + assert_eq!(rp.redemption_rate_per_millisecond, FIXED_POINT_ONE); + assert_eq!(rp.controller_integral_term, 0); + assert_eq!(rp.last_updated_at, now); + + // Stablecoin definition created via the chained Token::NewFungibleDefinition. + let definition = TokenDefinition::try_from( + &state + .get_account_by_id(Ids::stablecoin_definition_pda()) + .data, + ) + .expect("valid TokenDefinition"); + match definition { + TokenDefinition::Fungible { + name, + total_supply, + metadata_id, + authority, + } => { + assert_eq!(name, "test-stable"); + assert_eq!(total_supply, 0); + assert_eq!(metadata_id, None); + // Self/PDA authority: the definition is its own mint authority, so later + // debt operations can mint/burn by presenting the definition PDA seed. + assert_eq!(authority, Some(Ids::stablecoin_definition_pda())); + } + TokenDefinition::NonFungible { .. } => panic!("expected Fungible definition"), + } + + // Empty master holding created alongside the definition. + let master = TokenHolding::try_from( + &state + .get_account_by_id(compute_stablecoin_master_holding_pda( + Ids::stablecoin_program(), + )) + .data, + ) + .expect("valid TokenHolding"); + match master { + TokenHolding::Fungible { + definition_id, + balance, + } => { + assert_eq!(definition_id, Ids::stablecoin_definition_pda()); + assert_eq!(balance, 0); + } + TokenHolding::NftMaster { .. } | TokenHolding::NftPrintedCopy { .. } => { + panic!("expected Fungible holding") + } + } +} diff --git a/programs/stablecoin/methods/guest/src/bin/stablecoin.rs b/programs/stablecoin/methods/guest/src/bin/stablecoin.rs index c9de0bd..12fefef 100644 --- a/programs/stablecoin/methods/guest/src/bin/stablecoin.rs +++ b/programs/stablecoin/methods/guest/src/bin/stablecoin.rs @@ -12,6 +12,71 @@ mod stablecoin { #[allow(unused_imports)] use super::*; + /// Bootstrap the protocol (see spec §10.1 and host-function + /// `stablecoin_program::initialize_program::initialize_program`). + /// + /// Wall-clock time is read from the system `CLOCK_01` account passed as the + /// 9th input — the pinned `ProgramContext` exposes no clock. + /// + /// # Errors + /// Returns the host program's panic-converted error if any precondition + /// fails — see the host fn for the full list. + #[instruction] + #[allow( + clippy::too_many_arguments, + reason = "nine account inputs + the numerical params mirror the host function's ABI" + )] + pub fn initialize_program( + ctx: ProgramContext, + 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, + freeze_authority_account_id: nssa_core::account::AccountId, + initial_stability_fee_per_millisecond: u128, + initial_controller_proportional_gain: i128, + initial_controller_integral_gain: i128, + initial_minimum_collateralization_ratio: u128, + minimum_milliseconds_between_rate_updates: u64, + maximum_oracle_price_age_milliseconds: u64, + initial_redemption_price: u128, + stablecoin_name: String, + ) -> SpelResult { + let (post_states, chained_calls) = + stablecoin_program::initialize_program::initialize_program( + admin, + protocol_parameters, + stability_fee_accumulator, + redemption_price_state, + stablecoin_definition, + stablecoin_master_holding, + collateral_definition, + market_price_oracle, + clock, + ctx.self_program_id, + stablecoin_program::initialize_program::InitializeProgramParams { + freeze_authority_account_id, + initial_stability_fee_per_millisecond, + initial_controller_proportional_gain, + initial_controller_integral_gain, + initial_minimum_collateralization_ratio, + minimum_milliseconds_between_rate_updates, + maximum_oracle_price_age_milliseconds, + initial_redemption_price, + stablecoin_name: &stablecoin_name, + }, + ); + Ok(spel_framework::SpelOutput::execute( + post_states, + chained_calls, + )) + } + /// Open a new collateral-only position for the calling owner. /// /// # Errors