diff --git a/Cargo.lock b/Cargo.lock index a4aec91..d47a580 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -462,9 +462,9 @@ checksum = "7d902e3d592a523def97af8f317b08ce16b7ab854c1985a0c671e6f15cebc236" [[package]] name = "arrayvec" -version = "0.7.7" +version = "0.7.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f02882884d3e1bc524fb12c79f107f6ad0e1cfd498c536ffb494301740995dfe" +checksum = "d3fb67a6e08acf24fdeccbac2cb6ac4305825bd1f117462e0e6f2f193345ad56" [[package]] name = "ata-methods" @@ -3307,9 +3307,9 @@ checksum = "48fd7bd8a6377e15ad9d42a8ec25371b94ddc67abe7c8b9127bec79bebaaae18" [[package]] name = "rustc-hash" -version = "2.1.2" +version = "2.1.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "94300abf3f1ae2e2b8ffb7b58043de3d399c73fa6f4b73826402a5c457614dbe" +checksum = "6b1e7f9a428571be2dc5bc0505c13fb6bf936822b894ec87abf8a08a4e51742d" [[package]] name = "rustc-hex" @@ -3788,9 +3788,12 @@ dependencies = [ name = "stablecoin_program" version = "0.1.0" dependencies = [ + "borsh", + "clock_core", "lee_core", "stablecoin_core", "token_core", + "twap_oracle_core", ] [[package]] diff --git a/artifacts/stablecoin-idl.json b/artifacts/stablecoin-idl.json index 2d57776..ae8a9c6 100644 --- a/artifacts/stablecoin-idl.json +++ b/artifacts/stablecoin-idl.json @@ -2,6 +2,168 @@ "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": "accrue_stability_fee", + "accounts": [ + { + "name": "caller", + "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": "clock", + "writable": false, + "signer": false, + "init": false + } + ], + "args": [] + }, + { + "name": "set_stability_fee_per_millisecond", + "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": "clock", + "writable": false, + "signer": false, + "init": false + } + ], + "args": [ + { + "name": "new_rate", + "type": "u128" + } + ] + }, { "name": "open_position", "accounts": [ @@ -30,7 +192,19 @@ "init": false }, { - "name": "token_definition", + "name": "collateral_definition", + "writable": false, + "signer": false, + "init": false + }, + { + "name": "protocol_parameters", + "writable": false, + "signer": false, + "init": false + }, + { + "name": "clock", "writable": false, "signer": false, "init": false @@ -38,7 +212,76 @@ ], "args": [ { - "name": "collateral_amount", + "name": "position_nonce", + "type": "u64" + }, + { + "name": "initial_collateral_amount", + "type": "u128" + } + ] + }, + { + "name": "generate_debt", + "accounts": [ + { + "name": "owner", + "writable": false, + "signer": false, + "init": false + }, + { + "name": "position", + "writable": false, + "signer": false, + "init": false + }, + { + "name": "stablecoin_definition", + "writable": false, + "signer": false, + "init": false + }, + { + "name": "user_stablecoin_holding", + "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": "market_price_oracle", + "writable": false, + "signer": false, + "init": false + }, + { + "name": "protocol_parameters", + "writable": false, + "signer": false, + "init": false + }, + { + "name": "clock", + "writable": false, + "signer": false, + "init": false + } + ], + "args": [ + { + "name": "amount", "type": "u128" } ] @@ -69,6 +312,30 @@ "writable": true, "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": "protocol_parameters", + "writable": false, + "signer": false, + "init": false + }, + { + "name": "clock", + "writable": false, + "signer": false, + "init": false } ], "args": [ @@ -104,6 +371,24 @@ "writable": true, "signer": true, "init": false + }, + { + "name": "stability_fee_accumulator", + "writable": false, + "signer": false, + "init": false + }, + { + "name": "protocol_parameters", + "writable": false, + "signer": false, + "init": false + }, + { + "name": "clock", + "writable": false, + "signer": false, + "init": false } ], "args": [ @@ -116,25 +401,129 @@ ], "accounts": [ { - "name": "Position", + "name": "ProtocolParameters", "type": { "kind": "struct", "fields": [ { - "name": "collateral_vault_id", + "name": "admin_account_id", + "type": "account_id" + }, + { + "name": "freeze_authority_account_id", + "type": "account_id" + }, + { + "name": "stablecoin_definition_id", "type": "account_id" }, { "name": "collateral_definition_id", "type": "account_id" }, + { + "name": "market_price_oracle_id", + "type": "account_id" + }, + { + "name": "stability_fee_per_millisecond", + "type": "u128" + }, + { + "name": "controller_proportional_gain", + "type": "i128" + }, + { + "name": "controller_integral_gain", + "type": "i128" + }, + { + "name": "minimum_collateralization_ratio", + "type": "u128" + }, + { + "name": "minimum_milliseconds_between_rate_updates", + "type": "u64" + }, + { + "name": "maximum_oracle_price_age_milliseconds", + "type": "u64" + }, + { + "name": "is_frozen", + "type": "bool" + } + ] + } + }, + { + "name": "StabilityFeeAccumulator", + "type": { + "kind": "struct", + "fields": [ + { + "name": "accumulated_rate_at_last_accrual", + "type": "u128" + }, + { + "name": "last_accrued_at", + "type": "u64" + } + ] + } + }, + { + "name": "RedemptionPriceState", + "type": { + "kind": "struct", + "fields": [ + { + "name": "redemption_price_at_last_update", + "type": "u128" + }, + { + "name": "redemption_rate_per_millisecond", + "type": "u128" + }, + { + "name": "controller_integral_term", + "type": "i128" + }, + { + "name": "last_updated_at", + "type": "u64" + } + ] + } + }, + { + "name": "Position", + "type": { + "kind": "struct", + "fields": [ + { + "name": "owner_account_id", + "type": "account_id" + }, + { + "name": "position_nonce", + "type": "u64" + }, + { + "name": "vault_account_id", + "type": "account_id" + }, { "name": "collateral_amount", "type": "u128" }, { - "name": "debt_amount", + "name": "normalized_debt_amount", "type": "u128" + }, + { + "name": "opened_at", + "type": "u64" } ] } diff --git a/programs/integration_tests/tests/stablecoin.rs b/programs/integration_tests/tests/stablecoin.rs index d72ad67..bd20c9e 100644 --- a/programs/integration_tests/tests/stablecoin.rs +++ b/programs/integration_tests/tests/stablecoin.rs @@ -1,10 +1,29 @@ +#![allow( + clippy::panic, + clippy::unwrap_used, + reason = "integration fixtures use fixed balances and transaction assertions" +)] + +use clock_core::{ClockAccountData, CLOCK_01_PROGRAM_ACCOUNT_ID}; use nssa::{ + error::LeeError, 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::{ + compound_rate, 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, ProtocolParameters, RedemptionPriceState, + StabilityFeeAccumulator, FIXED_POINT_ONE, +}; use token_core::{TokenDefinition, TokenHolding}; +use twap_oracle_core::OraclePriceAccount; + +const POSITION_NONCE: u64 = 7; +const CLOCK_START: u64 = 1_000; +const Q64_ONE: u128 = 18_446_744_073_709_551_616; struct Keys; struct Ids; @@ -12,11 +31,15 @@ struct Balances; struct Accounts; impl Keys { + fn admin() -> PrivateKey { + PrivateKey::try_new([40; 32]).expect("valid private key") + } + fn owner() -> PrivateKey { PrivateKey::try_new([41; 32]).expect("valid private key") } - fn user_holding() -> PrivateKey { + fn user_collateral_holding() -> PrivateKey { PrivateKey::try_new([42; 32]).expect("valid private key") } @@ -34,20 +57,54 @@ impl Ids { stablecoin_methods::STABLECOIN_ID } - fn collateral_definition() -> AccountId { - AccountId::new([5; 32]) + fn twap_oracle_program() -> nssa_core::program::ProgramId { + twap_oracle_methods::TWAP_ORACLE_ID + } + + fn admin() -> AccountId { + AccountId::from(&PublicKey::new_from_private_key(&Keys::admin())) + } + + fn freeze_authority() -> AccountId { + AccountId::new([44; 32]) } fn owner() -> AccountId { AccountId::from(&PublicKey::new_from_private_key(&Keys::owner())) } - fn user_holding() -> AccountId { - AccountId::from(&PublicKey::new_from_private_key(&Keys::user_holding())) + fn collateral_definition() -> AccountId { + AccountId::new([5; 32]) + } + + fn user_collateral_holding() -> AccountId { + AccountId::from(&PublicKey::new_from_private_key( + &Keys::user_collateral_holding(), + )) + } + + fn oracle() -> AccountId { + AccountId::new([7; 32]) + } + + fn protocol_parameters() -> AccountId { + compute_protocol_parameters_pda(Self::stablecoin_program()) + } + + fn stability_fee_accumulator() -> AccountId { + compute_stability_fee_accumulator_pda(Self::stablecoin_program()) + } + + fn redemption_price_state() -> AccountId { + compute_redemption_price_state_pda(Self::stablecoin_program()) } fn stablecoin_definition() -> AccountId { - AccountId::new([6; 32]) + compute_stablecoin_definition_pda(Self::stablecoin_program()) + } + + fn stablecoin_master_holding() -> AccountId { + compute_stablecoin_master_holding_pda(Self::stablecoin_program()) } fn user_stablecoin_holding() -> AccountId { @@ -57,11 +114,7 @@ impl Ids { } fn position() -> AccountId { - compute_position_pda( - Self::stablecoin_program(), - Self::owner(), - Self::collateral_definition(), - ) + compute_position_pda(Self::stablecoin_program(), Self::owner(), POSITION_NONCE) } fn vault() -> AccountId { @@ -70,43 +123,31 @@ impl Ids { } impl Balances { - fn user_holding_init() -> u128 { + fn user_collateral_init() -> u128 { 1_000_000 } fn collateral_deposit() -> u128 { - 500_000 + 500 } fn collateral_withdraw() -> u128 { - 200_000 + 400 } - fn stablecoin_supply_init() -> u128 { - 1_000 - } - - fn user_stablecoin_holding_init() -> u128 { - 1_000 - } - - fn initial_debt() -> u128 { - 300 - } - - fn debt_repay_amount() -> u128 { + fn generated_debt() -> u128 { 100 } } impl Accounts { - fn collateral_definition_init() -> Account { + fn collateral_definition() -> Account { Account { program_owner: Ids::token_program(), - balance: 0_u128, + balance: 0, data: Data::from(&TokenDefinition::Fungible { name: String::from("Gold"), - total_supply: Balances::user_holding_init(), + total_supply: Balances::user_collateral_init(), metadata_id: None, authority: None, }), @@ -114,53 +155,41 @@ impl Accounts { } } - fn user_holding_init() -> Account { + fn user_collateral_holding() -> Account { Account { program_owner: Ids::token_program(), - balance: 0_u128, + balance: 0, data: Data::from(&TokenHolding::Fungible { definition_id: Ids::collateral_definition(), - balance: Balances::user_holding_init(), + balance: Balances::user_collateral_init(), }), nonce: Nonce(0), } } - fn stablecoin_definition_init() -> Account { + fn user_stablecoin_holding() -> Account { Account { program_owner: Ids::token_program(), - balance: 0_u128, - data: Data::from(&TokenDefinition::Fungible { - name: String::from("DAI"), - total_supply: Balances::stablecoin_supply_init(), - metadata_id: None, - authority: None, - }), - nonce: Nonce(0), - } - } - - fn user_stablecoin_holding_init() -> Account { - Account { - program_owner: Ids::token_program(), - balance: 0_u128, + balance: 0, data: Data::from(&TokenHolding::Fungible { definition_id: Ids::stablecoin_definition(), - balance: Balances::user_stablecoin_holding_init(), + balance: 0, }), nonce: Nonce(0), } } - fn position_with_debt_init() -> Account { + fn oracle(timestamp: u64) -> Account { Account { - program_owner: stablecoin_methods::STABLECOIN_ID, - balance: 0_u128, - data: Data::from(&Position { - collateral_vault_id: Ids::vault(), - collateral_definition_id: Ids::collateral_definition(), - collateral_amount: Balances::collateral_deposit(), - debt_amount: Balances::initial_debt(), + program_owner: Ids::twap_oracle_program(), + balance: 0, + data: Data::from(&OraclePriceAccount { + base_asset: Ids::stablecoin_definition(), + quote_asset: Ids::collateral_definition(), + price: Q64_ONE, + timestamp, + source_id: AccountId::new([8; 32]), + confidence_interval: 0, }), nonce: Nonce(0), } @@ -190,9 +219,18 @@ fn state_for_stablecoin_tests() -> V03State { deploy_programs(&mut state); state.force_insert_account( Ids::collateral_definition(), - Accounts::collateral_definition_init(), + Accounts::collateral_definition(), ); - state.force_insert_account(Ids::user_holding(), Accounts::user_holding_init()); + state.force_insert_account( + Ids::user_collateral_holding(), + Accounts::user_collateral_holding(), + ); + state.force_insert_account( + Ids::user_stablecoin_holding(), + Accounts::user_stablecoin_holding(), + ); + state.force_insert_account(Ids::oracle(), Accounts::oracle(CLOCK_START)); + advance_clock(&mut state, CLOCK_START); state } @@ -200,61 +238,59 @@ fn current_nonce(state: &V03State, account_id: AccountId) -> Nonce { state.get_account_by_id(account_id).nonce } -fn state_for_stablecoin_repay_tests() -> V03State { - let mut state = V03State::new(); - deploy_programs(&mut state); - state.force_insert_account( - Ids::collateral_definition(), - Accounts::collateral_definition_init(), - ); - state.force_insert_account( - Ids::stablecoin_definition(), - Accounts::stablecoin_definition_init(), - ); - state.force_insert_account(Ids::position(), Accounts::position_with_debt_init()); - state.force_insert_account( - Ids::user_stablecoin_holding(), - Accounts::user_stablecoin_holding_init(), - ); - 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"); - assert_eq!(position.collateral_amount, expected_collateral); - assert_eq!(position.debt_amount, 0); - assert_eq!(position.collateral_vault_id, Ids::vault()); - assert_eq!( - position.collateral_definition_id, - Ids::collateral_definition() - ); -} - -fn assert_fungible_balance(state: &V03State, account_id: AccountId, expected_balance: u128) { - let holding = TokenHolding::try_from(&state.get_account_by_id(account_id).data) - .expect("valid TokenHolding"); - match holding { - TokenHolding::Fungible { - definition_id, - balance, - } => { - assert_eq!(definition_id, Ids::collateral_definition()); - assert_eq!(balance, expected_balance); - } - TokenHolding::NftMaster { .. } | TokenHolding::NftPrintedCopy { .. } => { - panic!("expected Fungible holding") - } +fn advance_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); } -#[test] -fn stablecoin_open_position_then_withdraw_collateral() { - let mut state = state_for_stablecoin_tests(); +fn initialize_protocol(state: &mut V03State, rate: u128) { + let instruction = stablecoin_core::Instruction::InitializeProgram { + freeze_authority_account_id: Ids::freeze_authority(), + initial_stability_fee_per_millisecond: rate, + initial_controller_proportional_gain: 0, + initial_controller_integral_gain: 0, + initial_minimum_collateralization_ratio: FIXED_POINT_ONE / 10 * 11, + minimum_milliseconds_between_rate_updates: 1, + maximum_oracle_price_age_milliseconds: 86_400_000, + initial_redemption_price: FIXED_POINT_ONE, + stablecoin_name: String::from("DAI"), + }; + let message = public_transaction::Message::try_new( + Ids::stablecoin_program(), + vec![ + Ids::admin(), + Ids::protocol_parameters(), + Ids::stability_fee_accumulator(), + Ids::redemption_price_state(), + Ids::stablecoin_definition(), + Ids::stablecoin_master_holding(), + 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, 0, 0) + .expect("initialize_program must succeed"); +} - // Open the position: deposit collateral from the user's holding into a fresh vault. - let open = stablecoin_core::Instruction::OpenPosition { - collateral_amount: Balances::collateral_deposit(), +fn open_position(state: &mut V03State) { + let instruction = stablecoin_core::Instruction::OpenPosition { + position_nonce: POSITION_NONCE, + initial_collateral_amount: Balances::collateral_deposit(), }; let message = public_transaction::Message::try_new( Ids::stablecoin_program(), @@ -262,78 +298,31 @@ fn stablecoin_open_position_then_withdraw_collateral() { Ids::owner(), Ids::position(), Ids::vault(), - Ids::user_holding(), + Ids::user_collateral_holding(), Ids::collateral_definition(), + Ids::protocol_parameters(), + CLOCK_01_PROGRAM_ACCOUNT_ID, ], vec![ - current_nonce(&state, Ids::owner()), - current_nonce(&state, Ids::user_holding()), + current_nonce(state, Ids::owner()), + current_nonce(state, Ids::user_collateral_holding()), ], - open, + instruction, ) .unwrap(); let witness_set = public_transaction::WitnessSet::for_message( &message, - &[&Keys::owner(), &Keys::user_holding()], + &[&Keys::owner(), &Keys::user_collateral_holding()], ); let tx = PublicTransaction::new(message, witness_set); state .transition_from_public_transaction(&tx, 0, 0) .expect("open_position must succeed"); - - assert_position(&state, Balances::collateral_deposit()); - assert_fungible_balance(&state, Ids::vault(), Balances::collateral_deposit()); - assert_fungible_balance( - &state, - Ids::user_holding(), - Balances::user_holding_init() - Balances::collateral_deposit(), - ); - - // Withdraw part of the collateral back to the same user holding. - let withdraw = stablecoin_core::Instruction::WithdrawCollateral { - amount: Balances::collateral_withdraw(), - }; - let message = public_transaction::Message::try_new( - Ids::stablecoin_program(), - vec![ - Ids::owner(), - Ids::position(), - Ids::vault(), - Ids::user_holding(), - ], - vec![current_nonce(&state, Ids::owner())], - withdraw, - ) - .unwrap(); - let witness_set = public_transaction::WitnessSet::for_message(&message, &[&Keys::owner()]); - let tx = PublicTransaction::new(message, witness_set); - state - .transition_from_public_transaction(&tx, 0, 0) - .expect("withdraw_collateral must succeed"); - - assert_position( - &state, - Balances::collateral_deposit() - Balances::collateral_withdraw(), - ); - assert_fungible_balance( - &state, - Ids::vault(), - Balances::collateral_deposit() - Balances::collateral_withdraw(), - ); - assert_fungible_balance( - &state, - Ids::user_holding(), - Balances::user_holding_init() - Balances::collateral_deposit() - + Balances::collateral_withdraw(), - ); } -#[test] -fn stablecoin_repay_debt_burns_stablecoins_and_decreases_debt() { - let mut state = state_for_stablecoin_repay_tests(); - - let repay = stablecoin_core::Instruction::RepayDebt { - amount: Balances::debt_repay_amount(), +fn generate_debt(state: &mut V03State) { + let instruction = stablecoin_core::Instruction::GenerateDebt { + amount: Balances::generated_debt(), }; let message = public_transaction::Message::try_new( Ids::stablecoin_program(), @@ -342,12 +331,87 @@ fn stablecoin_repay_debt_burns_stablecoins_and_decreases_debt() { Ids::position(), Ids::stablecoin_definition(), Ids::user_stablecoin_holding(), + Ids::stability_fee_accumulator(), + Ids::redemption_price_state(), + Ids::oracle(), + Ids::protocol_parameters(), + CLOCK_01_PROGRAM_ACCOUNT_ID, + ], + vec![current_nonce(state, Ids::owner())], + instruction, + ) + .unwrap(); + let witness_set = public_transaction::WitnessSet::for_message(&message, &[&Keys::owner()]); + let tx = PublicTransaction::new(message, witness_set); + state + .transition_from_public_transaction(&tx, 0, 0) + .expect("generate_debt must succeed"); +} + +fn accrue_stability_fee(state: &mut V03State) { + execute_accrue_stability_fee(state).expect("accrue_stability_fee must succeed"); +} + +fn execute_accrue_stability_fee(state: &mut V03State) -> Result<(), LeeError> { + let instruction = stablecoin_core::Instruction::AccrueStabilityFee; + let message = public_transaction::Message::try_new( + Ids::stablecoin_program(), + vec![ + Ids::owner(), + Ids::protocol_parameters(), + Ids::stability_fee_accumulator(), + CLOCK_01_PROGRAM_ACCOUNT_ID, + ], + vec![current_nonce(state, Ids::owner())], + instruction, + ) + .unwrap(); + let witness_set = public_transaction::WitnessSet::for_message(&message, &[&Keys::owner()]); + let tx = PublicTransaction::new(message, witness_set); + state.transition_from_public_transaction(&tx, 0, 0) +} + +fn set_stability_fee_per_millisecond(state: &mut V03State, new_rate: u128) { + let instruction = stablecoin_core::Instruction::SetStabilityFeePerMillisecond { new_rate }; + let message = public_transaction::Message::try_new( + Ids::stablecoin_program(), + vec![ + Ids::admin(), + Ids::protocol_parameters(), + Ids::stability_fee_accumulator(), + 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, 0, 0) + .expect("set_stability_fee_per_millisecond must succeed"); +} + +fn repay_debt(state: &mut V03State) { + let instruction = stablecoin_core::Instruction::RepayDebt { + amount: Balances::generated_debt(), + }; + let message = public_transaction::Message::try_new( + Ids::stablecoin_program(), + vec![ + Ids::owner(), + Ids::position(), + Ids::stablecoin_definition(), + Ids::user_stablecoin_holding(), + Ids::stability_fee_accumulator(), + Ids::protocol_parameters(), + CLOCK_01_PROGRAM_ACCOUNT_ID, ], vec![ - current_nonce(&state, Ids::owner()), - current_nonce(&state, Ids::user_stablecoin_holding()), + current_nonce(state, Ids::owner()), + current_nonce(state, Ids::user_stablecoin_holding()), ], - repay, + instruction, ) .unwrap(); let witness_set = public_transaction::WitnessSet::for_message( @@ -358,43 +422,224 @@ fn stablecoin_repay_debt_burns_stablecoins_and_decreases_debt() { state .transition_from_public_transaction(&tx, 0, 0) .expect("repay_debt must succeed"); +} - // Position debt decreased; collateral untouched. - let position = - Position::try_from(&state.get_account_by_id(Ids::position()).data).expect("valid Position"); - assert_eq!( - position.debt_amount, - Balances::initial_debt() - Balances::debt_repay_amount() - ); - assert_eq!(position.collateral_amount, Balances::collateral_deposit()); +fn withdraw_collateral(state: &mut V03State) { + execute_withdraw_collateral(state, Balances::collateral_withdraw()) + .expect("withdraw_collateral must succeed"); +} - // Stablecoin total supply decreased by the burn amount. - let definition = - TokenDefinition::try_from(&state.get_account_by_id(Ids::stablecoin_definition()).data) - .expect("valid TokenDefinition"); - match definition { - TokenDefinition::Fungible { total_supply, .. } => { - assert_eq!( - total_supply, - Balances::stablecoin_supply_init() - Balances::debt_repay_amount() - ); - } - TokenDefinition::NonFungible { .. } => panic!("expected Fungible definition"), - } +fn execute_withdraw_collateral(state: &mut V03State, amount: u128) -> Result<(), LeeError> { + let instruction = stablecoin_core::Instruction::WithdrawCollateral { amount }; + let message = public_transaction::Message::try_new( + Ids::stablecoin_program(), + vec![ + Ids::owner(), + Ids::position(), + Ids::vault(), + Ids::user_collateral_holding(), + Ids::stability_fee_accumulator(), + Ids::redemption_price_state(), + Ids::protocol_parameters(), + CLOCK_01_PROGRAM_ACCOUNT_ID, + ], + vec![current_nonce(state, Ids::owner())], + instruction, + ) + .unwrap(); + let witness_set = public_transaction::WitnessSet::for_message(&message, &[&Keys::owner()]); + let tx = PublicTransaction::new(message, witness_set); + state.transition_from_public_transaction(&tx, 0, 0) +} - // User stablecoin holding decreased by the burn amount. - let holding = - TokenHolding::try_from(&state.get_account_by_id(Ids::user_stablecoin_holding()).data) - .expect("valid TokenHolding"); - match holding { - TokenHolding::Fungible { balance, .. } => { - assert_eq!( - balance, - Balances::user_stablecoin_holding_init() - Balances::debt_repay_amount() - ); - } +fn position(state: &V03State) -> Position { + Position::try_from(&state.get_account_by_id(Ids::position()).data).expect("valid Position") +} + +fn stability_fee_accumulator(state: &V03State) -> StabilityFeeAccumulator { + StabilityFeeAccumulator::try_from( + &state + .get_account_by_id(Ids::stability_fee_accumulator()) + .data, + ) + .expect("valid StabilityFeeAccumulator") +} + +fn protocol_parameters(state: &V03State) -> ProtocolParameters { + ProtocolParameters::try_from(&state.get_account_by_id(Ids::protocol_parameters()).data) + .expect("valid ProtocolParameters") +} + +fn redemption_price_state(state: &V03State) -> RedemptionPriceState { + RedemptionPriceState::try_from(&state.get_account_by_id(Ids::redemption_price_state()).data) + .expect("valid RedemptionPriceState") +} + +fn fungible_balance(state: &V03State, account_id: AccountId) -> u128 { + match TokenHolding::try_from(&state.get_account_by_id(account_id).data) + .expect("valid TokenHolding") + { + TokenHolding::Fungible { balance, .. } => balance, TokenHolding::NftMaster { .. } | TokenHolding::NftPrintedCopy { .. } => { panic!("expected Fungible holding") } } } + +fn fungible_supply(state: &V03State, account_id: AccountId) -> u128 { + match TokenDefinition::try_from(&state.get_account_by_id(account_id).data) + .expect("valid TokenDefinition") + { + TokenDefinition::Fungible { total_supply, .. } => total_supply, + TokenDefinition::NonFungible { .. } => panic!("expected Fungible definition"), + } +} + +#[test] +fn stablecoin_initialize_then_accrue_fee() { + let mut state = state_for_stablecoin_tests(); + let rate = FIXED_POINT_ONE + FIXED_POINT_ONE / 10; + initialize_protocol(&mut state, rate); + + let params = protocol_parameters(&state); + assert_eq!(params.stability_fee_per_millisecond, rate); + assert_eq!( + params.stablecoin_definition_id, + Ids::stablecoin_definition() + ); + assert_eq!( + params.collateral_definition_id, + Ids::collateral_definition() + ); + assert_eq!(params.market_price_oracle_id, Ids::oracle()); + assert_eq!(fungible_supply(&state, Ids::stablecoin_definition()), 0); + + let accumulator = stability_fee_accumulator(&state); + assert_eq!( + accumulator.accumulated_rate_at_last_accrual, + FIXED_POINT_ONE + ); + assert_eq!(accumulator.last_accrued_at, CLOCK_START); + let redemption = redemption_price_state(&state); + assert_eq!(redemption.redemption_price_at_last_update, FIXED_POINT_ONE); + assert_eq!(redemption.last_updated_at, CLOCK_START); + + advance_clock(&mut state, CLOCK_START + 2); + accrue_stability_fee(&mut state); + + let updated = stability_fee_accumulator(&state); + assert_eq!( + updated.accumulated_rate_at_last_accrual, + compound_rate(rate, 2) + ); + assert_eq!(updated.last_accrued_at, CLOCK_START + 2); +} + +#[test] +fn stablecoin_set_fee_rate_anchors_old_rate_before_switching() { + let mut state = state_for_stablecoin_tests(); + let old_rate = FIXED_POINT_ONE + FIXED_POINT_ONE / 10; + initialize_protocol(&mut state, old_rate); + + advance_clock(&mut state, CLOCK_START + 2); + set_stability_fee_per_millisecond(&mut state, FIXED_POINT_ONE); + + let updated = stability_fee_accumulator(&state); + let old_window_anchor = compound_rate(old_rate, 2); + assert_eq!(updated.accumulated_rate_at_last_accrual, old_window_anchor); + assert_eq!(updated.last_accrued_at, CLOCK_START + 2); + assert_eq!( + protocol_parameters(&state).stability_fee_per_millisecond, + FIXED_POINT_ONE + ); + + advance_clock(&mut state, CLOCK_START + 4); + accrue_stability_fee(&mut state); + let reanchored = stability_fee_accumulator(&state); + assert_eq!( + reanchored.accumulated_rate_at_last_accrual, + old_window_anchor + ); + assert_eq!(reanchored.last_accrued_at, CLOCK_START + 4); +} + +#[test] +fn stablecoin_open_generate_repay_and_withdraw_flow() { + let mut state = state_for_stablecoin_tests(); + initialize_protocol(&mut state, FIXED_POINT_ONE); + + open_position(&mut state); + let opened = position(&state); + assert_eq!(opened.owner_account_id, Ids::owner()); + assert_eq!(opened.position_nonce, POSITION_NONCE); + assert_eq!(opened.vault_account_id, Ids::vault()); + assert_eq!(opened.collateral_amount, Balances::collateral_deposit()); + assert_eq!(opened.normalized_debt_amount, 0); + assert_eq!(opened.opened_at, CLOCK_START); + assert_eq!( + fungible_balance(&state, Ids::user_collateral_holding()), + Balances::user_collateral_init() - Balances::collateral_deposit() + ); + assert_eq!( + fungible_balance(&state, Ids::vault()), + Balances::collateral_deposit() + ); + + generate_debt(&mut state); + advance_clock(&mut state, CLOCK_START + 1); + accrue_stability_fee(&mut state); + let indebted = position(&state); + assert_eq!(indebted.normalized_debt_amount, Balances::generated_debt()); + assert_eq!( + fungible_supply(&state, Ids::stablecoin_definition()), + Balances::generated_debt() + ); + assert_eq!( + fungible_balance(&state, Ids::user_stablecoin_holding()), + Balances::generated_debt() + ); + + repay_debt(&mut state); + let repaid = position(&state); + assert_eq!(repaid.normalized_debt_amount, 0); + assert_eq!(fungible_supply(&state, Ids::stablecoin_definition()), 0); + assert_eq!(fungible_balance(&state, Ids::user_stablecoin_holding()), 0); + + withdraw_collateral(&mut state); + let withdrawn = position(&state); + assert_eq!( + withdrawn.collateral_amount, + Balances::collateral_deposit() - Balances::collateral_withdraw() + ); + assert_eq!( + fungible_balance(&state, Ids::vault()), + Balances::collateral_deposit() - Balances::collateral_withdraw() + ); + assert_eq!( + fungible_balance(&state, Ids::user_collateral_holding()), + Balances::user_collateral_init() - Balances::collateral_deposit() + + Balances::collateral_withdraw() + ); +} + +#[test] +fn stablecoin_debt_bearing_withdraw_checks_collateralization() { + let mut state = state_for_stablecoin_tests(); + initialize_protocol(&mut state, FIXED_POINT_ONE); + open_position(&mut state); + generate_debt(&mut state); + + assert!(matches!( + execute_withdraw_collateral(&mut state, 400), + Err(LeeError::ProgramExecutionFailed(_)) + )); + assert_eq!( + position(&state).collateral_amount, + Balances::collateral_deposit() + ); + + execute_withdraw_collateral(&mut state, 300) + .expect("safe debt-bearing withdrawal must succeed"); + assert_eq!(position(&state).collateral_amount, 200); + assert_eq!(fungible_balance(&state, Ids::vault()), 200); +} diff --git a/programs/stablecoin/Cargo.toml b/programs/stablecoin/Cargo.toml index 89ea243..9627033 100644 --- a/programs/stablecoin/Cargo.toml +++ b/programs/stablecoin/Cargo.toml @@ -5,5 +5,8 @@ edition = "2021" [dependencies] nssa_core = { git = "https://github.com/logos-blockchain/logos-execution-zone.git", tag = "v0.2.0-rc6", features = ["host"], package = "lee_core" } +clock_core = { git = "https://github.com/logos-blockchain/logos-execution-zone.git", tag = "v0.2.0-rc6" } +borsh = { version = "1.5", features = ["derive"] } 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 d030564..268699c 100644 --- a/programs/stablecoin/core/src/lib.rs +++ b/programs/stablecoin/core/src/lib.rs @@ -2,7 +2,9 @@ pub mod math; +use alloy_primitives::U256; use borsh::{BorshDeserialize, BorshSerialize}; +pub use math::{compound_rate, mul_div as mul_div_floor, mul_div_ceil, FIXED_POINT_ONE}; use nssa_core::{ account::{AccountId, AccountWithMetadata, Data}, program::{PdaSeed, ProgramId}, @@ -10,93 +12,155 @@ use nssa_core::{ 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. +/// Maximum elapsed time rolled into one compounding projection. +pub const MAXIMUM_COMPOUNDING_WINDOW_MILLISECONDS: u64 = 86_400_000; + +/// Maximum accepted stability-fee multiplier per millisecond. +pub const MAX_STABILITY_FEE_PER_MILLISECOND: u128 = + FIXED_POINT_ONE + FIXED_POINT_ONE / 100_000_000_000; + +/// Minimum accepted collateralization ratio: 110%. +pub const MINIMUM_COLLATERALIZATION_RATIO_LOWER_BOUND: u128 = FIXED_POINT_ONE / 10 * 11; + +/// Maximum accepted collateralization ratio: 1000%. +pub const MINIMUM_COLLATERALIZATION_RATIO_UPPER_BOUND: u128 = FIXED_POINT_ONE * 10; + +/// Maximum accepted proportional gain magnitude. +pub const MAX_CONTROLLER_PROPORTIONAL_GAIN: u128 = FIXED_POINT_ONE * 1_000; + +/// Maximum accepted integral gain magnitude. +pub const MAX_CONTROLLER_INTEGRAL_GAIN: u128 = FIXED_POINT_ONE; + +/// Maximum accepted timing parameter in milliseconds. +pub const MAX_TIMING_PARAMETER_MILLISECONDS: u64 = 86_400_000; + +const PROTOCOL_PARAMETERS_PDA_DOMAIN: &[u8] = b"PROTOCOL_PARAMETERS"; +const STABILITY_FEE_ACCUMULATOR_PDA_DOMAIN: &[u8] = b"STABILITY_FEE_ACCUMULATOR"; +const REDEMPTION_PRICE_STATE_PDA_DOMAIN: &[u8] = b"REDEMPTION_PRICE_STATE"; +const STABLECOIN_DEFINITION_PDA_DOMAIN: &[u8] = b"STABLECOIN_DEFINITION"; +const STABLECOIN_MASTER_HOLDING_PDA_DOMAIN: &[u8] = b"STABLECOIN_MASTER_HOLDING"; const POSITION_PDA_DOMAIN: &[u8] = b"POSITION"; const POSITION_VAULT_PDA_DOMAIN: &[u8] = b"POSITION_VAULT"; /// Stablecoin Program Instruction. #[derive(Debug, Serialize, Deserialize)] pub enum Instruction { + /// Initialize protocol globals and stablecoin token definition. + InitializeProgram { + freeze_authority_account_id: 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, + }, + /// Permissionlessly roll the global stability-fee accumulator forward. + AccrueStabilityFee, + /// Update the stability-fee rate after accruing pending fees at the old rate. + SetStabilityFeePerMillisecond { new_rate: u128 }, /// 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 { + /// User-chosen nonce that disambiguates multiple positions. + position_nonce: u64, /// Amount of collateral tokens to deposit into the position vault. - collateral_amount: u128, + initial_collateral_amount: u128, + }, + /// Mint stablecoin debt against an existing position. + GenerateDebt { + /// Amount of stablecoin to mint. + 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 of stablecoin debt to repay and burn from the user's holding. amount: u128, }, } +/// Protocol-level configuration account. +#[account_type] +#[derive(Debug, PartialEq, Eq, Clone, Serialize, Deserialize, BorshSerialize, BorshDeserialize)] +pub struct ProtocolParameters { + pub admin_account_id: AccountId, + pub freeze_authority_account_id: AccountId, + pub stablecoin_definition_id: AccountId, + pub collateral_definition_id: AccountId, + pub market_price_oracle_id: AccountId, + pub stability_fee_per_millisecond: u128, + pub controller_proportional_gain: i128, + pub controller_integral_gain: i128, + pub minimum_collateralization_ratio: u128, + pub minimum_milliseconds_between_rate_updates: u64, + pub maximum_oracle_price_age_milliseconds: u64, + pub is_frozen: bool, +} + +/// Global stability-fee accumulator. +#[account_type] +#[derive(Debug, PartialEq, Eq, Clone, Serialize, Deserialize, BorshSerialize, BorshDeserialize)] +pub struct StabilityFeeAccumulator { + pub accumulated_rate_at_last_accrual: u128, + pub last_accrued_at: u64, +} + +/// Redemption-price state used by collateralization checks. +#[account_type] +#[derive(Debug, PartialEq, Eq, Clone, Serialize, Deserialize, BorshSerialize, BorshDeserialize)] +pub struct RedemptionPriceState { + pub redemption_price_at_last_update: u128, + pub redemption_rate_per_millisecond: u128, + pub controller_integral_term: i128, + pub last_updated_at: 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, + /// Owner authorized for every position operation. + pub owner_account_id: AccountId, + /// User-chosen nonce. Together with owner, it derives the position PDA. + pub position_nonce: u64, + /// Token holding account that custodies collateral for this position. + pub vault_account_id: AccountId, /// Amount of collateral tokens deposited. pub collateral_amount: u128, - /// Outstanding stablecoin debt against this position. - pub debt_amount: u128, + /// Debt shares. Nominal debt is derived from this and the current accumulator. + pub normalized_debt_amount: u128, + /// Unix timestamp in milliseconds when the position was opened. + pub opened_at: u64, +} + +impl TryFrom<&Data> for ProtocolParameters { + type Error = std::io::Error; + + fn try_from(data: &Data) -> Result { + Self::try_from_slice(data.as_ref()) + } +} + +impl TryFrom<&Data> for StabilityFeeAccumulator { + type Error = std::io::Error; + + fn try_from(data: &Data) -> Result { + Self::try_from_slice(data.as_ref()) + } +} + +impl TryFrom<&Data> for RedemptionPriceState { + type Error = std::io::Error; + + fn try_from(data: &Data) -> Result { + Self::try_from_slice(data.as_ref()) + } } impl TryFrom<&Data> for Position { @@ -107,64 +171,158 @@ impl TryFrom<&Data> for Position { } } -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 From<&ProtocolParameters> for Data { + fn from(params: &ProtocolParameters) -> Self { + serialize_to_data(params) } } -/// 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 { +impl From<&StabilityFeeAccumulator> for Data { + fn from(accumulator: &StabilityFeeAccumulator) -> Self { + serialize_to_data(accumulator) + } +} + +impl From<&RedemptionPriceState> for Data { + fn from(state: &RedemptionPriceState) -> Self { + serialize_to_data(state) + } +} + +impl From<&Position> for Data { + fn from(position: &Position) -> Self { + serialize_to_data(position) + } +} + +fn serialize_to_data(value: &T) -> Data { + let bytes = borsh::to_vec(value).expect("Serialization to Vec should not fail"); + Data::try_from(bytes).expect("Encoded account data should fit into Data") +} + +fn hash_seed(parts: &[&[u8]]) -> 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 total_len = parts + .iter() + .try_fold(0usize, |acc, part| acc.checked_add(part.len())) + .expect("PDA seed length should fit in usize"); + let mut bytes = Vec::with_capacity(total_len); + for part in parts { + bytes.extend_from_slice(part); + } let mut out = [0u8; 32]; out.copy_from_slice(Impl::hash_bytes(&bytes).as_bytes()); PdaSeed::new(out) } +/// PDA seed for the protocol parameters singleton. +#[must_use] +pub fn compute_protocol_parameters_pda_seed() -> PdaSeed { + hash_seed(&[PROTOCOL_PARAMETERS_PDA_DOMAIN]) +} + +/// Account id of the protocol parameters singleton. +#[must_use] +pub fn compute_protocol_parameters_pda(stablecoin_program_id: ProgramId) -> AccountId { + AccountId::for_public_pda( + &stablecoin_program_id, + &compute_protocol_parameters_pda_seed(), + ) +} + +/// PDA seed for the stability-fee accumulator singleton. +#[must_use] +pub fn compute_stability_fee_accumulator_pda_seed() -> PdaSeed { + hash_seed(&[STABILITY_FEE_ACCUMULATOR_PDA_DOMAIN]) +} + +/// Account id of the stability-fee accumulator singleton. +#[must_use] +pub fn compute_stability_fee_accumulator_pda(stablecoin_program_id: ProgramId) -> AccountId { + AccountId::for_public_pda( + &stablecoin_program_id, + &compute_stability_fee_accumulator_pda_seed(), + ) +} + +/// PDA seed for the redemption-price state singleton. +#[must_use] +pub fn compute_redemption_price_state_pda_seed() -> PdaSeed { + hash_seed(&[REDEMPTION_PRICE_STATE_PDA_DOMAIN]) +} + +/// Account id of the redemption-price state singleton. +#[must_use] +pub fn compute_redemption_price_state_pda(stablecoin_program_id: ProgramId) -> AccountId { + AccountId::for_public_pda( + &stablecoin_program_id, + &compute_redemption_price_state_pda_seed(), + ) +} + +/// PDA seed for the stablecoin token definition. +#[must_use] +pub fn compute_stablecoin_definition_pda_seed() -> PdaSeed { + hash_seed(&[STABLECOIN_DEFINITION_PDA_DOMAIN]) +} + +/// Account id of the stablecoin token definition. +#[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 stablecoin token definition's paired master holding. +#[must_use] +pub fn compute_stablecoin_master_holding_pda_seed() -> PdaSeed { + hash_seed(&[STABLECOIN_MASTER_HOLDING_PDA_DOMAIN]) +} + +/// Account id of the stablecoin token definition's paired master holding. +#[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(), + ) +} + +/// PDA seed for a [`Position`] account owned by `owner_id` with `position_nonce`. +#[must_use] +pub fn compute_position_pda_seed(owner_id: AccountId, position_nonce: u64) -> PdaSeed { + hash_seed(&[ + &owner_id.to_bytes(), + &position_nonce.to_le_bytes(), + POSITION_PDA_DOMAIN, + ]) +} + /// Account id of the [`Position`] PDA owned by `owner_id` under `stablecoin_program_id`. +#[must_use] pub fn compute_position_pda( stablecoin_program_id: ProgramId, owner_id: AccountId, - collateral_definition_id: AccountId, + position_nonce: u64, ) -> AccountId { AccountId::for_public_pda( &stablecoin_program_id, - &compute_position_pda_seed(owner_id, collateral_definition_id), + &compute_position_pda_seed(owner_id, position_nonce), ) } /// 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. +#[must_use] 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) + hash_seed(&[&position_id.to_bytes(), POSITION_VAULT_PDA_DOMAIN]) } /// Account id of the collateral vault PDA for `position_id` under `stablecoin_program_id`. +#[must_use] pub fn compute_position_vault_pda( stablecoin_program_id: ProgramId, position_id: AccountId, @@ -175,20 +333,18 @@ pub fn compute_position_vault_pda( ) } -/// Verify the position account's address matches -/// `(stablecoin_program_id, owner, collateral_definition_id)` and return the [`PdaSeed`] for -/// use in post-state claims. +/// Verify a position account's address and return the PDA seed for post-state claims. /// /// # Panics -/// If `position.account_id` does not match the address derived from `owner`, -/// `collateral_definition_id`, and `stablecoin_program_id`. +/// If `position.account_id` does not match the expected PDA. +#[must_use] pub fn verify_position_and_get_seed( position: &AccountWithMetadata, owner: &AccountWithMetadata, - collateral_definition_id: AccountId, + position_nonce: u64, stablecoin_program_id: ProgramId, ) -> PdaSeed { - let seed = compute_position_pda_seed(owner.account_id, collateral_definition_id); + let seed = compute_position_pda_seed(owner.account_id, position_nonce); let expected_id = AccountId::for_public_pda(&stablecoin_program_id, &seed); assert_eq!( position.account_id, expected_id, @@ -197,12 +353,11 @@ pub fn verify_position_and_get_seed( seed } -/// Verify the vault account's address matches `(stablecoin_program_id, position)` and -/// return the [`PdaSeed`] for use in chained calls. +/// Verify a vault account's address and return the PDA seed for chained calls. /// /// # Panics -/// If `vault.account_id` does not match the address derived from `position_id` and -/// `stablecoin_program_id`. +/// If `vault.account_id` does not match the expected PDA. +#[must_use] pub fn verify_position_vault_and_get_seed( vault: &AccountWithMetadata, position_id: AccountId, @@ -216,3 +371,237 @@ pub fn verify_position_vault_and_get_seed( ); seed } + +/// Projects the current global stability-fee accumulator. +/// +/// # Panics +/// Panics if fixed-point multiplication overflows. +#[must_use] +pub fn current_accumulated_rate( + state: &StabilityFeeAccumulator, + params: &ProtocolParameters, + now: u64, +) -> u128 { + let elapsed = now + .saturating_sub(state.last_accrued_at) + .min(MAXIMUM_COMPOUNDING_WINDOW_MILLISECONDS); + let factor = compound_rate(params.stability_fee_per_millisecond, elapsed); + mul_div_floor( + state.accumulated_rate_at_last_accrual, + factor, + FIXED_POINT_ONE, + ) +} + +/// Projects the current redemption price. +/// +/// # Panics +/// Panics if fixed-point multiplication overflows. +#[must_use] +pub fn current_redemption_price(state: &RedemptionPriceState, now: u64) -> u128 { + let elapsed = now + .saturating_sub(state.last_updated_at) + .min(MAXIMUM_COMPOUNDING_WINDOW_MILLISECONDS); + let factor = compound_rate(state.redemption_rate_per_millisecond, elapsed); + mul_div_floor( + state.redemption_price_at_last_update, + factor, + FIXED_POINT_ONE, + ) +} + +/// Computes nominal stablecoin debt from normalized position debt. +/// +/// # Panics +/// Panics if fixed-point multiplication overflows. +#[must_use] +pub fn nominal_debt(normalized_debt_amount: u128, current_accumulator: u128) -> u128 { + mul_div_floor(normalized_debt_amount, current_accumulator, FIXED_POINT_ONE) +} + +/// Checks the README collateralization inequality using 256-bit intermediates. +/// +/// # Panics +/// Panics if an intermediate exceeds 256 bits. +#[must_use] +pub fn is_collateralized( + collateral_amount: u128, + normalized_debt_amount: u128, + current_accumulator: u128, + redemption_price: u128, + minimum_collateralization_ratio: u128, +) -> bool { + let nominal_debt = nominal_debt(normalized_debt_amount, current_accumulator); + if nominal_debt == 0 { + return true; + } + + let fixed_point = U256::from(FIXED_POINT_ONE); + let left = U256::from(collateral_amount) + .checked_mul(fixed_point) + .and_then(|value| value.checked_mul(fixed_point)) + .expect("collateral side should fit in U256"); + let right = U256::from(nominal_debt) + .checked_mul(U256::from(redemption_price)) + .and_then(|value| value.checked_mul(U256::from(minimum_collateralization_ratio))) + .expect("debt side should fit in U256"); + + left >= right +} + +/// Validates protocol parameter bounds from the stablecoin README. +/// +/// # Panics +/// Panics if any parameter is outside the accepted range. +pub fn assert_protocol_parameter_bounds( + stability_fee_per_millisecond: u128, + controller_proportional_gain: i128, + controller_integral_gain: i128, + minimum_collateralization_ratio: u128, + minimum_milliseconds_between_rate_updates: u64, + maximum_oracle_price_age_milliseconds: u64, + initial_redemption_price: u128, +) { + assert_valid_stability_fee_per_millisecond(stability_fee_per_millisecond); + assert!( + controller_proportional_gain.unsigned_abs() <= MAX_CONTROLLER_PROPORTIONAL_GAIN, + "Controller proportional gain is out of bounds" + ); + assert!( + controller_integral_gain.unsigned_abs() <= MAX_CONTROLLER_INTEGRAL_GAIN, + "Controller integral gain is out of bounds" + ); + assert!( + (MINIMUM_COLLATERALIZATION_RATIO_LOWER_BOUND..=MINIMUM_COLLATERALIZATION_RATIO_UPPER_BOUND) + .contains(&minimum_collateralization_ratio), + "Minimum collateralization ratio is out of bounds" + ); + assert!( + (1..=MAX_TIMING_PARAMETER_MILLISECONDS) + .contains(&minimum_milliseconds_between_rate_updates), + "Minimum milliseconds between rate updates is out of bounds" + ); + assert!( + (1..=MAX_TIMING_PARAMETER_MILLISECONDS).contains(&maximum_oracle_price_age_milliseconds), + "Maximum oracle price age is out of bounds" + ); + assert!( + initial_redemption_price != 0, + "Initial redemption price must be non-zero" + ); +} + +/// Validates the stability-fee multiplier bound. +/// +/// # Panics +/// Panics if `rate` is outside the accepted range. +pub fn assert_valid_stability_fee_per_millisecond(rate: u128) { + assert!( + (FIXED_POINT_ONE..=MAX_STABILITY_FEE_PER_MILLISECOND).contains(&rate), + "Stability fee per millisecond is out of bounds" + ); +} + +#[cfg(test)] +mod tests { + use super::*; + + const PROGRAM_ID: ProgramId = [1u32; 8]; + + fn params(rate: u128) -> ProtocolParameters { + ProtocolParameters { + admin_account_id: AccountId::new([1u8; 32]), + freeze_authority_account_id: AccountId::new([2u8; 32]), + stablecoin_definition_id: AccountId::new([3u8; 32]), + collateral_definition_id: AccountId::new([4u8; 32]), + market_price_oracle_id: AccountId::new([5u8; 32]), + stability_fee_per_millisecond: rate, + controller_proportional_gain: 0, + controller_integral_gain: 0, + minimum_collateralization_ratio: MINIMUM_COLLATERALIZATION_RATIO_LOWER_BOUND, + minimum_milliseconds_between_rate_updates: 1, + maximum_oracle_price_age_milliseconds: 1, + is_frozen: false, + } + } + + #[test] + fn compound_rate_returns_identity_for_zero_elapsed_time() { + assert_eq!( + compound_rate(FIXED_POINT_ONE + FIXED_POINT_ONE / 10, 0), + FIXED_POINT_ONE + ); + } + + #[test] + fn compound_rate_keeps_one_rate_at_identity() { + assert_eq!(compound_rate(FIXED_POINT_ONE, 123), FIXED_POINT_ONE); + } + + #[test] + fn compound_rate_compounds_small_growth() { + let rate = FIXED_POINT_ONE + FIXED_POINT_ONE / 10; + + assert_eq!( + compound_rate(rate, 2), + FIXED_POINT_ONE + FIXED_POINT_ONE / 5 + FIXED_POINT_ONE / 100 + ); + } + + #[test] + fn mul_div_floor_and_ceil_split_remainders() { + assert_eq!(mul_div_floor(10, 10, 6), 16); + assert_eq!(mul_div_ceil(10, 10, 6), 17); + } + + #[test] + fn current_accumulated_rate_clamps_elapsed_time() { + let rate = FIXED_POINT_ONE + 1; + let state = StabilityFeeAccumulator { + accumulated_rate_at_last_accrual: FIXED_POINT_ONE, + last_accrued_at: 0, + }; + + assert_eq!( + current_accumulated_rate( + &state, + ¶ms(rate), + MAXIMUM_COMPOUNDING_WINDOW_MILLISECONDS + 1 + ), + compound_rate(rate, MAXIMUM_COMPOUNDING_WINDOW_MILLISECONDS) + ); + } + + #[test] + fn max_stability_fee_compounds_within_the_clamped_window() { + let factor = compound_rate( + MAX_STABILITY_FEE_PER_MILLISECOND, + MAXIMUM_COMPOUNDING_WINDOW_MILLISECONDS, + ); + + assert!(factor >= FIXED_POINT_ONE); + } + + #[test] + #[should_panic(expected = "Stability fee per millisecond is out of bounds")] + fn stability_fee_bound_rejects_rate_above_safe_maximum() { + assert_valid_stability_fee_per_millisecond(MAX_STABILITY_FEE_PER_MILLISECOND + 1); + } + + #[test] + fn nominal_debt_grows_with_accumulator() { + assert_eq!( + nominal_debt(100, FIXED_POINT_ONE + FIXED_POINT_ONE / 10), + 110 + ); + } + + #[test] + fn pda_helpers_keep_position_and_vault_derivations_distinct() { + let owner = AccountId::new([9u8; 32]); + let position = compute_position_pda(PROGRAM_ID, owner, 7); + let vault = compute_position_vault_pda(PROGRAM_ID, position); + + assert_ne!(position, vault); + } +} diff --git a/programs/stablecoin/methods/guest/Cargo.lock b/programs/stablecoin/methods/guest/Cargo.lock index 81ba492..7919756 100644 --- a/programs/stablecoin/methods/guest/Cargo.lock +++ b/programs/stablecoin/methods/guest/Cargo.lock @@ -31,7 +31,7 @@ dependencies = [ "cfg-if", "const-hex", "derive_more", - "foldhash 0.2.0", + "foldhash", "hashbrown 0.17.1", "indexmap 2.14.0", "itoa", @@ -69,9 +69,9 @@ dependencies = [ [[package]] name = "anyhow" -version = "1.0.102" +version = "1.0.103" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" +checksum = "2a4385e2e34eb35d6b3efe798b9eb88096925d87726c0798709bf56d9ed84af3" [[package]] name = "ark-bn254" @@ -115,7 +115,7 @@ checksum = "e7e89fe77d1f0f4fe5b96dfc940923d88d17b6a773808124f21e764dfb063c6a" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.118", ] [[package]] @@ -224,7 +224,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "62945a2f7e6de02a31fe400aa489f0e0f5b2502e69f95f853adb82a96c7a6b60" dependencies = [ "quote", - "syn 2.0.117", + "syn 2.0.118", ] [[package]] @@ -262,7 +262,7 @@ dependencies = [ "num-traits", "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.118", ] [[package]] @@ -366,7 +366,7 @@ checksum = "213888f660fddcca0d257e88e54ac05bca01885f258ccdf695bafd77031bb69d" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.118", ] [[package]] @@ -413,9 +413,9 @@ dependencies = [ [[package]] name = "arrayvec" -version = "0.7.6" +version = "0.7.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7c02d123df017efcdfbd739ef81735b36c5ba83ec3c59c80a9d7ecc718f92e50" +checksum = "d3fb67a6e08acf24fdeccbac2cb6ac4305825bd1f117462e0e6f2f193345ad56" [[package]] name = "auto_impl" @@ -425,7 +425,7 @@ checksum = "ffdcb70bdbc4d478427380519163274ac86e52916e10f0a8889adf0f96d3fee7" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.118", ] [[package]] @@ -474,19 +474,40 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5e764a1d40d510daf35e07be9eb06e75770908c27d411ee6c92109c9840eaaf7" [[package]] -name = "bitcoin-io" -version = "0.1.100" +name = "bitcoin-consensus-encoding" +version = "1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "11301df0b06f22dea7bb1916403fdd88a371031e495c49b8f96931b28189e175" +checksum = "b2d6094e2a1ba3c93b5a596fe5a10d1a10c3c6e06785cde89f693a044c01aa40" +dependencies = [ + "bitcoin-internals", +] + +[[package]] +name = "bitcoin-internals" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a30a22d1f112dde8e16be7b45c63645dc165cef254f835b3e1e9553e485cfa64" +dependencies = [ + "hex-conservative 0.3.2", +] + +[[package]] +name = "bitcoin-io" +version = "0.1.101" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb5de036369d1ac59d3c1819ebc4d850f89466f5401c571a285b6ed564a4cb78" +dependencies = [ + "bitcoin-consensus-encoding", +] [[package]] name = "bitcoin_hashes" -version = "0.14.100" +version = "0.14.101" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0c9901a56e133a1fc86eeb1113e2591f45f4682451ca893bff494d2f88918e3f" +checksum = "bca4c7abb40c8817d77403c880988cfd484f23ab2365726afb2f798363e2c4a2" dependencies = [ "bitcoin-io", - "hex-conservative", + "hex-conservative 0.2.2", ] [[package]] @@ -497,15 +518,15 @@ checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" [[package]] name = "bitflags" -version = "2.11.1" +version = "2.13.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c4512299f36f043ab09a583e57bceb5a5aab7a73db1805848e8fef3c9e8c78b3" +checksum = "b4388bee8683e3d04af747c73422af53102d2bd24d9eadb6cbc100baef4b43f8" [[package]] name = "bitvec" -version = "1.0.1" +version = "1.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1bc2832c24239b0141d5674bb9174f9d68a8b5b3f2753311927c172ca46f7e9c" +checksum = "ddcec3d12c579d40898fe0a9a358a803c23e9c52ca3c425707f81c9436211837" dependencies = [ "funty", "radium", @@ -539,18 +560,18 @@ dependencies = [ [[package]] name = "block-buffer" -version = "0.12.0" +version = "0.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cdd35008169921d80bc60d3d0ab416eecb028c4cd653352907921d95084790be" +checksum = "d2f6c7dbe95a6ed67ad9f18e57daf93a2f034c524b99fd2b76d18fdfeb6660aa" dependencies = [ "hybrid-array", ] [[package]] name = "borsh" -version = "1.6.1" +version = "1.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cfd1e3f8955a5d7de9fab72fc8373fade9fb8a703968cb200ae3dc6cf08e185a" +checksum = "2f3f6da4992df95bbcd9af42a6c7dcb994498fc9048230405f3b36ff7cd3f145" dependencies = [ "borsh-derive", "bytes", @@ -559,15 +580,15 @@ dependencies = [ [[package]] name = "borsh-derive" -version = "1.6.1" +version = "1.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bfcfdc083699101d5a7965e49925975f2f55060f94f9a05e7187be95d530ca59" +checksum = "3ae8fb4fb5740e4b2c4884ff95f5f32f5e8479db1e8fd8eb49ddbe09eb09bb7c" dependencies = [ "once_cell", "proc-macro-crate", "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.118", ] [[package]] @@ -608,7 +629,7 @@ checksum = "f9abbd1bc6865053c427f7198e6af43bfdedc55ab791faed4fbd361d789575ff" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.118", ] [[package]] @@ -619,24 +640,24 @@ checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" [[package]] name = "bytes" -version = "1.11.1" +version = "1.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33" +checksum = "8ae3f5d315924270530207e2a68396c3cc547f6dca3fbdca317cfb1a51edb593" [[package]] name = "bytesize" -version = "2.3.1" +version = "2.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6bd91ee7b2422bcb158d90ef4d14f75ef67f340943fc4149891dcce8f8b972a3" +checksum = "49e78e506b9d7633710dab98996f22f95f3d0f488e8f1aa162830556ed9fc14d" dependencies = [ "serde_core", ] [[package]] name = "cc" -version = "1.2.62" +version = "1.2.65" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a1dce859f0832a7d088c4f1119888ab94ef4b5d6795d1ce05afb7fe159d79f98" +checksum = "e228eec9be7c17ccb640b59b36a5cd805ea2a564a4c5e162c2f659fea30d3b96" dependencies = [ "find-msvc-tools", "shlex", @@ -656,9 +677,9 @@ checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" [[package]] name = "chacha20" -version = "0.10.0" +version = "0.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6f8d983286843e49675a4b7a2d174efe136dc93a18d69130dd18198a6c167601" +checksum = "d524456ba66e72eb8b115ff89e01e497f8e6d11d78b70b1aa13c0fbd97540a81" dependencies = [ "cfg-if", "cipher", @@ -667,9 +688,9 @@ dependencies = [ [[package]] name = "chrono" -version = "0.4.44" +version = "0.4.45" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c673075a2e0e5f4a1dde27ce9dee1ea4558c7ffe648f576438a20ca1d2acc4b0" +checksum = "1aa79e62e7697b8e29b513a68abacf485adcd1fe8284a4316c5ae868e6633327" dependencies = [ "iana-time-zone", "num-traits", @@ -683,11 +704,20 @@ version = "0.5.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e8cf2a2c93cd704877c0858356ed03480ff301ee950b43f1cbe4573b088bfa6c" dependencies = [ - "block-buffer 0.12.0", + "block-buffer 0.12.1", "crypto-common 0.2.2", "inout", ] +[[package]] +name = "clock_core" +version = "0.1.0" +source = "git+https://github.com/logos-blockchain/logos-execution-zone.git?tag=v0.2.0-rc6#e37876a64028a335eb693198a1ed6a0e875ec5b4" +dependencies = [ + "borsh", + "lee_core", +] + [[package]] name = "cmov" version = "0.5.4" @@ -822,9 +852,9 @@ dependencies = [ [[package]] name = "crypto-common" -version = "0.1.6" +version = "0.1.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1bfb12502f3fc46cca1bb51ac28df9d618d813cdc3d2f25b9fe775a34af26bb3" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" dependencies = [ "generic-array", "typenum", @@ -836,7 +866,7 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ce6e4c961d6cd6c9a86db418387425e8bdeaf05b3c8bc1411e6dca4c252f1453" dependencies = [ - "getrandom 0.4.2", + "getrandom 0.4.3", "hybrid-array", "rand_core 0.10.1", ] @@ -870,7 +900,7 @@ dependencies = [ "proc-macro2", "quote", "strsim", - "syn 2.0.117", + "syn 2.0.118", ] [[package]] @@ -881,7 +911,7 @@ checksum = "ac3984ec7bd6cfa798e62b4a642426a5be0e68f9401cfc2a01e3fa9ea2fcdb8d" dependencies = [ "darling_core", "quote", - "syn 2.0.117", + "syn 2.0.118", ] [[package]] @@ -910,7 +940,6 @@ version = "0.5.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c" dependencies = [ - "powerfmt", "serde_core", ] @@ -944,7 +973,7 @@ dependencies = [ "proc-macro2", "quote", "rustc_version 0.4.1", - "syn 2.0.117", + "syn 2.0.118", "unicode-xid", ] @@ -965,7 +994,7 @@ checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" dependencies = [ "block-buffer 0.10.4", "const-oid 0.9.6", - "crypto-common 0.1.6", + "crypto-common 0.1.7", "subtle", ] @@ -975,7 +1004,7 @@ version = "0.11.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f1dd6dbb5841937940781866fa1281a1ff7bd3bf827091440879f9994983d5c2" dependencies = [ - "block-buffer 0.12.0", + "block-buffer 0.12.1", "crypto-common 0.2.2", ] @@ -1014,7 +1043,7 @@ dependencies = [ "enum-ordinalize", "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.118", ] [[package]] @@ -1062,22 +1091,22 @@ checksum = "edd0f118536f44f5ccd48bcb8b111bdc3de888b58c74639dfb034a357d0f206d" [[package]] name = "enum-ordinalize" -version = "4.3.2" +version = "4.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4a1091a7bb1f8f2c4b28f1fe2cef4980ca2d410a3d727d67ecc3178c9b0800f0" +checksum = "07f808d588c10e464ea6f7d3eaed500049eff30aaac103460f61828c2d65b3eb" dependencies = [ "enum-ordinalize-derive", ] [[package]] name = "enum-ordinalize-derive" -version = "4.3.2" +version = "4.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8ca9601fb2d62598ee17836250842873a413586e5d7ed88b356e38ddbb0ec631" +checksum = "42e528e2d34ba8a67a1a650b86beae8ef69fc5fdb638016f386b973226590432" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.118", ] [[package]] @@ -1168,12 +1197,6 @@ version = "1.0.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" -[[package]] -name = "foldhash" -version = "0.1.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" - [[package]] name = "foldhash" version = "0.2.0" @@ -1198,7 +1221,7 @@ checksum = "1a5c6c585bc94aaf2c7b51dd4c2ba22680844aba4c687be581871a6f518c5742" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.118", ] [[package]] @@ -1239,9 +1262,9 @@ dependencies = [ [[package]] name = "generic-array" -version = "0.14.9" +version = "0.14.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4bb6743198531e02858aeaea5398fcc883e71851fcbcb5a2f773e2fb6cb1edf2" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" dependencies = [ "typenum", "version_check", @@ -1273,16 +1296,14 @@ dependencies = [ [[package]] name = "getrandom" -version = "0.4.2" +version = "0.4.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0de51e6874e94e7bf76d726fc5d13ba782deca734ff60d5bb2fb2607c7406555" +checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" dependencies = [ "cfg-if", "libc", "r-efi 6.0.0", "rand_core 0.10.1", - "wasip2", - "wasip3", ] [[package]] @@ -1309,7 +1330,6 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" dependencies = [ "allocator-api2", - "foldhash 0.1.5", ] [[package]] @@ -1318,15 +1338,9 @@ version = "0.17.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" dependencies = [ - "foldhash 0.2.0", + "foldhash", ] -[[package]] -name = "heck" -version = "0.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" - [[package]] name = "hex" version = "0.4.3" @@ -1342,6 +1356,15 @@ dependencies = [ "arrayvec", ] +[[package]] +name = "hex-conservative" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "830e599c2904b08f0834ee6337d8fe8f0ed4a63b5d9e7a7f49c0ffa06d08d360" +dependencies = [ + "arrayvec", +] + [[package]] name = "hex-literal" version = "0.4.1" @@ -1359,9 +1382,9 @@ dependencies = [ [[package]] name = "hybrid-array" -version = "0.4.12" +version = "0.4.13" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9155a582abd142abc056962c29e3ce5ff2ad5469f4246b537ed42c5deba857da" +checksum = "818356c5132c1fede50f837ca96afbe78ff42413047f4abb886217845e1b6c8c" dependencies = [ "ctutils", "typenum", @@ -1391,12 +1414,6 @@ dependencies = [ "cc", ] -[[package]] -name = "id-arena" -version = "2.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954" - [[package]] name = "ident_case" version = "1.0.1" @@ -1420,7 +1437,7 @@ checksum = "a0eb5a3343abf848c0984fe4604b2b105da9539376e24fc0a3b0007411ae4fd9" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.118", ] [[package]] @@ -1493,13 +1510,12 @@ checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" [[package]] name = "js-sys" -version = "0.3.99" +version = "0.3.103" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "142bc4740e452c1e57ade0cbc129f139c9093e354346f0872ef985f4f5cf5f11" +checksum = "53b44bfcdb3f8d5837a46dae1ca9660a837176eee74a28b229bc626816589102" dependencies = [ "cfg-if", "futures-util", - "once_cell", "wasm-bindgen", ] @@ -1579,12 +1595,6 @@ dependencies = [ "spin", ] -[[package]] -name = "leb128fmt" -version = "0.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" - [[package]] name = "lee_core" version = "0.1.0" @@ -1622,9 +1632,9 @@ checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" [[package]] name = "log" -version = "0.4.30" +version = "0.4.33" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "616ec5685824bcc94416c6d4a7a446eea774a31efd7062c8480ba6fd06d7a6e5" +checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad" [[package]] name = "malloc_buf" @@ -1637,9 +1647,9 @@ dependencies = [ [[package]] name = "memchr" -version = "2.8.1" +version = "2.8.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6b947ae49db0d222b1dbc6b113ce7248a3fc3a6ca21b696717bfc000ba4484d8" +checksum = "88904434abc2901f197fe8cc55f0445e7ded921dba5911dad2e2b39b48e663c4" [[package]] name = "merlin" @@ -1659,7 +1669,7 @@ version = "0.29.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7ecfd3296f8c56b7c1f6fbac3c71cefa9d78ce009850c45000015f206dc7fa21" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.13.0", "block", "core-graphics-types", "foreign-types", @@ -1752,7 +1762,7 @@ checksum = "680998035259dcfcafe653688bf2aa6d3e2dc05e98be6ab46afb089dc84f1df8" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.118", ] [[package]] @@ -1795,7 +1805,7 @@ dependencies = [ "proc-macro-crate", "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.118", ] [[package]] @@ -1867,16 +1877,6 @@ dependencies = [ "zerocopy", ] -[[package]] -name = "prettyplease" -version = "0.2.37" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" -dependencies = [ - "proc-macro2", - "syn 2.0.117", -] - [[package]] name = "primitive-types" version = "0.12.2" @@ -1914,7 +1914,7 @@ checksum = "4b45fcc2344c680f5025fe57779faef368840d0bd1f42f216291f0dc4ace4744" dependencies = [ "bit-set", "bit-vec", - "bitflags 2.11.1", + "bitflags 2.13.0", "num-traits", "rand 0.9.4", "rand_chacha 0.9.0", @@ -1933,9 +1933,9 @@ checksum = "a1d01941d82fa2ab50be1e79e6714289dd7cde78eba4c074bc5a4374f650dfe0" [[package]] name = "quote" -version = "1.0.45" +version = "1.0.46" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924" +checksum = "dfbc457d0c7a0759a614551b11a6409e5951f6c7537be1f1b7682b9ae9230368" dependencies = [ "proc-macro2", ] @@ -2034,9 +2034,9 @@ dependencies = [ [[package]] name = "rapidhash" -version = "4.4.1" +version = "4.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b5e48930979c155e2f33aa36ab3119b5ee81332beb6482199a8ecd6029b80b59" +checksum = "32b266a82f4aa99bb5c25e28d11cc44ace63d91adbcbcee4d323e2ae3d49ef37" dependencies = [ "rustversion", ] @@ -2058,14 +2058,14 @@ checksum = "b7186006dcb21920990093f30e3dea63b7d6e977bf1256be20c3563a5db070da" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.118", ] [[package]] name = "regex-syntax" -version = "0.8.10" +version = "0.8.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a" +checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" [[package]] name = "rfc6979" @@ -2316,9 +2316,9 @@ checksum = "48fd7bd8a6377e15ad9d42a8ec25371b94ddc67abe7c8b9127bec79bebaaae18" [[package]] name = "rustc-hash" -version = "2.1.2" +version = "2.1.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "94300abf3f1ae2e2b8ffb7b58043de3d399c73fa6f4b73826402a5c457614dbe" +checksum = "6b1e7f9a428571be2dc5bc0505c13fb6bf936822b894ec87abf8a08a4e51742d" [[package]] name = "rustc-hex" @@ -2350,7 +2350,7 @@ version = "1.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.13.0", "errno", "libc", "linux-raw-sys", @@ -2488,7 +2488,7 @@ checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.118", ] [[package]] @@ -2515,9 +2515,9 @@ dependencies = [ [[package]] name = "serde_with" -version = "3.20.0" +version = "3.21.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e72c1c2cb7b223fafb600a619537a871c2818583d619401b785e7c0b746ccde2" +checksum = "76a5c54c7310e7b8b9577c286d7e399ddd876c3e12b3ed917a8aabc4b96e9e8c" dependencies = [ "base64", "bs58", @@ -2535,14 +2535,14 @@ dependencies = [ [[package]] name = "serde_with_macros" -version = "3.20.0" +version = "3.21.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b90c488738ecb4fb0262f41f43bc40efc5868d9fb744319ddf5f5317f417bfac" +checksum = "84d57bc0c8b9a17920c178daa6bb924850d54a9c97ab45194bb8c17ad66bb660" dependencies = [ "darling", "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.118", ] [[package]] @@ -2578,9 +2578,9 @@ dependencies = [ [[package]] name = "shlex" -version = "1.3.0" +version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" +checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" [[package]] name = "signature" @@ -2622,7 +2622,7 @@ dependencies = [ "serde", "serde_json", "sha2", - "syn 2.0.117", + "syn 2.0.118", "thiserror 1.0.69", "toml", ] @@ -2637,7 +2637,7 @@ dependencies = [ "serde_json", "sha2", "spel-framework-core", - "syn 2.0.117", + "syn 2.0.118", ] [[package]] @@ -2673,7 +2673,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d904e7009df136af5297832a3ace3370cd14ff1546a232f4f185036c2736fcac" dependencies = [ "quote", - "syn 2.0.117", + "syn 2.0.118", ] [[package]] @@ -2709,9 +2709,12 @@ dependencies = [ name = "stablecoin_program" version = "0.1.0" dependencies = [ + "borsh", + "clock_core", "lee_core", "stablecoin_core", "token_core", + "twap_oracle_core", ] [[package]] @@ -2745,9 +2748,9 @@ dependencies = [ [[package]] name = "syn" -version = "2.0.117" +version = "2.0.118" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99" +checksum = "1b9ae57f904213ebb649ce6895b8a66c66f0203b9319718f69a5612a065b1422" dependencies = [ "proc-macro2", "quote", @@ -2767,7 +2770,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" dependencies = [ "fastrand", - "getrandom 0.4.2", + "getrandom 0.4.3", "once_cell", "rustix", "windows-sys", @@ -2799,7 +2802,7 @@ checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.118", ] [[package]] @@ -2810,17 +2813,16 @@ checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.118", ] [[package]] name = "time" -version = "0.3.47" +version = "0.3.53" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "743bd48c283afc0388f9b8827b976905fb217ad9e647fae3a379a9283c4def2c" +checksum = "18dfaaeddcb932337b5e7866ee7d0ce9b76d2fd092997146f187ec09b4558a50" dependencies = [ "deranged", - "itoa", "num-conv", "powerfmt", "serde_core", @@ -2830,15 +2832,15 @@ dependencies = [ [[package]] name = "time-core" -version = "0.1.8" +version = "0.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7694e1cfe791f8d31026952abf09c69ca6f6fa4e1a1229e18988f06a04a12dca" +checksum = "9e1c906769ad99c88eaa54e728060edef082f8e358ff32030cb7c7d315e81109" [[package]] name = "time-macros" -version = "0.2.27" +version = "0.2.31" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2e70e4c5a0e0a8a4823ad65dfe1a6930e4f4d756dcd9dd7939022b5e8c501215" +checksum = "c431b87111666e491a90baa837f914fb45cd5dc3c268591b0220ff5057f2085f" dependencies = [ "num-conv", "time-core", @@ -2960,7 +2962,7 @@ checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.118", ] [[package]] @@ -2998,9 +3000,9 @@ dependencies = [ [[package]] name = "typenum" -version = "1.20.0" +version = "1.20.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "40ce102ab67701b8526c123c1bab5cbe42d7040ccfd0f64af1a385808d2f43de" +checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" [[package]] name = "ucd-trie" @@ -3084,27 +3086,18 @@ checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" [[package]] name = "wasip2" -version = "1.0.3+wasi-0.2.9" +version = "1.0.4+wasi-0.2.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "20064672db26d7cdc89c7798c48a0fdfac8213434a1186e5ef29fd560ae223d6" +checksum = "b67efb37e106e55ce722a510d6b5f9c17f083e5fc79afc2badeb12cc313d9487" dependencies = [ - "wit-bindgen 0.57.1", -] - -[[package]] -name = "wasip3" -version = "0.4.0+wasi-0.3.0-rc-2026-01-06" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5428f8bf88ea5ddc08faddef2ac4a67e390b88186c703ce6dbd955e1c145aca5" -dependencies = [ - "wit-bindgen 0.51.0", + "wit-bindgen", ] [[package]] name = "wasm-bindgen" -version = "0.2.122" +version = "0.2.126" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3ed04576f974d2b2fba0f38c51dbc5518011e38c36bf1143164be765528fd409" +checksum = "4b067c0c11094aef6b7a801c1e34a26affafdf3d051dba08456b868789aaf9a4" dependencies = [ "cfg-if", "once_cell", @@ -3115,9 +3108,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro" -version = "0.2.122" +version = "0.2.126" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "916151b09da36bd82f6615cbf3a419e2f0ba23a03c6160e8e92eb6bd4aa1dec6" +checksum = "167ce5e579f6bcf889c4f7175a8a5a585de84e8ff93976ce393efa5f2837aab1" dependencies = [ "quote", "wasm-bindgen-macro-support", @@ -3125,60 +3118,26 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro-support" -version = "0.2.122" +version = "0.2.126" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "299047362ccbfce148b67ab7e73349f77748e00c8296f9542adfad2ad82c5c5e" +checksum = "f3997c7839262f4ef12cf90b818d6340c18e80f263f1a94bf157d0ec4420380e" dependencies = [ "bumpalo", "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.118", "wasm-bindgen-shared", ] [[package]] name = "wasm-bindgen-shared" -version = "0.2.122" +version = "0.2.126" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9a929b2c61f11ba3e9bc35b50c1f25cb38e0e892c0c231ae2b8cf78d5dad4437" +checksum = "dc1b4cb0cc549fcf58d7dfc081778139b3d283a081644e833e84682ad71cea24" dependencies = [ "unicode-ident", ] -[[package]] -name = "wasm-encoder" -version = "0.244.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "990065f2fe63003fe337b932cfb5e3b80e0b4d0f5ff650e6985b1048f62c8319" -dependencies = [ - "leb128fmt", - "wasmparser", -] - -[[package]] -name = "wasm-metadata" -version = "0.244.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bb0e353e6a2fbdc176932bbaab493762eb1255a7900fe0fea1a2f96c296cc909" -dependencies = [ - "anyhow", - "indexmap 2.14.0", - "wasm-encoder", - "wasmparser", -] - -[[package]] -name = "wasmparser" -version = "0.244.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe" -dependencies = [ - "bitflags 2.11.1", - "hashbrown 0.15.5", - "indexmap 2.14.0", - "semver 1.0.28", -] - [[package]] name = "windows-core" version = "0.62.2" @@ -3200,7 +3159,7 @@ checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.118", ] [[package]] @@ -3211,7 +3170,7 @@ checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.118", ] [[package]] @@ -3265,100 +3224,12 @@ dependencies = [ "memchr", ] -[[package]] -name = "wit-bindgen" -version = "0.51.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5" -dependencies = [ - "wit-bindgen-rust-macro", -] - [[package]] name = "wit-bindgen" version = "0.57.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" -[[package]] -name = "wit-bindgen-core" -version = "0.51.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ea61de684c3ea68cb082b7a88508a8b27fcc8b797d738bfc99a82facf1d752dc" -dependencies = [ - "anyhow", - "heck", - "wit-parser", -] - -[[package]] -name = "wit-bindgen-rust" -version = "0.51.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b7c566e0f4b284dd6561c786d9cb0142da491f46a9fbed79ea69cdad5db17f21" -dependencies = [ - "anyhow", - "heck", - "indexmap 2.14.0", - "prettyplease", - "syn 2.0.117", - "wasm-metadata", - "wit-bindgen-core", - "wit-component", -] - -[[package]] -name = "wit-bindgen-rust-macro" -version = "0.51.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0c0f9bfd77e6a48eccf51359e3ae77140a7f50b1e2ebfe62422d8afdaffab17a" -dependencies = [ - "anyhow", - "prettyplease", - "proc-macro2", - "quote", - "syn 2.0.117", - "wit-bindgen-core", - "wit-bindgen-rust", -] - -[[package]] -name = "wit-component" -version = "0.244.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2" -dependencies = [ - "anyhow", - "bitflags 2.11.1", - "indexmap 2.14.0", - "log", - "serde", - "serde_derive", - "serde_json", - "wasm-encoder", - "wasm-metadata", - "wasmparser", - "wit-parser", -] - -[[package]] -name = "wit-parser" -version = "0.244.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ecc8ac4bc1dc3381b7f59c34f00b67e18f910c2c0f50015669dde7def656a736" -dependencies = [ - "anyhow", - "id-arena", - "indexmap 2.14.0", - "log", - "semver 1.0.28", - "serde", - "serde_derive", - "serde_json", - "unicode-xid", - "wasmparser", -] - [[package]] name = "wyz" version = "0.5.1" @@ -3370,42 +3241,42 @@ dependencies = [ [[package]] name = "zerocopy" -version = "0.8.49" +version = "0.8.52" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bce33a6288fa3f072a8c2c7d0f2fdbb90e28298f0135c1f99b96c3db2efcc60b" +checksum = "ce1022995ff5ff5d841ad7d994facc23098cd40152f2c1d11cd607c6f530653f" dependencies = [ "zerocopy-derive", ] [[package]] name = "zerocopy-derive" -version = "0.8.49" +version = "0.8.52" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8fd425244944f4ab65ccff928e7323354c5a018c75838362fdce749dfad2ee1e" +checksum = "1ae7f38b72ec2a254e2b87ef277cf2cd4fb97cbebf944faa6f33354da0867930" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.118", ] [[package]] name = "zeroize" -version = "1.8.2" +version = "1.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0" +checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e" dependencies = [ "zeroize_derive", ] [[package]] name = "zeroize_derive" -version = "1.4.3" +version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "85a5b4158499876c763cb03bc4e49185d3cccbabb15b33c627f7884f43db852e" +checksum = "3c50655cbb0fe3fc43170059e702f1ce5e19b84cec58dc87b037a09935c2f328" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.118", ] [[package]] diff --git a/programs/stablecoin/methods/guest/src/bin/stablecoin.rs b/programs/stablecoin/methods/guest/src/bin/stablecoin.rs index 3cd28b1..c8d29b1 100644 --- a/programs/stablecoin/methods/guest/src/bin/stablecoin.rs +++ b/programs/stablecoin/methods/guest/src/bin/stablecoin.rs @@ -1,6 +1,6 @@ #![cfg_attr(not(test), no_main)] -use nssa_core::account::AccountWithMetadata; +use nssa_core::account::{AccountId, AccountWithMetadata}; use spel_framework::context::ProgramContext; use spel_framework::prelude::*; @@ -12,11 +12,109 @@ mod stablecoin { #[allow(unused_imports)] use super::*; + /// Initialize protocol globals and the stablecoin token definition. + #[expect( + clippy::too_many_arguments, + reason = "instruction interface initializes all stablecoin singleton accounts" + )] + #[instruction] + 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: 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, + 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, + ); + Ok(spel_framework::SpelOutput::execute( + post_states, + chained_calls, + )) + } + + /// Roll the global stability-fee accumulator forward. + #[instruction] + pub fn accrue_stability_fee( + ctx: ProgramContext, + caller: AccountWithMetadata, + protocol_parameters: AccountWithMetadata, + stability_fee_accumulator: AccountWithMetadata, + clock: AccountWithMetadata, + ) -> SpelResult { + let (post_states, chained_calls) = + stablecoin_program::accrue_stability_fee::accrue_stability_fee( + caller, + protocol_parameters, + stability_fee_accumulator, + clock, + ctx.self_program_id, + ); + Ok(spel_framework::SpelOutput::execute( + post_states, + chained_calls, + )) + } + + /// Update stability-fee rate after accruing pending fees at the old rate. + #[instruction] + pub fn set_stability_fee_per_millisecond( + ctx: ProgramContext, + admin: AccountWithMetadata, + protocol_parameters: AccountWithMetadata, + stability_fee_accumulator: AccountWithMetadata, + clock: AccountWithMetadata, + new_rate: u128, + ) -> SpelResult { + let (post_states, chained_calls) = + stablecoin_program::set_stability_fee_per_millisecond::set_stability_fee_per_millisecond( + admin, + protocol_parameters, + stability_fee_accumulator, + clock, + ctx.self_program_id, + new_rate, + ); + Ok(spel_framework::SpelOutput::execute( + post_states, + chained_calls, + )) + } + /// Open a new collateral-only position for the calling owner. - /// - /// # Errors - /// Returns the host program's panic-converted error if any precondition fails (see - /// [`stablecoin_program::open_position::open_position`] for the full list). #[instruction] pub fn open_position( ctx: ProgramContext, @@ -28,17 +126,23 @@ mod stablecoin { vault: AccountWithMetadata, #[account(mut, signer)] user_holding: AccountWithMetadata, - token_definition: AccountWithMetadata, - collateral_amount: u128, + collateral_definition: AccountWithMetadata, + protocol_parameters: AccountWithMetadata, + clock: AccountWithMetadata, + position_nonce: u64, + initial_collateral_amount: u128, ) -> SpelResult { let (post_states, chained_calls) = stablecoin_program::open_position::open_position( owner, position, vault, user_holding, - token_definition, + collateral_definition, + protocol_parameters, + clock, ctx.self_program_id, - collateral_amount, + position_nonce, + initial_collateral_amount, ); Ok(spel_framework::SpelOutput::execute( post_states, @@ -46,14 +150,49 @@ mod stablecoin { )) } - /// Withdraw `amount` collateral tokens from an existing position back to a - /// user-controlled holding. - /// - /// # Errors - /// Returns the host program's panic-converted error if any precondition - /// fails (see - /// [`stablecoin_program::withdraw_collateral::withdraw_collateral`] for the - /// full list). + /// Mint stablecoin debt against an existing position. + #[expect( + clippy::too_many_arguments, + reason = "instruction interface passes explicit position, token, oracle, and protocol accounts" + )] + #[instruction] + pub fn generate_debt( + ctx: ProgramContext, + owner: AccountWithMetadata, + position: AccountWithMetadata, + stablecoin_definition: AccountWithMetadata, + user_stablecoin_holding: AccountWithMetadata, + stability_fee_accumulator: AccountWithMetadata, + redemption_price_state: AccountWithMetadata, + market_price_oracle: AccountWithMetadata, + protocol_parameters: AccountWithMetadata, + clock: AccountWithMetadata, + amount: u128, + ) -> SpelResult { + let (post_states, chained_calls) = stablecoin_program::generate_debt::generate_debt( + owner, + position, + stablecoin_definition, + user_stablecoin_holding, + stability_fee_accumulator, + redemption_price_state, + market_price_oracle, + protocol_parameters, + clock, + ctx.self_program_id, + amount, + ); + Ok(spel_framework::SpelOutput::execute( + post_states, + chained_calls, + )) + } + + /// Withdraw collateral from an existing position. + #[expect( + clippy::too_many_arguments, + reason = "instruction interface passes explicit position and protocol accounts" + )] #[instruction] pub fn withdraw_collateral( ctx: ProgramContext, @@ -65,6 +204,10 @@ mod stablecoin { vault: AccountWithMetadata, #[account(mut)] destination: AccountWithMetadata, + stability_fee_accumulator: AccountWithMetadata, + redemption_price_state: AccountWithMetadata, + protocol_parameters: AccountWithMetadata, + clock: AccountWithMetadata, amount: u128, ) -> SpelResult { let (post_states, chained_calls) = @@ -73,6 +216,10 @@ mod stablecoin { position, vault, destination, + stability_fee_accumulator, + redemption_price_state, + protocol_parameters, + clock, ctx.self_program_id, amount, ); @@ -82,12 +229,7 @@ mod stablecoin { )) } - /// Repay `amount` of outstanding stablecoin debt against an existing position. - /// - /// # Errors - /// Returns the host program's panic-converted error if any precondition - /// fails (see [`stablecoin_program::repay_debt::repay_debt`] for the - /// full list). + /// Repay stablecoin debt against an existing position. #[instruction] pub fn repay_debt( ctx: ProgramContext, @@ -99,6 +241,9 @@ mod stablecoin { stablecoin_definition: AccountWithMetadata, #[account(mut, signer)] user_stablecoin_holding: AccountWithMetadata, + stability_fee_accumulator: AccountWithMetadata, + protocol_parameters: AccountWithMetadata, + clock: AccountWithMetadata, amount: u128, ) -> SpelResult { let (post_states, chained_calls) = stablecoin_program::repay_debt::repay_debt( @@ -106,6 +251,9 @@ mod stablecoin { position, stablecoin_definition, user_stablecoin_holding, + stability_fee_accumulator, + protocol_parameters, + clock, ctx.self_program_id, amount, ); diff --git a/programs/stablecoin/src/accrue_stability_fee.rs b/programs/stablecoin/src/accrue_stability_fee.rs new file mode 100644 index 0000000..f8de656 --- /dev/null +++ b/programs/stablecoin/src/accrue_stability_fee.rs @@ -0,0 +1,41 @@ +use nssa_core::{ + account::{AccountWithMetadata, Data}, + program::{AccountPostState, ChainedCall, ProgramId}, +}; + +use crate::shared::{ + accrue_stability_fee_state, read_clock_timestamp, read_protocol_parameters, + read_stability_fee_accumulator, +}; + +/// Rolls the global stability-fee accumulator forward. +/// +/// # Panics +/// Panics if the caller is unauthorized or the protocol accounts are invalid. +pub fn accrue_stability_fee( + caller: AccountWithMetadata, + protocol_parameters: AccountWithMetadata, + stability_fee_accumulator: AccountWithMetadata, + clock: AccountWithMetadata, + stablecoin_program_id: ProgramId, +) -> (Vec, Vec) { + assert!(caller.is_authorized, "Caller authorization is missing"); + + let params = read_protocol_parameters(&protocol_parameters, stablecoin_program_id); + let accumulator = + read_stability_fee_accumulator(&stability_fee_accumulator, stablecoin_program_id); + let now = read_clock_timestamp(&clock); + let updated = accrue_stability_fee_state(&accumulator, ¶ms, now); + + let mut accumulator_post = stability_fee_accumulator.account.clone(); + accumulator_post.data = Data::from(&updated); + + let post_states = vec![ + AccountPostState::new(caller.account), + AccountPostState::new(protocol_parameters.account), + AccountPostState::new(accumulator_post), + AccountPostState::new(clock.account), + ]; + + (post_states, vec![]) +} diff --git a/programs/stablecoin/src/generate_debt.rs b/programs/stablecoin/src/generate_debt.rs new file mode 100644 index 0000000..0926cf8 --- /dev/null +++ b/programs/stablecoin/src/generate_debt.rs @@ -0,0 +1,177 @@ +use nssa_core::{ + account::{Account, AccountWithMetadata, Data}, + program::{AccountPostState, ChainedCall, ProgramId}, +}; +use stablecoin_core::{ + compute_stablecoin_definition_pda_seed, verify_position_and_get_seed, Position, FIXED_POINT_ONE, +}; +use token_core::TokenHolding; +use twap_oracle_core::OraclePriceAccount; + +use crate::shared::{ + read_clock_timestamp, read_protocol_parameters, read_redemption_price_state, + read_stability_fee_accumulator, +}; + +/// Mints stablecoin debt against an existing position. +/// +/// # Panics +/// Panics if the position is not authorized, the oracle is stale, or the post-mint position would +/// be undercollateralized. +#[expect( + clippy::too_many_arguments, + reason = "instruction surface passes explicit position, token, oracle, and protocol accounts" +)] +pub fn generate_debt( + owner: AccountWithMetadata, + position: AccountWithMetadata, + stablecoin_definition: AccountWithMetadata, + user_stablecoin_holding: AccountWithMetadata, + stability_fee_accumulator: AccountWithMetadata, + redemption_price_state: AccountWithMetadata, + market_price_oracle: AccountWithMetadata, + protocol_parameters: AccountWithMetadata, + clock: AccountWithMetadata, + stablecoin_program_id: ProgramId, + amount: u128, +) -> (Vec, Vec) { + assert!(owner.is_authorized, "Owner authorization is missing"); + let params = read_protocol_parameters(&protocol_parameters, stablecoin_program_id); + assert!( + !params.is_frozen, + "Protocol is frozen; debt generation is disabled" + ); + assert_eq!( + stablecoin_definition.account_id, params.stablecoin_definition_id, + "Stablecoin definition does not match protocol parameters" + ); + + let accumulator = + read_stability_fee_accumulator(&stability_fee_accumulator, stablecoin_program_id); + let redemption_state = + read_redemption_price_state(&redemption_price_state, stablecoin_program_id); + let now = read_clock_timestamp(&clock); + let current_accumulator = stablecoin_core::current_accumulated_rate(&accumulator, ¶ms, now); + let current_redemption_price = + stablecoin_core::current_redemption_price(&redemption_state, now); + + assert_ne!( + position.account, + Account::default(), + "Position account must be initialized" + ); + assert_eq!( + position.account.program_owner, stablecoin_program_id, + "Position is not owned by this stablecoin program" + ); + let position_data = Position::try_from(&position.account.data) + .expect("Position account must hold valid Position state"); + assert_eq!( + position_data.owner_account_id, owner.account_id, + "Position owner does not match signer" + ); + let _position_seed = verify_position_and_get_seed( + &position, + &owner, + position_data.position_nonce, + stablecoin_program_id, + ); + + assert_ne!( + stablecoin_definition.account, + Account::default(), + "Stablecoin definition account must be initialized" + ); + assert_ne!( + user_stablecoin_holding.account, + Account::default(), + "User stablecoin holding must be initialized" + ); + assert_eq!( + user_stablecoin_holding.account.program_owner, stablecoin_definition.account.program_owner, + "Stablecoin holding and definition must be owned by the same Token Program" + ); + let user_holding = TokenHolding::try_from(&user_stablecoin_holding.account.data) + .expect("User stablecoin holding must hold a valid TokenHolding"); + assert_eq!( + user_holding.definition_id(), + stablecoin_definition.account_id, + "Stablecoin holding does not match the provided stablecoin definition" + ); + + assert_eq!( + market_price_oracle.account_id, params.market_price_oracle_id, + "Market price oracle does not match protocol parameters" + ); + let oracle = OraclePriceAccount::try_from(&market_price_oracle.account.data) + .expect("Market price oracle account must hold a valid OraclePriceAccount"); + assert_eq!( + oracle.base_asset, params.stablecoin_definition_id, + "Market price oracle base asset must be the stablecoin definition" + ); + assert_eq!( + oracle.quote_asset, params.collateral_definition_id, + "Market price oracle quote asset must be the collateral definition" + ); + assert!( + oracle.price != 0, + "Market price oracle price must be non-zero" + ); + assert!( + now.saturating_sub(oracle.timestamp) <= params.maximum_oracle_price_age_milliseconds, + "Market price oracle is stale" + ); + + let normalized_delta = + stablecoin_core::mul_div_ceil(amount, FIXED_POINT_ONE, current_accumulator); + let new_normalized_debt = position_data + .normalized_debt_amount + .checked_add(normalized_delta) + .expect("Position normalized debt overflow"); + assert!( + stablecoin_core::is_collateralized( + position_data.collateral_amount, + new_normalized_debt, + current_accumulator, + current_redemption_price, + params.minimum_collateralization_ratio, + ), + "Position would be undercollateralized after debt generation" + ); + + let updated_position = Position { + owner_account_id: position_data.owner_account_id, + position_nonce: position_data.position_nonce, + vault_account_id: position_data.vault_account_id, + collateral_amount: position_data.collateral_amount, + normalized_debt_amount: new_normalized_debt, + opened_at: position_data.opened_at, + }; + let mut position_post = position.account.clone(); + position_post.data = Data::from(&updated_position); + + let post_states = vec![ + AccountPostState::new(owner.account), + AccountPostState::new(position_post), + AccountPostState::new(stablecoin_definition.account.clone()), + AccountPostState::new(user_stablecoin_holding.account.clone()), + AccountPostState::new(stability_fee_accumulator.account), + AccountPostState::new(redemption_price_state.account), + AccountPostState::new(market_price_oracle.account), + AccountPostState::new(protocol_parameters.account), + AccountPostState::new(clock.account), + ]; + + let mut stablecoin_definition_authorized = stablecoin_definition; + stablecoin_definition_authorized.is_authorized = true; + let mint_call = ChainedCall::new( + stablecoin_definition_authorized.account.program_owner, + vec![stablecoin_definition_authorized, user_stablecoin_holding], + &token_core::Instruction::Mint { + amount_to_mint: amount, + }, + ) + .with_pda_seeds(vec![compute_stablecoin_definition_pda_seed()]); + + (post_states, vec![mint_call]) +} diff --git a/programs/stablecoin/src/initialize_program.rs b/programs/stablecoin/src/initialize_program.rs new file mode 100644 index 0000000..b4da59b --- /dev/null +++ b/programs/stablecoin/src/initialize_program.rs @@ -0,0 +1,206 @@ +use nssa_core::{ + account::{Account, AccountWithMetadata, Data}, + program::{AccountPostState, ChainedCall, Claim, ProgramId}, +}; +use stablecoin_core::{ + assert_protocol_parameter_bounds, 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, ProtocolParameters, RedemptionPriceState, + StabilityFeeAccumulator, FIXED_POINT_ONE, +}; +use token_core::TokenDefinition; +use twap_oracle_core::OraclePriceAccount; + +use crate::shared::read_clock_timestamp; + +/// Initializes stablecoin protocol globals and creates the stablecoin token definition. +/// +/// # Panics +/// Panics if any account is not the expected PDA/state shape or any parameter is out of bounds. +#[expect( + clippy::too_many_arguments, + reason = "instruction surface initializes all protocol singleton accounts in one call" +)] +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, + 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, +) -> (Vec, Vec) { + assert!(admin.is_authorized, "Admin authorization is missing"); + assert_protocol_parameter_bounds( + 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, + ); + + assert_uninitialized_pda( + &protocol_parameters, + compute_protocol_parameters_pda(stablecoin_program_id), + "Protocol parameters", + ); + assert_uninitialized_pda( + &stability_fee_accumulator, + compute_stability_fee_accumulator_pda(stablecoin_program_id), + "Stability fee accumulator", + ); + assert_uninitialized_pda( + &redemption_price_state, + compute_redemption_price_state_pda(stablecoin_program_id), + "Redemption price state", + ); + assert_uninitialized_pda( + &stablecoin_definition, + compute_stablecoin_definition_pda(stablecoin_program_id), + "Stablecoin definition", + ); + assert_uninitialized_pda( + &stablecoin_master_holding, + compute_stablecoin_master_holding_pda(stablecoin_program_id), + "Stablecoin master holding", + ); + + assert_ne!( + collateral_definition.account, + Account::default(), + "Collateral definition account must be initialized" + ); + let collateral_definition_data = TokenDefinition::try_from(&collateral_definition.account.data) + .expect("Collateral definition account must hold a valid TokenDefinition"); + assert!( + matches!(collateral_definition_data, TokenDefinition::Fungible { .. }), + "Collateral definition must be fungible" + ); + + 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 account must hold a valid OraclePriceAccount"); + assert_eq!( + oracle.base_asset, stablecoin_definition.account_id, + "Market price oracle base asset must be the stablecoin definition" + ); + assert_eq!( + oracle.quote_asset, collateral_definition.account_id, + "Market price oracle quote asset must be the collateral definition" + ); + + let now = read_clock_timestamp(&clock); + + let protocol_data = ProtocolParameters { + admin_account_id: admin.account_id, + 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: initial_stability_fee_per_millisecond, + controller_proportional_gain: initial_controller_proportional_gain, + controller_integral_gain: initial_controller_integral_gain, + minimum_collateralization_ratio: initial_minimum_collateralization_ratio, + minimum_milliseconds_between_rate_updates, + maximum_oracle_price_age_milliseconds, + is_frozen: false, + }; + let mut protocol_post = protocol_parameters.account.clone(); + protocol_post.data = Data::from(&protocol_data); + + let mut fee_post = stability_fee_accumulator.account.clone(); + fee_post.data = Data::from(&StabilityFeeAccumulator { + accumulated_rate_at_last_accrual: FIXED_POINT_ONE, + last_accrued_at: now, + }); + + let mut redemption_post = redemption_price_state.account.clone(); + redemption_post.data = Data::from(&RedemptionPriceState { + redemption_price_at_last_update: initial_redemption_price, + redemption_rate_per_millisecond: FIXED_POINT_ONE, + controller_integral_term: 0, + last_updated_at: now, + }); + + let mut stablecoin_definition_authorized = stablecoin_definition.clone(); + stablecoin_definition_authorized.is_authorized = true; + let mut stablecoin_master_holding_authorized = stablecoin_master_holding.clone(); + stablecoin_master_holding_authorized.is_authorized = true; + let token_program_id = collateral_definition.account.program_owner; + let create_stablecoin_call = ChainedCall::new( + token_program_id, + vec![ + stablecoin_definition_authorized, + stablecoin_master_holding_authorized, + ], + &token_core::Instruction::NewFungibleDefinition { + name: stablecoin_name, + total_supply: 0, + mint_authority: Some(stablecoin_definition.account_id), + }, + ) + .with_pda_seeds(vec![ + compute_stablecoin_definition_pda_seed(), + compute_stablecoin_master_holding_pda_seed(), + ]); + + let post_states = vec![ + AccountPostState::new(admin.account), + AccountPostState::new_claimed( + protocol_post, + Claim::Pda(compute_protocol_parameters_pda_seed()), + ), + AccountPostState::new_claimed( + fee_post, + Claim::Pda(compute_stability_fee_accumulator_pda_seed()), + ), + AccountPostState::new_claimed( + redemption_post, + Claim::Pda(compute_redemption_price_state_pda_seed()), + ), + AccountPostState::new(stablecoin_definition.account), + AccountPostState::new(stablecoin_master_holding.account), + AccountPostState::new(collateral_definition.account), + AccountPostState::new(market_price_oracle.account), + AccountPostState::new(clock.account), + ]; + + (post_states, vec![create_stablecoin_call]) +} + +fn assert_uninitialized_pda( + account: &AccountWithMetadata, + expected_id: nssa_core::account::AccountId, + label: &str, +) { + assert_eq!( + account.account_id, expected_id, + "{label} account ID does not match PDA" + ); + assert_eq!( + account.account, + Account::default(), + "{label} account must be uninitialized" + ); +} diff --git a/programs/stablecoin/src/lib.rs b/programs/stablecoin/src/lib.rs index 690024e..bc3f134 100644 --- a/programs/stablecoin/src/lib.rs +++ b/programs/stablecoin/src/lib.rs @@ -2,12 +2,26 @@ pub use stablecoin_core as core; +/// Roll the global stability-fee accumulator forward. +pub mod accrue_stability_fee; + +/// Mint stablecoin debt against an existing position. +pub mod generate_debt; + +/// Initialize protocol globals and the stablecoin token definition. +pub mod initialize_program; + /// Open a new collateral-only position for a calling owner. pub mod open_position; /// Repay outstanding stablecoin debt against an existing position. pub mod repay_debt; +/// Update the stability-fee rate after accruing at the old rate. +pub mod set_stability_fee_per_millisecond; + +mod shared; + /// Withdraw collateral from an existing position back to a user-controlled holding. pub mod withdraw_collateral; diff --git a/programs/stablecoin/src/open_position.rs b/programs/stablecoin/src/open_position.rs index 8c33ae8..4d0eafd 100644 --- a/programs/stablecoin/src/open_position.rs +++ b/programs/stablecoin/src/open_position.rs @@ -5,16 +5,17 @@ use nssa_core::{ use stablecoin_core::{verify_position_and_get_seed, verify_position_vault_and_get_seed, Position}; use token_core::TokenHolding; +use crate::shared::{read_clock_timestamp, read_protocol_parameters}; + /// Open a new collateral-only position for `owner`. /// /// This claims the [`Position`] PDA, issues two chained token-program calls under the -/// stablecoin's PDA authority, and stores `collateral_amount` with `debt_amount = 0`: +/// stablecoin's PDA authority, and stores `initial_collateral_amount` with zero normalized debt: /// 1. `InitializeAccount` materializes the vault token holding for the collateral. -/// 2. `Transfer` moves `collateral_amount` collateral tokens from the user's holding into the -/// freshly initialized vault. +/// 2. `Transfer` moves `initial_collateral_amount` collateral tokens from the user's holding into +/// the freshly initialized vault. /// -/// `debt_amount` is deferred to a future `generate_debt` instruction and is intentionally -/// not parameterized here. +/// Stablecoin debt is created later by `generate_debt`; it is intentionally not parameterized here. /// /// # Panics /// - `owner` or `user_holding` is not authorized. @@ -23,16 +24,33 @@ use token_core::TokenHolding; /// - `user_holding` cannot be decoded as a [`TokenHolding`]. /// - `user_holding`'s definition does not match `token_definition`. /// - `token_definition.program_owner` does not match `user_holding.program_owner`. +#[expect( + clippy::too_many_arguments, + reason = "instruction surface passes explicit owner, position, vault, collateral, and protocol accounts" +)] pub fn open_position( owner: AccountWithMetadata, position: AccountWithMetadata, vault: AccountWithMetadata, user_holding: AccountWithMetadata, - token_definition: AccountWithMetadata, + collateral_definition: AccountWithMetadata, + protocol_parameters: AccountWithMetadata, + clock: AccountWithMetadata, stablecoin_program_id: ProgramId, - collateral_amount: u128, + position_nonce: u64, + initial_collateral_amount: u128, ) -> (Vec, Vec) { assert!(owner.is_authorized, "Owner authorization is missing"); + let params = read_protocol_parameters(&protocol_parameters, stablecoin_program_id); + assert!( + !params.is_frozen, + "Protocol is frozen; opening positions is disabled" + ); + assert_eq!( + collateral_definition.account_id, params.collateral_definition_id, + "Collateral definition does not match protocol parameters" + ); + assert!( user_holding.is_authorized, "User collateral holding authorization is missing" @@ -52,30 +70,29 @@ pub fn open_position( .expect("User holding must be a valid Token Holding") .definition_id(); assert_eq!( - user_holding_definition_id, token_definition.account_id, + user_holding_definition_id, collateral_definition.account_id, "User collateral holding does not match the provided token definition" ); let token_program_id = user_holding.account.program_owner; assert_eq!( - token_definition.account.program_owner, token_program_id, + collateral_definition.account.program_owner, token_program_id, "Collateral token definition is not owned by the user holding's Token Program" ); - let position_seed = verify_position_and_get_seed( - &position, - &owner, - token_definition.account_id, - stablecoin_program_id, - ); + let position_seed = + verify_position_and_get_seed(&position, &owner, position_nonce, stablecoin_program_id); let vault_seed = verify_position_vault_and_get_seed(&vault, position.account_id, stablecoin_program_id); + let now = read_clock_timestamp(&clock); let mut position_post = position.account; position_post.data = Data::from(&Position { - collateral_vault_id: vault.account_id, - collateral_definition_id: token_definition.account_id, - collateral_amount, - debt_amount: 0, + owner_account_id: owner.account_id, + position_nonce, + vault_account_id: vault.account_id, + collateral_amount: initial_collateral_amount, + normalized_debt_amount: 0, + opened_at: now, }); let post_states = vec![ @@ -83,7 +100,9 @@ pub fn open_position( AccountPostState::new_claimed(position_post, Claim::Pda(position_seed)), AccountPostState::new(vault.account.clone()), AccountPostState::new(user_holding.account.clone()), - AccountPostState::new(token_definition.account.clone()), + AccountPostState::new(collateral_definition.account.clone()), + AccountPostState::new(protocol_parameters.account), + AccountPostState::new(clock.account.clone()), ]; // Chained Token::InitializeAccount owns the vault as a Token holding. The Stablecoin @@ -92,7 +111,7 @@ pub fn open_position( vault_authorized.is_authorized = true; let initialize_call = ChainedCall::new( token_program_id, - vec![token_definition.clone(), vault_authorized], + vec![collateral_definition.clone(), vault_authorized], &token_core::Instruction::InitializeAccount, ) .with_pda_seeds(vec![vault_seed]); @@ -105,7 +124,7 @@ pub fn open_position( program_owner: token_program_id, balance: 0, data: Data::from(&TokenHolding::Fungible { - definition_id: token_definition.account_id, + definition_id: collateral_definition.account_id, balance: 0, }), nonce: vault.account.nonce, @@ -117,7 +136,7 @@ pub fn open_position( token_program_id, vec![user_holding, post_init_vault], &token_core::Instruction::Transfer { - amount_to_transfer: collateral_amount, + amount_to_transfer: initial_collateral_amount, }, ); diff --git a/programs/stablecoin/src/repay_debt.rs b/programs/stablecoin/src/repay_debt.rs index dfe72bd..9d01d1f 100644 --- a/programs/stablecoin/src/repay_debt.rs +++ b/programs/stablecoin/src/repay_debt.rs @@ -5,42 +5,54 @@ use nssa_core::{ use stablecoin_core::{verify_position_and_get_seed, Position}; use token_core::TokenHolding; +use crate::shared::{ + read_clock_timestamp, read_protocol_parameters, read_stability_fee_accumulator, +}; + /// Repay `amount` of outstanding stablecoin debt against an existing position. /// /// Burns `amount` stablecoins from `user_stablecoin_holding` via a chained -/// `Token::Burn` and decreases `Position.debt_amount` by the same amount. The +/// `Token::Burn` and decreases `Position.normalized_debt_amount` by the repayment's normalized +/// equivalent. The /// position post-state uses plain [`AccountPostState::new`] — the PDA was /// already claimed at `open_position` time. /// -/// Until issue #97 (stability fee accrual) lands, the fee-accrual step is a -/// no-op (every position structurally has `debt_amount = 0` today because -/// `generate_debt` is unimplemented; "fees-accrued" is therefore vacuously -/// true). A `// TODO(#97)` comment marks where the accrual code will plug in -/// — right before the `checked_sub` below. -/// -/// Until issue #91 (`generate_debt`) records the stablecoin definition into -/// `Position`, this instruction cannot validate that `stablecoin_definition` -/// is the correct one for the position's debt. The caller is trusted. -/// /// # Panics /// - `owner` is not authorized. /// - `position` is uninitialized, not owned by `stablecoin_program_id`, holds data that does not /// decode as a [`Position`], or sits at an address that does not match -/// `compute_position_pda(stablecoin_program_id, owner, Position.collateral_definition_id)`. +/// `compute_position_pda(stablecoin_program_id, owner, Position.position_nonce)`. /// - `user_stablecoin_holding` is not authorized, is uninitialized, is owned by a different Token /// Program than `stablecoin_definition`, or holds a [`TokenHolding`] whose `definition_id` does /// not match `stablecoin_definition.account_id`. /// - `stablecoin_definition` is uninitialized. -/// - `amount > Position.debt_amount`. +/// - `amount` is greater than the current nominal debt ceiling or normalizes to zero while nonzero. +#[expect( + clippy::too_many_arguments, + reason = "instruction surface passes explicit position, token, fee, and protocol accounts" +)] pub fn repay_debt( owner: AccountWithMetadata, position: AccountWithMetadata, stablecoin_definition: AccountWithMetadata, user_stablecoin_holding: AccountWithMetadata, + stability_fee_accumulator: AccountWithMetadata, + protocol_parameters: AccountWithMetadata, + clock: AccountWithMetadata, stablecoin_program_id: ProgramId, amount: u128, ) -> (Vec, Vec) { assert!(owner.is_authorized, "Owner authorization is missing"); + let params = read_protocol_parameters(&protocol_parameters, stablecoin_program_id); + assert_eq!( + stablecoin_definition.account_id, params.stablecoin_definition_id, + "Stablecoin definition does not match protocol parameters" + ); + let accumulator = + read_stability_fee_accumulator(&stability_fee_accumulator, stablecoin_program_id); + let now = read_clock_timestamp(&clock); + let current_accumulator = stablecoin_core::current_accumulated_rate(&accumulator, ¶ms, now); + assert_ne!( position.account, Account::default(), @@ -53,13 +65,17 @@ pub fn repay_debt( let position_data = Position::try_from(&position.account.data) .expect("Position account must hold valid Position state"); + assert_eq!( + position_data.owner_account_id, owner.account_id, + "Position owner does not match signer" + ); // `verify_position_and_get_seed` asserts the position address matches the - // (owner, collateral_definition) PDA derivation. The returned seed is + // (owner, position_nonce) PDA derivation. The returned seed is // dropped — the position is already PDA-claimed. let _position_seed = verify_position_and_get_seed( &position, &owner, - position_data.collateral_definition_id, + position_data.position_nonce, stablecoin_program_id, ); @@ -89,19 +105,37 @@ pub fn repay_debt( "Stablecoin holding does not match the provided stablecoin definition" ); - // TODO(#97): accrue stability fees onto position_data.debt_amount here, before - // the checked_sub below. Today every position has debt_amount = 0 (no - // generate_debt yet), so the precondition is trivially met. - let new_debt = position_data - .debt_amount - .checked_sub(amount) + let maximum_repay_amount = stablecoin_core::mul_div_ceil( + position_data.normalized_debt_amount, + current_accumulator, + stablecoin_core::FIXED_POINT_ONE, + ); + assert!( + amount <= maximum_repay_amount, + "Repay amount exceeds outstanding debt" + ); + + let normalized_delta = stablecoin_core::mul_div_floor( + amount, + stablecoin_core::FIXED_POINT_ONE, + current_accumulator, + ); + assert!( + amount == 0 || normalized_delta != 0, + "Repay amount is too small to reduce outstanding debt" + ); + let new_normalized_debt = position_data + .normalized_debt_amount + .checked_sub(normalized_delta) .expect("Repay amount exceeds outstanding debt"); let updated_position = Position { - collateral_vault_id: position_data.collateral_vault_id, - collateral_definition_id: position_data.collateral_definition_id, + owner_account_id: position_data.owner_account_id, + position_nonce: position_data.position_nonce, + vault_account_id: position_data.vault_account_id, collateral_amount: position_data.collateral_amount, - debt_amount: new_debt, + normalized_debt_amount: new_normalized_debt, + opened_at: position_data.opened_at, }; let mut position_post = position.account.clone(); position_post.data = Data::from(&updated_position); @@ -111,6 +145,9 @@ pub fn repay_debt( AccountPostState::new(position_post), AccountPostState::new(stablecoin_definition.account.clone()), AccountPostState::new(user_stablecoin_holding.account.clone()), + AccountPostState::new(stability_fee_accumulator.account), + AccountPostState::new(protocol_parameters.account), + AccountPostState::new(clock.account), ]; let token_program_id = user_stablecoin_holding.account.program_owner; diff --git a/programs/stablecoin/src/set_stability_fee_per_millisecond.rs b/programs/stablecoin/src/set_stability_fee_per_millisecond.rs new file mode 100644 index 0000000..7e89348 --- /dev/null +++ b/programs/stablecoin/src/set_stability_fee_per_millisecond.rs @@ -0,0 +1,52 @@ +use nssa_core::{ + account::{AccountWithMetadata, Data}, + program::{AccountPostState, ChainedCall, ProgramId}, +}; +use stablecoin_core::assert_valid_stability_fee_per_millisecond; + +use crate::shared::{ + accrue_stability_fee_state, read_clock_timestamp, read_protocol_parameters, + read_stability_fee_accumulator, +}; + +/// Updates the protocol stability-fee rate after accruing at the old rate. +/// +/// # Panics +/// Panics if `admin` is not the configured admin or `new_rate` is out of bounds. +pub fn set_stability_fee_per_millisecond( + admin: AccountWithMetadata, + protocol_parameters: AccountWithMetadata, + stability_fee_accumulator: AccountWithMetadata, + clock: AccountWithMetadata, + stablecoin_program_id: ProgramId, + new_rate: u128, +) -> (Vec, Vec) { + assert!(admin.is_authorized, "Admin authorization is missing"); + assert_valid_stability_fee_per_millisecond(new_rate); + + let mut params = read_protocol_parameters(&protocol_parameters, stablecoin_program_id); + assert_eq!( + admin.account_id, params.admin_account_id, + "Admin account does not match protocol admin" + ); + + let accumulator = + read_stability_fee_accumulator(&stability_fee_accumulator, stablecoin_program_id); + let now = read_clock_timestamp(&clock); + let updated_accumulator = accrue_stability_fee_state(&accumulator, ¶ms, now); + params.stability_fee_per_millisecond = new_rate; + + let mut protocol_post = protocol_parameters.account.clone(); + protocol_post.data = Data::from(¶ms); + let mut accumulator_post = stability_fee_accumulator.account.clone(); + accumulator_post.data = Data::from(&updated_accumulator); + + let post_states = vec![ + AccountPostState::new(admin.account), + AccountPostState::new(protocol_post), + AccountPostState::new(accumulator_post), + AccountPostState::new(clock.account), + ]; + + (post_states, vec![]) +} diff --git a/programs/stablecoin/src/shared.rs b/programs/stablecoin/src/shared.rs new file mode 100644 index 0000000..28bf4e2 --- /dev/null +++ b/programs/stablecoin/src/shared.rs @@ -0,0 +1,107 @@ +use borsh::BorshDeserialize as _; +use clock_core::{ClockAccountData, CLOCK_01_PROGRAM_ACCOUNT_ID}; +use nssa_core::{ + account::{Account, AccountWithMetadata}, + program::ProgramId, +}; +use stablecoin_core::{ + compute_protocol_parameters_pda, compute_redemption_price_state_pda, + compute_stability_fee_accumulator_pda, current_accumulated_rate, ProtocolParameters, + RedemptionPriceState, StabilityFeeAccumulator, +}; + +pub(crate) fn read_clock_timestamp(clock: &AccountWithMetadata) -> u64 { + assert_eq!( + clock.account_id, CLOCK_01_PROGRAM_ACCOUNT_ID, + "Clock account must be the canonical 1-block LEZ clock account" + ); + assert_ne!( + clock.account, + Account::default(), + "Clock account must be initialized" + ); + ClockAccountData::try_from_slice(clock.account.data.as_ref()) + .expect("Clock account must hold valid ClockAccountData") + .timestamp +} + +pub(crate) fn read_protocol_parameters( + protocol_parameters: &AccountWithMetadata, + stablecoin_program_id: ProgramId, +) -> ProtocolParameters { + assert_eq!( + protocol_parameters.account_id, + compute_protocol_parameters_pda(stablecoin_program_id), + "Protocol parameters account ID does not match PDA" + ); + assert_initialized_owned( + protocol_parameters, + stablecoin_program_id, + "Protocol parameters", + ); + ProtocolParameters::try_from(&protocol_parameters.account.data) + .expect("Protocol parameters account must hold valid ProtocolParameters state") +} + +pub(crate) fn read_stability_fee_accumulator( + stability_fee_accumulator: &AccountWithMetadata, + stablecoin_program_id: ProgramId, +) -> StabilityFeeAccumulator { + assert_eq!( + stability_fee_accumulator.account_id, + compute_stability_fee_accumulator_pda(stablecoin_program_id), + "Stability fee accumulator account ID does not match PDA" + ); + assert_initialized_owned( + stability_fee_accumulator, + stablecoin_program_id, + "Stability fee accumulator", + ); + StabilityFeeAccumulator::try_from(&stability_fee_accumulator.account.data) + .expect("Stability fee accumulator account must hold valid state") +} + +pub(crate) fn read_redemption_price_state( + redemption_price_state: &AccountWithMetadata, + stablecoin_program_id: ProgramId, +) -> RedemptionPriceState { + assert_eq!( + redemption_price_state.account_id, + compute_redemption_price_state_pda(stablecoin_program_id), + "Redemption price state account ID does not match PDA" + ); + assert_initialized_owned( + redemption_price_state, + stablecoin_program_id, + "Redemption price state", + ); + RedemptionPriceState::try_from(&redemption_price_state.account.data) + .expect("Redemption price state account must hold valid state") +} + +pub(crate) fn accrue_stability_fee_state( + accumulator: &StabilityFeeAccumulator, + params: &ProtocolParameters, + now: u64, +) -> StabilityFeeAccumulator { + StabilityFeeAccumulator { + accumulated_rate_at_last_accrual: current_accumulated_rate(accumulator, params, now), + last_accrued_at: now, + } +} + +fn assert_initialized_owned( + account: &AccountWithMetadata, + stablecoin_program_id: ProgramId, + label: &str, +) { + assert_ne!( + account.account, + Account::default(), + "{label} account must be initialized" + ); + assert_eq!( + account.account.program_owner, stablecoin_program_id, + "{label} account is not owned by this stablecoin program" + ); +} diff --git a/programs/stablecoin/src/tests.rs b/programs/stablecoin/src/tests.rs index 57a64ed..fff2d05 100644 --- a/programs/stablecoin/src/tests.rs +++ b/programs/stablecoin/src/tests.rs @@ -2,975 +2,1100 @@ clippy::indexing_slicing, clippy::panic, clippy::unwrap_used, - reason = "tests deliberately panic on bad state via assert!/#[should_panic] and index fixed-size vectors" + reason = "tests deliberately inspect fixed post-state slots and panic on invalid variants" )] +use clock_core::{ClockAccountData, CLOCK_01_PROGRAM_ACCOUNT_ID, CLOCK_10_PROGRAM_ACCOUNT_ID}; use nssa_core::{ account::{Account, AccountId, AccountWithMetadata, Data, Nonce}, - program::{ChainedCall, Claim, ProgramId}, + program::{AccountPostState, ChainedCall, Claim, ProgramId}, }; use stablecoin_core::{ - compute_position_pda, compute_position_pda_seed, compute_position_vault_pda, - compute_position_vault_pda_seed, Position, + compound_rate, compute_position_pda, compute_position_pda_seed, compute_position_vault_pda, + 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, Position, + ProtocolParameters, RedemptionPriceState, StabilityFeeAccumulator, FIXED_POINT_ONE, + MAXIMUM_COMPOUNDING_WINDOW_MILLISECONDS, }; use token_core::{TokenDefinition, TokenHolding}; +use twap_oracle_core::OraclePriceAccount; const STABLECOIN_PROGRAM_ID: ProgramId = [3u32; 8]; const TOKEN_PROGRAM_ID: ProgramId = [2u32; 8]; +const CLOCK_PROGRAM_ID: ProgramId = [4u32; 8]; +const ORACLE_PROGRAM_ID: ProgramId = [5u32; 8]; +const POSITION_NONCE: u64 = 7; fn owner_id() -> AccountId { AccountId::new([0x10u8; 32]) } +fn admin_id() -> AccountId { + AccountId::new([0x11u8; 32]) +} + +fn freeze_authority_id() -> AccountId { + AccountId::new([0x12u8; 32]) +} + fn collateral_definition_id() -> AccountId { AccountId::new([0x20u8; 32]) } -fn user_holding_id() -> AccountId { +fn other_collateral_definition_id() -> AccountId { + AccountId::new([0x21u8; 32]) +} + +fn user_collateral_holding_id() -> AccountId { AccountId::new([0x30u8; 32]) } -fn token_holding_account( - account_id: AccountId, - definition_id: AccountId, - balance: u128, -) -> AccountWithMetadata { - AccountWithMetadata { - account: Account { - program_owner: TOKEN_PROGRAM_ID, - balance: 0, - data: Data::from(&TokenHolding::Fungible { - definition_id, - balance, - }), - nonce: Nonce(0), - }, - is_authorized: false, - account_id, - } -} - -fn position_id() -> AccountId { - compute_position_pda( - STABLECOIN_PROGRAM_ID, - owner_id(), - collateral_definition_id(), - ) -} - -fn vault_id() -> AccountId { - compute_position_vault_pda(STABLECOIN_PROGRAM_ID, position_id()) -} - -fn owner_account() -> AccountWithMetadata { - AccountWithMetadata { - account: Account::default(), - is_authorized: true, - account_id: owner_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 user_holding_account(balance: u128) -> AccountWithMetadata { - let mut account = token_holding_account(user_holding_id(), collateral_definition_id(), balance); - account.is_authorized = true; - account -} - -fn uninit_position_account() -> AccountWithMetadata { - AccountWithMetadata { - account: Account::default(), - is_authorized: false, - account_id: position_id(), - } -} - -fn uninit_vault_account() -> AccountWithMetadata { - AccountWithMetadata { - account: Account::default(), - is_authorized: false, - account_id: vault_id(), - } -} - fn destination_holding_id() -> AccountId { AccountId::new([0x40u8; 32]) } -fn init_position_account(collateral_amount: u128, debt_amount: u128) -> AccountWithMetadata { - AccountWithMetadata { - account: Account { - program_owner: STABLECOIN_PROGRAM_ID, - balance: 0, - data: Data::from(&Position { - collateral_vault_id: vault_id(), - collateral_definition_id: collateral_definition_id(), - collateral_amount, - debt_amount, - }), - nonce: Nonce(0), - }, - is_authorized: false, - account_id: position_id(), - } -} - -fn init_vault_account() -> AccountWithMetadata { - token_holding_account(vault_id(), collateral_definition_id(), 0) -} - -fn destination_holding_account() -> AccountWithMetadata { - token_holding_account(destination_holding_id(), collateral_definition_id(), 0) -} - -fn stablecoin_definition_id() -> AccountId { - AccountId::new([0x50u8; 32]) -} - fn user_stablecoin_holding_id() -> AccountId { AccountId::new([0x60u8; 32]) } -fn stablecoin_definition_account() -> AccountWithMetadata { +fn oracle_id() -> AccountId { + AccountId::new([0x70u8; 32]) +} + +fn other_stablecoin_definition_id() -> AccountId { + AccountId::new([0x80u8; 32]) +} + +fn position_id() -> AccountId { + compute_position_pda(STABLECOIN_PROGRAM_ID, owner_id(), POSITION_NONCE) +} + +fn vault_id() -> AccountId { + compute_position_vault_pda(STABLECOIN_PROGRAM_ID, position_id()) +} + +fn account( + account_id: AccountId, + program_owner: ProgramId, + data: Data, + is_authorized: bool, +) -> AccountWithMetadata { AccountWithMetadata { account: Account { - program_owner: TOKEN_PROGRAM_ID, + program_owner, balance: 0, - data: Data::from(&TokenDefinition::Fungible { - name: "DAI".to_owned(), - total_supply: 1_000_000, - metadata_id: None, - authority: None, - }), + data, nonce: Nonce(0), }, - is_authorized: false, - account_id: stablecoin_definition_id(), + is_authorized, + account_id, } } -fn user_stablecoin_holding_account(balance: u128) -> AccountWithMetadata { - let mut account = token_holding_account( - user_stablecoin_holding_id(), - stablecoin_definition_id(), +fn uninit(account_id: AccountId) -> AccountWithMetadata { + AccountWithMetadata { + account: Account::default(), + is_authorized: false, + account_id, + } +} + +fn owner_account() -> AccountWithMetadata { + let mut owner = uninit(owner_id()); + owner.is_authorized = true; + owner +} + +fn admin_account() -> AccountWithMetadata { + let mut admin = uninit(admin_id()); + admin.is_authorized = true; + admin +} + +fn caller_account() -> AccountWithMetadata { + let mut caller = uninit(AccountId::new([0x13u8; 32])); + caller.is_authorized = true; + caller +} + +fn clock_account(timestamp: u64) -> AccountWithMetadata { + clock_account_with_id(CLOCK_01_PROGRAM_ACCOUNT_ID, timestamp) +} + +fn clock_account_with_id(account_id: AccountId, timestamp: u64) -> AccountWithMetadata { + account( + account_id, + CLOCK_PROGRAM_ID, + Data::try_from( + ClockAccountData { + block_id: 1, + timestamp, + } + .to_bytes(), + ) + .expect("clock data fits"), + false, + ) +} + +fn malformed_clock_account() -> AccountWithMetadata { + account( + CLOCK_01_PROGRAM_ACCOUNT_ID, + CLOCK_PROGRAM_ID, + Data::try_from(vec![0xff; 3]).expect("malformed clock bytes fit"), + false, + ) +} + +fn collateral_definition_account() -> AccountWithMetadata { + collateral_definition_account_with_id(collateral_definition_id()) +} + +fn collateral_definition_account_with_id(account_id: AccountId) -> AccountWithMetadata { + account( + account_id, + TOKEN_PROGRAM_ID, + Data::from(&TokenDefinition::Fungible { + name: "COL".to_owned(), + total_supply: 1_000_000, + metadata_id: None, + authority: None, + }), + false, + ) +} + +fn user_collateral_holding(balance: u128) -> AccountWithMetadata { + account( + user_collateral_holding_id(), + TOKEN_PROGRAM_ID, + Data::from(&TokenHolding::Fungible { + definition_id: collateral_definition_id(), + balance, + }), + true, + ) +} + +fn destination_holding() -> AccountWithMetadata { + account( + destination_holding_id(), + TOKEN_PROGRAM_ID, + Data::from(&TokenHolding::Fungible { + definition_id: collateral_definition_id(), + balance: 0, + }), + false, + ) +} + +fn stablecoin_definition_account(total_supply: u128) -> AccountWithMetadata { + stablecoin_definition_account_with_id( + compute_stablecoin_definition_pda(STABLECOIN_PROGRAM_ID), + total_supply, + ) +} + +fn stablecoin_definition_account_with_id( + account_id: AccountId, + total_supply: u128, +) -> AccountWithMetadata { + account( + account_id, + TOKEN_PROGRAM_ID, + Data::from(&TokenDefinition::Fungible { + name: "STBL".to_owned(), + total_supply, + metadata_id: None, + authority: None, + }), + false, + ) +} + +fn user_stablecoin_holding(balance: u128) -> AccountWithMetadata { + user_stablecoin_holding_with_definition( + compute_stablecoin_definition_pda(STABLECOIN_PROGRAM_ID), balance, - ); - account.is_authorized = true; - account + ) +} + +fn user_stablecoin_holding_with_definition( + definition_id: AccountId, + balance: u128, +) -> AccountWithMetadata { + account( + user_stablecoin_holding_id(), + TOKEN_PROGRAM_ID, + Data::from(&TokenHolding::Fungible { + definition_id, + balance, + }), + true, + ) +} + +fn protocol_parameters(is_frozen: bool) -> ProtocolParameters { + ProtocolParameters { + admin_account_id: admin_id(), + freeze_authority_account_id: freeze_authority_id(), + stablecoin_definition_id: compute_stablecoin_definition_pda(STABLECOIN_PROGRAM_ID), + collateral_definition_id: collateral_definition_id(), + market_price_oracle_id: oracle_id(), + stability_fee_per_millisecond: FIXED_POINT_ONE, + controller_proportional_gain: 0, + controller_integral_gain: 0, + minimum_collateralization_ratio: FIXED_POINT_ONE / 10 * 11, + minimum_milliseconds_between_rate_updates: 1, + maximum_oracle_price_age_milliseconds: 86_400_000, + is_frozen, + } +} + +fn protocol_parameters_account(is_frozen: bool) -> AccountWithMetadata { + protocol_parameters_account_with_id( + compute_protocol_parameters_pda(STABLECOIN_PROGRAM_ID), + is_frozen, + ) +} + +fn protocol_parameters_account_with_id( + account_id: AccountId, + is_frozen: bool, +) -> AccountWithMetadata { + account( + account_id, + STABLECOIN_PROGRAM_ID, + Data::from(&protocol_parameters(is_frozen)), + false, + ) +} + +fn stability_fee_accumulator_account(anchor: u128, last_accrued_at: u64) -> AccountWithMetadata { + account( + compute_stability_fee_accumulator_pda(STABLECOIN_PROGRAM_ID), + STABLECOIN_PROGRAM_ID, + Data::from(&StabilityFeeAccumulator { + accumulated_rate_at_last_accrual: anchor, + last_accrued_at, + }), + false, + ) +} + +fn redemption_price_state_account(price: u128, last_updated_at: u64) -> AccountWithMetadata { + account( + compute_redemption_price_state_pda(STABLECOIN_PROGRAM_ID), + STABLECOIN_PROGRAM_ID, + Data::from(&RedemptionPriceState { + redemption_price_at_last_update: price, + redemption_rate_per_millisecond: FIXED_POINT_ONE, + controller_integral_term: 0, + last_updated_at, + }), + false, + ) +} + +fn oracle_account(timestamp: u64) -> AccountWithMetadata { + oracle_account_with_assets( + compute_stablecoin_definition_pda(STABLECOIN_PROGRAM_ID), + collateral_definition_id(), + timestamp, + ) +} + +fn oracle_account_with_assets( + base_asset: AccountId, + quote_asset: AccountId, + timestamp: u64, +) -> AccountWithMetadata { + account( + oracle_id(), + ORACLE_PROGRAM_ID, + Data::from(&OraclePriceAccount { + base_asset, + quote_asset, + price: 1u128 << 64, + timestamp, + source_id: AccountId::new([0x71u8; 32]), + confidence_interval: 0, + }), + false, + ) +} + +fn position_account(collateral_amount: u128, normalized_debt_amount: u128) -> AccountWithMetadata { + account( + position_id(), + STABLECOIN_PROGRAM_ID, + Data::from(&Position { + owner_account_id: owner_id(), + position_nonce: POSITION_NONCE, + vault_account_id: vault_id(), + collateral_amount, + normalized_debt_amount, + opened_at: 1_000, + }), + false, + ) +} + +fn vault_account() -> AccountWithMetadata { + vault_account_with_definition(collateral_definition_id()) +} + +fn vault_account_with_definition(definition_id: AccountId) -> AccountWithMetadata { + account( + vault_id(), + TOKEN_PROGRAM_ID, + Data::from(&TokenHolding::Fungible { + definition_id, + balance: 0, + }), + false, + ) +} + +fn decode_position(post_state: &AccountPostState) -> Position { + Position::try_from(&post_state.account().data).expect("post state must hold Position") +} + +fn decode_accumulator(post_state: &AccountPostState) -> StabilityFeeAccumulator { + StabilityFeeAccumulator::try_from(&post_state.account().data) + .expect("post state must hold StabilityFeeAccumulator") } #[test] -fn open_position_claims_pda_and_emits_chained_calls() { - let collateral_amount: u128 = 500; +fn initialize_program_creates_globals_and_stablecoin_definition_call() { + let (post_states, chained_calls) = crate::initialize_program::initialize_program( + admin_account(), + uninit(compute_protocol_parameters_pda(STABLECOIN_PROGRAM_ID)), + uninit(compute_stability_fee_accumulator_pda(STABLECOIN_PROGRAM_ID)), + uninit(compute_redemption_price_state_pda(STABLECOIN_PROGRAM_ID)), + uninit(compute_stablecoin_definition_pda(STABLECOIN_PROGRAM_ID)), + uninit(compute_stablecoin_master_holding_pda(STABLECOIN_PROGRAM_ID)), + collateral_definition_account(), + oracle_account(1_000), + clock_account(1_000), + STABLECOIN_PROGRAM_ID, + freeze_authority_id(), + FIXED_POINT_ONE, + 0, + 0, + FIXED_POINT_ONE / 10 * 11, + 1, + 86_400_000, + FIXED_POINT_ONE, + "STBL".to_owned(), + ); + + assert_eq!(post_states.len(), 9); + assert_eq!( + post_states[1].required_claim(), + Some(Claim::Pda(compute_protocol_parameters_pda_seed())) + ); + assert_eq!( + post_states[2].required_claim(), + Some(Claim::Pda(compute_stability_fee_accumulator_pda_seed())) + ); + assert_eq!( + post_states[3].required_claim(), + Some(Claim::Pda(compute_redemption_price_state_pda_seed())) + ); + let params = ProtocolParameters::try_from(&post_states[1].account().data) + .expect("valid ProtocolParameters"); + assert_eq!(params.admin_account_id, admin_id()); + let accumulator = decode_accumulator(&post_states[2]); + assert_eq!( + accumulator.accumulated_rate_at_last_accrual, + FIXED_POINT_ONE + ); + assert_eq!(accumulator.last_accrued_at, 1_000); + + let mut stablecoin_definition = + uninit(compute_stablecoin_definition_pda(STABLECOIN_PROGRAM_ID)); + stablecoin_definition.is_authorized = true; + let stablecoin_definition_id = stablecoin_definition.account_id; + let mut stablecoin_master = + uninit(compute_stablecoin_master_holding_pda(STABLECOIN_PROGRAM_ID)); + stablecoin_master.is_authorized = true; + let expected = ChainedCall::new( + TOKEN_PROGRAM_ID, + vec![stablecoin_definition, stablecoin_master], + &token_core::Instruction::NewFungibleDefinition { + name: "STBL".to_owned(), + total_supply: 0, + mint_authority: Some(stablecoin_definition_id), + }, + ) + .with_pda_seeds(vec![ + compute_stablecoin_definition_pda_seed(), + compute_stablecoin_master_holding_pda_seed(), + ]); + assert_eq!(chained_calls, vec![expected]); +} + +#[test] +#[should_panic(expected = "Admin authorization is missing")] +fn initialize_program_rejects_unauthorized_admin() { + let mut admin = admin_account(); + admin.is_authorized = false; + + crate::initialize_program::initialize_program( + admin, + uninit(compute_protocol_parameters_pda(STABLECOIN_PROGRAM_ID)), + uninit(compute_stability_fee_accumulator_pda(STABLECOIN_PROGRAM_ID)), + uninit(compute_redemption_price_state_pda(STABLECOIN_PROGRAM_ID)), + uninit(compute_stablecoin_definition_pda(STABLECOIN_PROGRAM_ID)), + uninit(compute_stablecoin_master_holding_pda(STABLECOIN_PROGRAM_ID)), + collateral_definition_account(), + oracle_account(1_000), + clock_account(1_000), + STABLECOIN_PROGRAM_ID, + freeze_authority_id(), + FIXED_POINT_ONE, + 0, + 0, + FIXED_POINT_ONE / 10 * 11, + 1, + 86_400_000, + FIXED_POINT_ONE, + "STBL".to_owned(), + ); +} + +#[test] +#[should_panic(expected = "Clock account must be the canonical 1-block LEZ clock account")] +fn initialize_program_rejects_wrong_clock_account() { + crate::initialize_program::initialize_program( + admin_account(), + uninit(compute_protocol_parameters_pda(STABLECOIN_PROGRAM_ID)), + uninit(compute_stability_fee_accumulator_pda(STABLECOIN_PROGRAM_ID)), + uninit(compute_redemption_price_state_pda(STABLECOIN_PROGRAM_ID)), + uninit(compute_stablecoin_definition_pda(STABLECOIN_PROGRAM_ID)), + uninit(compute_stablecoin_master_holding_pda(STABLECOIN_PROGRAM_ID)), + collateral_definition_account(), + oracle_account(1_000), + clock_account_with_id(CLOCK_10_PROGRAM_ACCOUNT_ID, 1_000), + STABLECOIN_PROGRAM_ID, + freeze_authority_id(), + FIXED_POINT_ONE, + 0, + 0, + FIXED_POINT_ONE / 10 * 11, + 1, + 86_400_000, + FIXED_POINT_ONE, + "STBL".to_owned(), + ); +} + +#[test] +#[should_panic(expected = "Stability fee per millisecond is out of bounds")] +fn initialize_program_rejects_stability_fee_below_one() { + crate::initialize_program::initialize_program( + admin_account(), + uninit(compute_protocol_parameters_pda(STABLECOIN_PROGRAM_ID)), + uninit(compute_stability_fee_accumulator_pda(STABLECOIN_PROGRAM_ID)), + uninit(compute_redemption_price_state_pda(STABLECOIN_PROGRAM_ID)), + uninit(compute_stablecoin_definition_pda(STABLECOIN_PROGRAM_ID)), + uninit(compute_stablecoin_master_holding_pda(STABLECOIN_PROGRAM_ID)), + collateral_definition_account(), + oracle_account(1_000), + clock_account(1_000), + STABLECOIN_PROGRAM_ID, + freeze_authority_id(), + FIXED_POINT_ONE - 1, + 0, + 0, + FIXED_POINT_ONE / 10 * 11, + 1, + 86_400_000, + FIXED_POINT_ONE, + "STBL".to_owned(), + ); +} + +#[test] +#[should_panic(expected = "Protocol parameters account must be uninitialized")] +fn initialize_program_rejects_initialized_protocol_parameters_pda() { + crate::initialize_program::initialize_program( + admin_account(), + protocol_parameters_account(false), + uninit(compute_stability_fee_accumulator_pda(STABLECOIN_PROGRAM_ID)), + uninit(compute_redemption_price_state_pda(STABLECOIN_PROGRAM_ID)), + uninit(compute_stablecoin_definition_pda(STABLECOIN_PROGRAM_ID)), + uninit(compute_stablecoin_master_holding_pda(STABLECOIN_PROGRAM_ID)), + collateral_definition_account(), + oracle_account(1_000), + clock_account(1_000), + STABLECOIN_PROGRAM_ID, + freeze_authority_id(), + FIXED_POINT_ONE, + 0, + 0, + FIXED_POINT_ONE / 10 * 11, + 1, + 86_400_000, + FIXED_POINT_ONE, + "STBL".to_owned(), + ); +} + +#[test] +#[should_panic(expected = "Collateral definition account must be initialized")] +fn initialize_program_rejects_uninitialized_collateral_definition() { + crate::initialize_program::initialize_program( + admin_account(), + uninit(compute_protocol_parameters_pda(STABLECOIN_PROGRAM_ID)), + uninit(compute_stability_fee_accumulator_pda(STABLECOIN_PROGRAM_ID)), + uninit(compute_redemption_price_state_pda(STABLECOIN_PROGRAM_ID)), + uninit(compute_stablecoin_definition_pda(STABLECOIN_PROGRAM_ID)), + uninit(compute_stablecoin_master_holding_pda(STABLECOIN_PROGRAM_ID)), + uninit(collateral_definition_id()), + oracle_account(1_000), + clock_account(1_000), + STABLECOIN_PROGRAM_ID, + freeze_authority_id(), + FIXED_POINT_ONE, + 0, + 0, + FIXED_POINT_ONE / 10 * 11, + 1, + 86_400_000, + FIXED_POINT_ONE, + "STBL".to_owned(), + ); +} + +#[test] +#[should_panic(expected = "Market price oracle account must be initialized")] +fn initialize_program_rejects_uninitialized_market_price_oracle() { + crate::initialize_program::initialize_program( + admin_account(), + uninit(compute_protocol_parameters_pda(STABLECOIN_PROGRAM_ID)), + uninit(compute_stability_fee_accumulator_pda(STABLECOIN_PROGRAM_ID)), + uninit(compute_redemption_price_state_pda(STABLECOIN_PROGRAM_ID)), + uninit(compute_stablecoin_definition_pda(STABLECOIN_PROGRAM_ID)), + uninit(compute_stablecoin_master_holding_pda(STABLECOIN_PROGRAM_ID)), + collateral_definition_account(), + uninit(oracle_id()), + clock_account(1_000), + STABLECOIN_PROGRAM_ID, + freeze_authority_id(), + FIXED_POINT_ONE, + 0, + 0, + FIXED_POINT_ONE / 10 * 11, + 1, + 86_400_000, + FIXED_POINT_ONE, + "STBL".to_owned(), + ); +} + +#[test] +#[should_panic(expected = "Market price oracle quote asset must be the collateral definition")] +fn initialize_program_rejects_oracle_quote_mismatch() { + crate::initialize_program::initialize_program( + admin_account(), + uninit(compute_protocol_parameters_pda(STABLECOIN_PROGRAM_ID)), + uninit(compute_stability_fee_accumulator_pda(STABLECOIN_PROGRAM_ID)), + uninit(compute_redemption_price_state_pda(STABLECOIN_PROGRAM_ID)), + uninit(compute_stablecoin_definition_pda(STABLECOIN_PROGRAM_ID)), + uninit(compute_stablecoin_master_holding_pda(STABLECOIN_PROGRAM_ID)), + collateral_definition_account(), + oracle_account_with_assets( + compute_stablecoin_definition_pda(STABLECOIN_PROGRAM_ID), + other_collateral_definition_id(), + 1_000, + ), + clock_account(1_000), + STABLECOIN_PROGRAM_ID, + freeze_authority_id(), + FIXED_POINT_ONE, + 0, + 0, + FIXED_POINT_ONE / 10 * 11, + 1, + 86_400_000, + FIXED_POINT_ONE, + "STBL".to_owned(), + ); +} + +#[test] +fn accrue_stability_fee_rolls_anchor_forward() { + let mut params = protocol_parameters(false); + params.stability_fee_per_millisecond = FIXED_POINT_ONE + FIXED_POINT_ONE / 10; + let protocol = account( + compute_protocol_parameters_pda(STABLECOIN_PROGRAM_ID), + STABLECOIN_PROGRAM_ID, + Data::from(¶ms), + false, + ); + let accumulator = stability_fee_accumulator_account(FIXED_POINT_ONE, 1_000); + + let (post_states, chained_calls) = crate::accrue_stability_fee::accrue_stability_fee( + caller_account(), + protocol, + accumulator, + clock_account(1_002), + STABLECOIN_PROGRAM_ID, + ); + + assert!(chained_calls.is_empty()); + let updated = decode_accumulator(&post_states[2]); + let expected_factor = compound_rate(params.stability_fee_per_millisecond, 2); + assert_eq!(updated.accumulated_rate_at_last_accrual, expected_factor); + assert_eq!(updated.last_accrued_at, 1_002); +} + +#[test] +fn accrue_stability_fee_clamps_elapsed_window() { + let mut params = protocol_parameters(false); + params.stability_fee_per_millisecond = FIXED_POINT_ONE + 1; + let protocol = account( + compute_protocol_parameters_pda(STABLECOIN_PROGRAM_ID), + STABLECOIN_PROGRAM_ID, + Data::from(¶ms), + false, + ); + + let (post_states, chained_calls) = crate::accrue_stability_fee::accrue_stability_fee( + caller_account(), + protocol, + stability_fee_accumulator_account(FIXED_POINT_ONE, 0), + clock_account(MAXIMUM_COMPOUNDING_WINDOW_MILLISECONDS + 1), + STABLECOIN_PROGRAM_ID, + ); + + assert!(chained_calls.is_empty()); + let updated = decode_accumulator(&post_states[2]); + assert_eq!( + updated.accumulated_rate_at_last_accrual, + compound_rate( + params.stability_fee_per_millisecond, + MAXIMUM_COMPOUNDING_WINDOW_MILLISECONDS + ) + ); + assert_eq!( + updated.last_accrued_at, + MAXIMUM_COMPOUNDING_WINDOW_MILLISECONDS + 1 + ); +} + +#[test] +#[should_panic(expected = "Caller authorization is missing")] +fn accrue_stability_fee_rejects_unauthorized_caller() { + let mut caller = caller_account(); + caller.is_authorized = false; + + crate::accrue_stability_fee::accrue_stability_fee( + caller, + protocol_parameters_account(false), + stability_fee_accumulator_account(FIXED_POINT_ONE, 1_000), + clock_account(1_000), + STABLECOIN_PROGRAM_ID, + ); +} + +#[test] +#[should_panic(expected = "Clock account must be initialized")] +fn accrue_stability_fee_rejects_uninitialized_clock() { + crate::accrue_stability_fee::accrue_stability_fee( + caller_account(), + protocol_parameters_account(false), + stability_fee_accumulator_account(FIXED_POINT_ONE, 1_000), + uninit(CLOCK_01_PROGRAM_ACCOUNT_ID), + STABLECOIN_PROGRAM_ID, + ); +} + +#[test] +#[should_panic(expected = "Clock account must hold valid ClockAccountData")] +fn accrue_stability_fee_rejects_malformed_clock_data() { + crate::accrue_stability_fee::accrue_stability_fee( + caller_account(), + protocol_parameters_account(false), + stability_fee_accumulator_account(FIXED_POINT_ONE, 1_000), + malformed_clock_account(), + STABLECOIN_PROGRAM_ID, + ); +} + +#[test] +fn set_stability_fee_accrues_old_rate_before_update() { + let mut params = protocol_parameters(false); + params.stability_fee_per_millisecond = FIXED_POINT_ONE + FIXED_POINT_ONE / 10; + let protocol = account( + compute_protocol_parameters_pda(STABLECOIN_PROGRAM_ID), + STABLECOIN_PROGRAM_ID, + Data::from(¶ms), + false, + ); + + let (post_states, _chained_calls) = + crate::set_stability_fee_per_millisecond::set_stability_fee_per_millisecond( + admin_account(), + protocol, + stability_fee_accumulator_account(FIXED_POINT_ONE, 1_000), + clock_account(1_002), + STABLECOIN_PROGRAM_ID, + FIXED_POINT_ONE, + ); + + let updated_params = ProtocolParameters::try_from(&post_states[1].account().data) + .expect("valid ProtocolParameters"); + assert_eq!( + updated_params.stability_fee_per_millisecond, + FIXED_POINT_ONE + ); + let updated_accumulator = decode_accumulator(&post_states[2]); + assert_eq!( + updated_accumulator.accumulated_rate_at_last_accrual, + compound_rate(FIXED_POINT_ONE + FIXED_POINT_ONE / 10, 2) + ); +} + +#[test] +#[should_panic(expected = "Admin account does not match protocol admin")] +fn set_stability_fee_rejects_non_admin() { + crate::set_stability_fee_per_millisecond::set_stability_fee_per_millisecond( + owner_account(), + protocol_parameters_account(false), + stability_fee_accumulator_account(FIXED_POINT_ONE, 1_000), + clock_account(1_000), + STABLECOIN_PROGRAM_ID, + FIXED_POINT_ONE, + ); +} + +#[test] +#[should_panic(expected = "Stability fee per millisecond is out of bounds")] +fn set_stability_fee_rejects_rate_below_one() { + crate::set_stability_fee_per_millisecond::set_stability_fee_per_millisecond( + admin_account(), + protocol_parameters_account(false), + stability_fee_accumulator_account(FIXED_POINT_ONE, 1_000), + clock_account(1_000), + STABLECOIN_PROGRAM_ID, + FIXED_POINT_ONE - 1, + ); +} + +#[test] +fn open_position_stores_normalized_position_and_emits_token_calls() { let (post_states, chained_calls) = crate::open_position::open_position( owner_account(), - uninit_position_account(), - uninit_vault_account(), - user_holding_account(1_000), + uninit(position_id()), + uninit(vault_id()), + user_collateral_holding(1_000), collateral_definition_account(), + protocol_parameters_account(false), + clock_account(1_000), STABLECOIN_PROGRAM_ID, - collateral_amount, + POSITION_NONCE, + 500, ); - assert_eq!(post_states.len(), 5); - - // Position is PDA-claimed and carries the encoded Position state. - let position_post = &post_states[1]; + let position = decode_position(&post_states[1]); assert_eq!( - position_post.required_claim(), + position, + Position { + owner_account_id: owner_id(), + position_nonce: POSITION_NONCE, + vault_account_id: vault_id(), + collateral_amount: 500, + normalized_debt_amount: 0, + opened_at: 1_000, + } + ); + assert_eq!( + post_states[1].required_claim(), Some(Claim::Pda(compute_position_pda_seed( owner_id(), - collateral_definition_id() + POSITION_NONCE ))) ); - let position = Position::try_from(&position_post.account().data).expect("valid Position"); - assert_eq!( - position, - Position { - collateral_vault_id: vault_id(), - collateral_definition_id: collateral_definition_id(), - collateral_amount, - debt_amount: 0, - } - ); - // The runtime sets the program_owner on the claimed account after validating Claim::Pda. - assert_eq!(position_post.account().program_owner, ProgramId::default()); - assert_eq!(chained_calls.len(), 2); - - let mut vault_authorized = uninit_vault_account(); - vault_authorized.is_authorized = true; - let expected_initialize = ChainedCall::new( - TOKEN_PROGRAM_ID, - vec![collateral_definition_account(), vault_authorized], - &token_core::Instruction::InitializeAccount, - ) - .with_pda_seeds(vec![compute_position_vault_pda_seed(position_id())]); - assert_eq!(chained_calls[0], expected_initialize); - - let post_init_vault = AccountWithMetadata { - account: Account { - program_owner: TOKEN_PROGRAM_ID, - balance: 0, - data: Data::from(&TokenHolding::Fungible { - definition_id: collateral_definition_id(), - balance: 0, - }), - nonce: Nonce(0), - }, - is_authorized: false, - account_id: vault_id(), - }; - let expected_transfer = ChainedCall::new( - TOKEN_PROGRAM_ID, - vec![user_holding_account(1_000), post_init_vault], - &token_core::Instruction::Transfer { - amount_to_transfer: collateral_amount, - }, - ); - assert_eq!(chained_calls[1], expected_transfer); } #[test] -#[should_panic(expected = "Owner authorization is missing")] -fn open_position_requires_owner_authorization() { - let mut owner = owner_account(); - owner.is_authorized = false; - +#[should_panic(expected = "Protocol is frozen; opening positions is disabled")] +fn open_position_rejects_frozen_protocol() { crate::open_position::open_position( - owner, - uninit_position_account(), - uninit_vault_account(), - user_holding_account(1_000), + owner_account(), + uninit(position_id()), + uninit(vault_id()), + user_collateral_holding(1_000), collateral_definition_account(), + protocol_parameters_account(true), + clock_account(1_000), STABLECOIN_PROGRAM_ID, + POSITION_NONCE, 500, ); } #[test] -#[should_panic(expected = "User collateral holding authorization is missing")] -fn open_position_requires_user_holding_authorization() { - let mut holding = user_holding_account(1_000); - holding.is_authorized = false; - +#[should_panic(expected = "Protocol parameters account ID does not match PDA")] +fn open_position_rejects_wrong_protocol_parameters_pda() { crate::open_position::open_position( owner_account(), - uninit_position_account(), - uninit_vault_account(), - holding, + uninit(position_id()), + uninit(vault_id()), + user_collateral_holding(1_000), collateral_definition_account(), + protocol_parameters_account_with_id(AccountId::new([0x90u8; 32]), false), + clock_account(1_000), STABLECOIN_PROGRAM_ID, + POSITION_NONCE, 500, ); } #[test] -#[should_panic(expected = "Position account must be uninitialized")] -fn open_position_rejects_initialized_position() { - let position = AccountWithMetadata { - account: Account { - program_owner: STABLECOIN_PROGRAM_ID, - balance: 0, - data: Data::from(&Position { - collateral_vault_id: vault_id(), - collateral_definition_id: collateral_definition_id(), - collateral_amount: 1, - debt_amount: 0, - }), - nonce: Nonce(0), - }, - is_authorized: false, - account_id: position_id(), - }; - +#[should_panic(expected = "Collateral definition does not match protocol parameters")] +fn open_position_rejects_wrong_collateral_definition() { crate::open_position::open_position( owner_account(), - position, - uninit_vault_account(), - user_holding_account(1_000), + uninit(position_id()), + uninit(vault_id()), + user_collateral_holding(1_000), + collateral_definition_account_with_id(other_collateral_definition_id()), + protocol_parameters_account(false), + clock_account(1_000), + STABLECOIN_PROGRAM_ID, + POSITION_NONCE, + 500, + ); +} + +#[test] +#[should_panic(expected = "Clock account must be the canonical 1-block LEZ clock account")] +fn open_position_rejects_wrong_clock_account() { + crate::open_position::open_position( + owner_account(), + uninit(position_id()), + uninit(vault_id()), + user_collateral_holding(1_000), collateral_definition_account(), + protocol_parameters_account(false), + clock_account_with_id(CLOCK_10_PROGRAM_ACCOUNT_ID, 1_000), STABLECOIN_PROGRAM_ID, + POSITION_NONCE, 500, ); } #[test] -#[should_panic(expected = "Position vault account must be uninitialized")] -fn open_position_rejects_initialized_vault() { - let vault = AccountWithMetadata { - account: Account { - program_owner: TOKEN_PROGRAM_ID, - balance: 0, - data: Data::from(&TokenHolding::Fungible { - definition_id: collateral_definition_id(), - balance: 0, - }), - nonce: Nonce(0), - }, - is_authorized: false, - account_id: vault_id(), - }; - - crate::open_position::open_position( +fn generate_debt_increases_normalized_debt_and_mints_exact_amount() { + let amount = 100; + let (post_states, chained_calls) = crate::generate_debt::generate_debt( owner_account(), - uninit_position_account(), - vault, - user_holding_account(1_000), - collateral_definition_account(), - STABLECOIN_PROGRAM_ID, - 500, - ); -} - -#[test] -#[should_panic(expected = "Position account ID does not match expected derivation")] -fn open_position_rejects_wrong_position_address() { - let bad_position = AccountWithMetadata { - account: Account::default(), - is_authorized: false, - account_id: AccountId::new([0xFFu8; 32]), - }; - - crate::open_position::open_position( - owner_account(), - bad_position, - uninit_vault_account(), - user_holding_account(1_000), - collateral_definition_account(), - STABLECOIN_PROGRAM_ID, - 500, - ); -} - -#[test] -#[should_panic(expected = "Position vault account ID does not match expected derivation")] -fn open_position_rejects_wrong_vault_address() { - let bad_vault = AccountWithMetadata { - account: Account::default(), - is_authorized: false, - account_id: AccountId::new([0xEEu8; 32]), - }; - - crate::open_position::open_position( - owner_account(), - uninit_position_account(), - bad_vault, - user_holding_account(1_000), - collateral_definition_account(), - STABLECOIN_PROGRAM_ID, - 500, - ); -} - -#[test] -#[should_panic(expected = "User collateral holding does not match the provided token definition")] -fn open_position_rejects_mismatched_token_definition() { - let other_definition = AccountWithMetadata { - account: Account { - program_owner: TOKEN_PROGRAM_ID, - balance: 0, - data: Data::from(&TokenDefinition::Fungible { - name: "OTHER".to_owned(), - total_supply: 1, - metadata_id: None, - authority: None, - }), - nonce: Nonce(0), - }, - is_authorized: false, - account_id: AccountId::new([0x21u8; 32]), - }; - - crate::open_position::open_position( - owner_account(), - uninit_position_account(), - uninit_vault_account(), - user_holding_account(1_000), - other_definition, - STABLECOIN_PROGRAM_ID, - 500, - ); -} - -#[test] -#[should_panic( - expected = "Collateral token definition is not owned by the user holding's Token Program" -)] -fn open_position_rejects_definition_with_wrong_token_program() { - let mut definition = collateral_definition_account(); - definition.account.program_owner = [9u32; 8]; - - crate::open_position::open_position( - owner_account(), - uninit_position_account(), - uninit_vault_account(), - user_holding_account(1_000), - definition, - STABLECOIN_PROGRAM_ID, - 500, - ); -} - -#[test] -fn position_pda_is_deterministic_and_owner_and_collateral_specific() { - let id_a = compute_position_pda( - STABLECOIN_PROGRAM_ID, - owner_id(), - collateral_definition_id(), - ); - let id_b = compute_position_pda( - STABLECOIN_PROGRAM_ID, - owner_id(), - collateral_definition_id(), - ); - assert_eq!(id_a, id_b); - - let other_owner = AccountId::new([0x11u8; 32]); - assert_ne!( - compute_position_pda( - STABLECOIN_PROGRAM_ID, - other_owner, - collateral_definition_id() - ), - id_a - ); - - let other_definition = AccountId::new([0x21u8; 32]); - assert_ne!( - compute_position_pda(STABLECOIN_PROGRAM_ID, owner_id(), other_definition), - id_a - ); -} - -#[test] -fn position_pda_and_vault_pda_do_not_collide() { - // Distinct domain tags must keep the position id and its vault id disjoint. - let position = compute_position_pda( - STABLECOIN_PROGRAM_ID, - owner_id(), - collateral_definition_id(), - ); - let vault = compute_position_vault_pda(STABLECOIN_PROGRAM_ID, position); - assert_ne!(position, vault); -} - -#[test] -fn withdraw_collateral_updates_position_and_emits_transfer() { - let initial_collateral: u128 = 500; - let amount: u128 = 200; - let (post_states, chained_calls) = crate::withdraw_collateral::withdraw_collateral( - owner_account(), - init_position_account(initial_collateral, 0), - init_vault_account(), - destination_holding_account(), + position_account(1_000, 0), + stablecoin_definition_account(0), + user_stablecoin_holding(0), + stability_fee_accumulator_account(FIXED_POINT_ONE, 1_000), + redemption_price_state_account(FIXED_POINT_ONE, 1_000), + oracle_account(1_000), + protocol_parameters_account(false), + clock_account(1_000), STABLECOIN_PROGRAM_ID, amount, ); - assert_eq!(post_states.len(), 4); - - // Position post-state: plain `new`, holds the decremented Position. - let position_post = &post_states[1]; - assert_eq!(position_post.required_claim(), None); - let position = Position::try_from(&position_post.account().data).expect("valid Position"); - assert_eq!( - position, - Position { - collateral_vault_id: vault_id(), - collateral_definition_id: collateral_definition_id(), - collateral_amount: initial_collateral - amount, - debt_amount: 0, - } - ); - assert_eq!(position_post.account().program_owner, STABLECOIN_PROGRAM_ID); - - // Vault and destination post-states are pre-transfer (mutation comes via chained call). - assert_eq!(post_states[2].account(), &init_vault_account().account); - assert_eq!( - post_states[3].account(), - &destination_holding_account().account - ); - - // Single chained Token::Transfer with vault PDA seed. - assert_eq!(chained_calls.len(), 1); - let mut vault_authorized = init_vault_account(); - vault_authorized.is_authorized = true; - let expected_transfer = ChainedCall::new( + let position = decode_position(&post_states[1]); + assert_eq!(position.normalized_debt_amount, amount); + let mut stablecoin_definition = stablecoin_definition_account(0); + stablecoin_definition.is_authorized = true; + let expected_mint = ChainedCall::new( TOKEN_PROGRAM_ID, - vec![vault_authorized, destination_holding_account()], - &token_core::Instruction::Transfer { - amount_to_transfer: amount, + vec![stablecoin_definition, user_stablecoin_holding(0)], + &token_core::Instruction::Mint { + amount_to_mint: amount, }, ) - .with_pda_seeds(vec![compute_position_vault_pda_seed(position_id())]); - assert_eq!(chained_calls[0], expected_transfer); + .with_pda_seeds(vec![compute_stablecoin_definition_pda_seed()]); + assert_eq!(chained_calls, vec![expected_mint]); } #[test] -fn withdraw_collateral_allows_full_drain() { - let amount: u128 = 500; - let (post_states, _chained_calls) = crate::withdraw_collateral::withdraw_collateral( +#[should_panic(expected = "Protocol is frozen; debt generation is disabled")] +fn generate_debt_rejects_frozen_protocol() { + crate::generate_debt::generate_debt( owner_account(), - init_position_account(amount, 0), - init_vault_account(), - destination_holding_account(), - STABLECOIN_PROGRAM_ID, - amount, - ); - let position = Position::try_from(&post_states[1].account().data).expect("valid Position"); - assert_eq!(position.collateral_amount, 0); - assert_eq!(position.debt_amount, 0); -} - -#[test] -fn withdraw_collateral_allows_zero_amount() { - let initial: u128 = 500; - let (post_states, chained_calls) = crate::withdraw_collateral::withdraw_collateral( - owner_account(), - init_position_account(initial, 0), - init_vault_account(), - destination_holding_account(), - STABLECOIN_PROGRAM_ID, - 0, - ); - let position = Position::try_from(&post_states[1].account().data).expect("valid Position"); - assert_eq!(position.collateral_amount, initial); - - let mut vault_authorized = init_vault_account(); - vault_authorized.is_authorized = true; - let expected_transfer = ChainedCall::new( - TOKEN_PROGRAM_ID, - vec![vault_authorized, destination_holding_account()], - &token_core::Instruction::Transfer { - amount_to_transfer: 0, - }, - ) - .with_pda_seeds(vec![compute_position_vault_pda_seed(position_id())]); - assert_eq!(chained_calls, vec![expected_transfer]); -} - -#[test] -#[should_panic(expected = "Owner authorization is missing")] -fn withdraw_collateral_requires_owner_authorization() { - let mut owner = owner_account(); - owner.is_authorized = false; - crate::withdraw_collateral::withdraw_collateral( - owner, - init_position_account(500, 0), - init_vault_account(), - destination_holding_account(), + position_account(1_000, 0), + stablecoin_definition_account(0), + user_stablecoin_holding(0), + stability_fee_accumulator_account(FIXED_POINT_ONE, 1_000), + redemption_price_state_account(FIXED_POINT_ONE, 1_000), + oracle_account(1_000), + protocol_parameters_account(true), + clock_account(1_000), STABLECOIN_PROGRAM_ID, 100, ); } #[test] -#[should_panic(expected = "Position account must be initialized")] -fn withdraw_collateral_rejects_uninitialized_position() { - crate::withdraw_collateral::withdraw_collateral( +#[should_panic(expected = "Stablecoin definition does not match protocol parameters")] +fn generate_debt_rejects_wrong_stablecoin_definition() { + crate::generate_debt::generate_debt( owner_account(), - uninit_position_account(), - init_vault_account(), - destination_holding_account(), + position_account(1_000, 0), + stablecoin_definition_account_with_id(other_stablecoin_definition_id(), 0), + user_stablecoin_holding(0), + stability_fee_accumulator_account(FIXED_POINT_ONE, 1_000), + redemption_price_state_account(FIXED_POINT_ONE, 1_000), + oracle_account(1_000), + protocol_parameters_account(false), + clock_account(1_000), STABLECOIN_PROGRAM_ID, 100, ); } #[test] -#[should_panic(expected = "Position is not owned by this stablecoin program")] -fn withdraw_collateral_rejects_position_owned_by_other_program() { - let mut position = init_position_account(500, 0); - position.account.program_owner = [9u32; 8]; - crate::withdraw_collateral::withdraw_collateral( - owner_account(), - position, - init_vault_account(), - destination_holding_account(), - STABLECOIN_PROGRAM_ID, - 100, - ); -} - -#[test] -#[should_panic(expected = "Position account ID does not match expected derivation")] -fn withdraw_collateral_rejects_wrong_position_address() { - let mut position = init_position_account(500, 0); - position.account_id = AccountId::new([0xFFu8; 32]); - crate::withdraw_collateral::withdraw_collateral( - owner_account(), - position, - init_vault_account(), - destination_holding_account(), - STABLECOIN_PROGRAM_ID, - 100, - ); -} - -#[test] -#[should_panic(expected = "Position vault account ID does not match expected derivation")] -fn withdraw_collateral_rejects_wrong_vault_address() { - let mut vault = init_vault_account(); - vault.account_id = AccountId::new([0xEEu8; 32]); - crate::withdraw_collateral::withdraw_collateral( - owner_account(), - init_position_account(500, 0), - vault, - destination_holding_account(), - STABLECOIN_PROGRAM_ID, - 100, - ); -} - -#[test] -#[should_panic(expected = "Vault token holding is not for the position's collateral definition")] -fn withdraw_collateral_rejects_vault_for_other_definition() { - let mut vault = init_vault_account(); - vault.account.data = Data::from(&TokenHolding::Fungible { - definition_id: AccountId::new([0x21u8; 32]), - balance: 0, - }); - crate::withdraw_collateral::withdraw_collateral( - owner_account(), - init_position_account(500, 0), - vault, - destination_holding_account(), - STABLECOIN_PROGRAM_ID, - 100, - ); -} - -#[test] -#[should_panic(expected = "Destination must be initialized")] -fn withdraw_collateral_rejects_uninitialized_destination() { - let destination = AccountWithMetadata { - account: Account::default(), - is_authorized: false, - account_id: destination_holding_id(), - }; - crate::withdraw_collateral::withdraw_collateral( - owner_account(), - init_position_account(500, 0), - init_vault_account(), - destination, - STABLECOIN_PROGRAM_ID, - 100, - ); -} - -#[test] -#[should_panic(expected = "Destination must be owned by the same Token Program as the vault")] -fn withdraw_collateral_rejects_destination_with_wrong_token_program() { - let mut destination = destination_holding_account(); - destination.account.program_owner = [9u32; 8]; - crate::withdraw_collateral::withdraw_collateral( - owner_account(), - init_position_account(500, 0), - init_vault_account(), - destination, - STABLECOIN_PROGRAM_ID, - 100, - ); -} - -#[test] -#[should_panic( - expected = "Destination token definition does not match the position's collateral definition" -)] -fn withdraw_collateral_rejects_destination_for_other_definition() { - let mut destination = destination_holding_account(); - destination.account.data = Data::from(&TokenHolding::Fungible { - definition_id: AccountId::new([0x21u8; 32]), - balance: 0, - }); - crate::withdraw_collateral::withdraw_collateral( - owner_account(), - init_position_account(500, 0), - init_vault_account(), - destination, - STABLECOIN_PROGRAM_ID, - 100, - ); -} - -#[test] -#[should_panic(expected = "withdraw_collateral with debt is not supported yet")] -fn withdraw_collateral_rejects_withdrawal_with_outstanding_debt() { - crate::withdraw_collateral::withdraw_collateral( - owner_account(), - init_position_account(500, 1), - init_vault_account(), - destination_holding_account(), - STABLECOIN_PROGRAM_ID, - 100, - ); -} - -#[test] -#[should_panic(expected = "Withdrawal amount exceeds position collateral")] -fn withdraw_collateral_rejects_overdraw() { - crate::withdraw_collateral::withdraw_collateral( - owner_account(), - init_position_account(100, 0), - init_vault_account(), - destination_holding_account(), - STABLECOIN_PROGRAM_ID, - 200, - ); -} - -#[test] -fn repay_debt_decreases_debt_and_emits_burn() { - let initial_collateral: u128 = 500; - let initial_debt: u128 = 300; - let amount: u128 = 100; - let holding_balance: u128 = 1_000; +fn repay_debt_uses_floor_rounding_against_current_accumulator() { + let accumulator = FIXED_POINT_ONE + FIXED_POINT_ONE / 10; let (post_states, chained_calls) = crate::repay_debt::repay_debt( owner_account(), - init_position_account(initial_collateral, initial_debt), - stablecoin_definition_account(), - user_stablecoin_holding_account(holding_balance), + position_account(1_000, 100), + stablecoin_definition_account(100), + user_stablecoin_holding(100), + stability_fee_accumulator_account(accumulator, 1_000), + protocol_parameters_account(false), + clock_account(1_000), STABLECOIN_PROGRAM_ID, - amount, + 11, ); - assert_eq!(post_states.len(), 4); - - // Position post-state: plain `new`, holds the decremented Position. - let position_post = &post_states[1]; - assert_eq!(position_post.required_claim(), None); - let position = Position::try_from(&position_post.account().data).expect("valid Position"); - assert_eq!( - position, - Position { - collateral_vault_id: vault_id(), - collateral_definition_id: collateral_definition_id(), - collateral_amount: initial_collateral, - debt_amount: initial_debt - amount, - } - ); - assert_eq!(position_post.account().program_owner, STABLECOIN_PROGRAM_ID); - - // Stablecoin definition and user holding post-states are pre-burn. - assert_eq!( - post_states[2].account(), - &stablecoin_definition_account().account - ); - assert_eq!( - post_states[3].account(), - &user_stablecoin_holding_account(holding_balance).account - ); - - // Single chained Token::Burn, no PDA seeds (user-authorized burn source). + let position = decode_position(&post_states[1]); + assert_eq!(position.normalized_debt_amount, 90); assert_eq!(chained_calls.len(), 1); - let expected_burn = ChainedCall::new( - TOKEN_PROGRAM_ID, - vec![ - stablecoin_definition_account(), - user_stablecoin_holding_account(holding_balance), - ], - &token_core::Instruction::Burn { - amount_to_burn: amount, - }, - ); - assert_eq!(chained_calls[0], expected_burn); } #[test] -fn repay_debt_allows_full_repayment() { - let debt: u128 = 300; - let (post_states, _chained_calls) = crate::repay_debt::repay_debt( - owner_account(), - init_position_account(500, debt), - stablecoin_definition_account(), - user_stablecoin_holding_account(1_000), - STABLECOIN_PROGRAM_ID, - debt, - ); - let position = Position::try_from(&post_states[1].account().data).expect("valid Position"); - assert_eq!(position.debt_amount, 0); - assert_eq!(position.collateral_amount, 500); -} - -#[test] -fn repay_debt_allows_zero_amount() { - let initial_debt: u128 = 300; - let (post_states, chained_calls) = crate::repay_debt::repay_debt( - owner_account(), - init_position_account(500, initial_debt), - stablecoin_definition_account(), - user_stablecoin_holding_account(1_000), - STABLECOIN_PROGRAM_ID, - 0, - ); - let position = Position::try_from(&post_states[1].account().data).expect("valid Position"); - assert_eq!(position.debt_amount, initial_debt); - - let expected_burn = ChainedCall::new( - TOKEN_PROGRAM_ID, - vec![ - stablecoin_definition_account(), - user_stablecoin_holding_account(1_000), - ], - &token_core::Instruction::Burn { amount_to_burn: 0 }, - ); - assert_eq!(chained_calls, vec![expected_burn]); -} - -#[test] -#[should_panic(expected = "Owner authorization is missing")] -fn repay_debt_requires_owner_authorization() { - let mut owner = owner_account(); - owner.is_authorized = false; - crate::repay_debt::repay_debt( - owner, - init_position_account(500, 300), - stablecoin_definition_account(), - user_stablecoin_holding_account(1_000), - STABLECOIN_PROGRAM_ID, - 100, - ); -} - -#[test] -#[should_panic(expected = "Position account must be initialized")] -fn repay_debt_rejects_uninitialized_position() { +#[should_panic(expected = "Repay amount is too small to reduce outstanding debt")] +fn repay_debt_rejects_nonzero_amount_that_rounds_to_zero() { crate::repay_debt::repay_debt( owner_account(), - uninit_position_account(), - stablecoin_definition_account(), - user_stablecoin_holding_account(1_000), + position_account(1_000, 100), + stablecoin_definition_account(100), + user_stablecoin_holding(100), + stability_fee_accumulator_account(FIXED_POINT_ONE * 2, 1_000), + protocol_parameters_account(false), + clock_account(1_000), STABLECOIN_PROGRAM_ID, - 100, - ); -} - -#[test] -#[should_panic(expected = "Position is not owned by this stablecoin program")] -fn repay_debt_rejects_position_owned_by_other_program() { - let mut position = init_position_account(500, 300); - position.account.program_owner = [9u32; 8]; - crate::repay_debt::repay_debt( - owner_account(), - position, - stablecoin_definition_account(), - user_stablecoin_holding_account(1_000), - STABLECOIN_PROGRAM_ID, - 100, - ); -} - -#[test] -#[should_panic(expected = "Position account ID does not match expected derivation")] -fn repay_debt_rejects_wrong_position_address() { - let mut position = init_position_account(500, 300); - position.account_id = AccountId::new([0xFFu8; 32]); - crate::repay_debt::repay_debt( - owner_account(), - position, - stablecoin_definition_account(), - user_stablecoin_holding_account(1_000), - STABLECOIN_PROGRAM_ID, - 100, - ); -} - -#[test] -#[should_panic(expected = "User stablecoin holding authorization is missing")] -fn repay_debt_requires_user_holding_authorization() { - let mut holding = user_stablecoin_holding_account(1_000); - holding.is_authorized = false; - crate::repay_debt::repay_debt( - owner_account(), - init_position_account(500, 300), - stablecoin_definition_account(), - holding, - STABLECOIN_PROGRAM_ID, - 100, - ); -} - -#[test] -#[should_panic(expected = "User stablecoin holding must be initialized")] -fn repay_debt_rejects_uninitialized_user_holding() { - let holding = AccountWithMetadata { - account: Account::default(), - is_authorized: true, - account_id: user_stablecoin_holding_id(), - }; - crate::repay_debt::repay_debt( - owner_account(), - init_position_account(500, 300), - stablecoin_definition_account(), - holding, - STABLECOIN_PROGRAM_ID, - 100, - ); -} - -#[test] -#[should_panic( - expected = "Stablecoin holding and definition must be owned by the same Token Program" -)] -fn repay_debt_rejects_holding_with_different_token_program() { - let mut holding = user_stablecoin_holding_account(1_000); - holding.account.program_owner = [9u32; 8]; - crate::repay_debt::repay_debt( - owner_account(), - init_position_account(500, 300), - stablecoin_definition_account(), - holding, - STABLECOIN_PROGRAM_ID, - 100, - ); -} - -#[test] -#[should_panic(expected = "Stablecoin holding does not match the provided stablecoin definition")] -fn repay_debt_rejects_holding_for_other_definition() { - let mut holding = user_stablecoin_holding_account(1_000); - holding.account.data = Data::from(&TokenHolding::Fungible { - definition_id: AccountId::new([0x21u8; 32]), - balance: 1_000, - }); - crate::repay_debt::repay_debt( - owner_account(), - init_position_account(500, 300), - stablecoin_definition_account(), - holding, - STABLECOIN_PROGRAM_ID, - 100, + 1, ); } #[test] #[should_panic(expected = "Repay amount exceeds outstanding debt")] -fn repay_debt_rejects_overrepay() { +fn repay_debt_rejects_amount_above_current_debt_ceiling() { + let accumulator = FIXED_POINT_ONE + FIXED_POINT_ONE / 10; + crate::repay_debt::repay_debt( owner_account(), - init_position_account(500, 100), - stablecoin_definition_account(), - user_stablecoin_holding_account(1_000), + position_account(1_000, 100), + stablecoin_definition_account(100), + user_stablecoin_holding(111), + stability_fee_accumulator_account(accumulator, 1_000), + protocol_parameters_account(false), + clock_account(1_000), STABLECOIN_PROGRAM_ID, - 200, + 111, + ); +} + +#[test] +#[should_panic(expected = "Stablecoin holding does not match the provided stablecoin definition")] +fn repay_debt_rejects_wrong_stablecoin_holding_definition() { + crate::repay_debt::repay_debt( + owner_account(), + position_account(1_000, 100), + stablecoin_definition_account(100), + user_stablecoin_holding_with_definition(other_stablecoin_definition_id(), 100), + stability_fee_accumulator_account(FIXED_POINT_ONE, 1_000), + protocol_parameters_account(false), + clock_account(1_000), + STABLECOIN_PROGRAM_ID, + 10, + ); +} + +#[test] +fn withdraw_collateral_allows_safe_withdrawal_and_rejects_unsafe_withdrawal() { + let safe = crate::withdraw_collateral::withdraw_collateral( + owner_account(), + position_account(600, 500), + vault_account(), + destination_holding(), + stability_fee_accumulator_account(FIXED_POINT_ONE, 1_000), + redemption_price_state_account(FIXED_POINT_ONE, 1_000), + protocol_parameters_account(false), + clock_account(1_000), + STABLECOIN_PROGRAM_ID, + 50, + ); + let position = decode_position(&safe.0[1]); + assert_eq!(position.collateral_amount, 550); + + let result = std::panic::catch_unwind(|| { + crate::withdraw_collateral::withdraw_collateral( + owner_account(), + position_account(600, 500), + vault_account(), + destination_holding(), + stability_fee_accumulator_account(FIXED_POINT_ONE, 1_000), + redemption_price_state_account(FIXED_POINT_ONE, 1_000), + protocol_parameters_account(false), + clock_account(1_000), + STABLECOIN_PROGRAM_ID, + 51, + ); + }); + assert!(result.is_err()); +} + +#[test] +#[should_panic(expected = "Protocol is frozen; collateral withdrawal is disabled")] +fn withdraw_collateral_rejects_frozen_protocol() { + crate::withdraw_collateral::withdraw_collateral( + owner_account(), + position_account(600, 500), + vault_account(), + destination_holding(), + stability_fee_accumulator_account(FIXED_POINT_ONE, 1_000), + redemption_price_state_account(FIXED_POINT_ONE, 1_000), + protocol_parameters_account(true), + clock_account(1_000), + STABLECOIN_PROGRAM_ID, + 50, + ); +} + +#[test] +#[should_panic(expected = "Vault token holding does not match protocol collateral definition")] +fn withdraw_collateral_rejects_wrong_vault_collateral_definition() { + crate::withdraw_collateral::withdraw_collateral( + owner_account(), + position_account(600, 500), + vault_account_with_definition(other_collateral_definition_id()), + destination_holding(), + stability_fee_accumulator_account(FIXED_POINT_ONE, 1_000), + redemption_price_state_account(FIXED_POINT_ONE, 1_000), + protocol_parameters_account(false), + clock_account(1_000), + STABLECOIN_PROGRAM_ID, + 50, ); } diff --git a/programs/stablecoin/src/withdraw_collateral.rs b/programs/stablecoin/src/withdraw_collateral.rs index 259c663..8050a0c 100644 --- a/programs/stablecoin/src/withdraw_collateral.rs +++ b/programs/stablecoin/src/withdraw_collateral.rs @@ -5,6 +5,11 @@ use nssa_core::{ use stablecoin_core::{verify_position_and_get_seed, verify_position_vault_and_get_seed, Position}; use token_core::TokenHolding; +use crate::shared::{ + read_clock_timestamp, read_protocol_parameters, read_redemption_price_state, + read_stability_fee_accumulator, +}; + /// Withdraw `amount` collateral tokens from `position`'s vault back to `destination`. /// /// Decreases `Position.collateral_amount` by `amount` and emits a single chained @@ -13,32 +18,49 @@ use token_core::TokenHolding; /// the initial PDA claim already happened in /// [`crate::open_position::open_position`]. /// -/// Until issues #95 / #96 / #97 land (redemption price, price feed, stability -/// fee accrual), this instruction hard-asserts `Position.debt_amount == 0`. -/// When those land, this guard is replaced by real fee accrual + a -/// collateralization-ratio check against the post-withdrawal collateral. -/// /// # Panics /// - `owner` is not authorized. /// - `position` is uninitialized, not owned by `stablecoin_program_id`, holds data that does not /// decode as a [`Position`], or sits at an address that does not match -/// `compute_position_pda(stablecoin_program_id, owner, Position.collateral_definition_id)`. +/// `compute_position_pda(stablecoin_program_id, owner, Position.position_nonce)`. /// - `vault` sits at an address that does not match /// `compute_position_vault_pda(stablecoin_program_id, position_id)`, or holds a [`TokenHolding`] -/// whose `definition_id` does not match the position's collateral definition. +/// whose `definition_id` does not match the protocol collateral definition. /// - `destination` is uninitialized, owned by a different Token Program than the vault, or holds a -/// [`TokenHolding`] whose `definition_id` does not match the position's collateral definition. -/// - `Position.debt_amount` is non-zero. +/// [`TokenHolding`] whose `definition_id` does not match the protocol collateral definition. /// - `amount > Position.collateral_amount`. +/// - the post-withdrawal position would be undercollateralized. +#[expect( + clippy::too_many_arguments, + reason = "instruction surface passes explicit position, vault, fee, redemption, and protocol accounts" +)] pub fn withdraw_collateral( owner: AccountWithMetadata, position: AccountWithMetadata, vault: AccountWithMetadata, destination: AccountWithMetadata, + stability_fee_accumulator: AccountWithMetadata, + redemption_price_state: AccountWithMetadata, + protocol_parameters: AccountWithMetadata, + clock: AccountWithMetadata, stablecoin_program_id: ProgramId, amount: u128, ) -> (Vec, Vec) { assert!(owner.is_authorized, "Owner authorization is missing"); + let params = read_protocol_parameters(&protocol_parameters, stablecoin_program_id); + assert!( + !params.is_frozen, + "Protocol is frozen; collateral withdrawal is disabled" + ); + let accumulator = + read_stability_fee_accumulator(&stability_fee_accumulator, stablecoin_program_id); + let redemption_state = + read_redemption_price_state(&redemption_price_state, stablecoin_program_id); + let now = read_clock_timestamp(&clock); + let current_accumulator = stablecoin_core::current_accumulated_rate(&accumulator, ¶ms, now); + let current_redemption_price = + stablecoin_core::current_redemption_price(&redemption_state, now); + assert_ne!( position.account, Account::default(), @@ -51,15 +73,23 @@ pub fn withdraw_collateral( let position_data = Position::try_from(&position.account.data) .expect("Position account must hold valid Position state"); + assert_eq!( + position_data.owner_account_id, owner.account_id, + "Position owner does not match signer" + ); // `verify_position_and_get_seed` asserts the position address matches the - // (owner, collateral_definition) PDA derivation. We do not use the seed + // (owner, position_nonce) PDA derivation. We do not use the seed // downstream — the position is already PDA-claimed. let _position_seed = verify_position_and_get_seed( &position, &owner, - position_data.collateral_definition_id, + position_data.position_nonce, stablecoin_program_id, ); + assert_eq!( + vault.account_id, position_data.vault_account_id, + "Vault account does not match position vault" + ); let vault_seed = verify_position_vault_and_get_seed(&vault, position.account_id, stablecoin_program_id); @@ -67,8 +97,8 @@ pub fn withdraw_collateral( .expect("Vault account must hold a valid TokenHolding"); assert_eq!( vault_holding.definition_id(), - position_data.collateral_definition_id, - "Vault token holding is not for the position's collateral definition" + params.collateral_definition_id, + "Vault token holding does not match protocol collateral definition" ); let token_program_id = vault.account.program_owner; @@ -85,24 +115,32 @@ pub fn withdraw_collateral( .expect("Destination account must hold a valid TokenHolding"); assert_eq!( destination_holding.definition_id(), - position_data.collateral_definition_id, - "Destination token definition does not match the position's collateral definition" + params.collateral_definition_id, + "Destination token definition does not match protocol collateral definition" ); - assert_eq!( - position_data.debt_amount, 0, - "withdraw_collateral with debt is not supported yet — stability fee accrual and collateralization check land with #97/#96" - ); let new_collateral = position_data .collateral_amount .checked_sub(amount) .expect("Withdrawal amount exceeds position collateral"); + assert!( + stablecoin_core::is_collateralized( + new_collateral, + position_data.normalized_debt_amount, + current_accumulator, + current_redemption_price, + params.minimum_collateralization_ratio, + ), + "Position would be undercollateralized after withdrawal" + ); let updated_position = Position { - collateral_vault_id: position_data.collateral_vault_id, - collateral_definition_id: position_data.collateral_definition_id, + owner_account_id: position_data.owner_account_id, + position_nonce: position_data.position_nonce, + vault_account_id: position_data.vault_account_id, collateral_amount: new_collateral, - debt_amount: position_data.debt_amount, + normalized_debt_amount: position_data.normalized_debt_amount, + opened_at: position_data.opened_at, }; let mut position_post = position.account.clone(); position_post.data = Data::from(&updated_position); @@ -112,6 +150,10 @@ pub fn withdraw_collateral( AccountPostState::new(position_post), AccountPostState::new(vault.account.clone()), AccountPostState::new(destination.account.clone()), + AccountPostState::new(stability_fee_accumulator.account), + AccountPostState::new(redemption_price_state.account), + AccountPostState::new(protocol_parameters.account), + AccountPostState::new(clock.account.clone()), ]; let mut vault_authorized = vault.clone();