diff --git a/artifacts/stablecoin-idl.json b/artifacts/stablecoin-idl.json index edcd7be3..fd0d43e1 100644 --- a/artifacts/stablecoin-idl.json +++ b/artifacts/stablecoin-idl.json @@ -229,13 +229,25 @@ "init": true }, { - "name": "user_holding", + "name": "user_collateral_holding", "writable": true, "signer": true, "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 @@ -247,7 +259,7 @@ "type": "u64" }, { - "name": "collateral_amount", + "name": "initial_collateral_amount", "type": "u128" } ] diff --git a/programs/integration_tests/tests/stablecoin.rs b/programs/integration_tests/tests/stablecoin.rs index deed0173..bc7ee7a7 100644 --- a/programs/integration_tests/tests/stablecoin.rs +++ b/programs/integration_tests/tests/stablecoin.rs @@ -201,6 +201,33 @@ impl Accounts { } } + /// A bootstrapped `ProtocolParameters`, force-inserted rather than produced by + /// `initialize_program`, so the position tests stay independent of the bootstrap + /// flow. The full lifecycle test in Plan 3 issue 08 uses the real bootstrap. + fn protocol_parameters_init() -> Account { + Account { + program_owner: Ids::stablecoin_program(), + balance: 0, + data: Data::from(&stablecoin_core::ProtocolParameters { + admin_account_id: Ids::admin(), + freeze_authority_account_id: Ids::freeze_authority(), + stablecoin_definition_id: Ids::stablecoin_definition(), + collateral_definition_id: Ids::collateral_definition(), + market_price_oracle_id: Ids::oracle(), + stability_fee_per_millisecond: protocol_config::STABILITY_FEE_PER_MILLISECOND, + controller_proportional_gain: 0, + controller_integral_gain: 0, + minimum_collateralization_ratio: stablecoin_core::math::FIXED_POINT_ONE * 3 / 2, + minimum_milliseconds_between_rate_updates: + protocol_config::MINIMUM_MILLISECONDS_BETWEEN_RATE_UPDATES, + maximum_oracle_price_age_milliseconds: + protocol_config::MAXIMUM_ORACLE_PRICE_AGE_MILLISECONDS, + is_frozen: false, + }), + nonce: Nonce(0), + } + } + fn oracle_init(base_asset: AccountId, quote_asset: AccountId) -> Account { Self::oracle_with( base_asset, @@ -296,6 +323,11 @@ fn state_for_stablecoin_tests() -> V03State { ..Account::default() }, ); + state.force_insert_account( + compute_protocol_parameters_pda(Ids::stablecoin_program()), + Accounts::protocol_parameters_init(), + ); + seed_clock(&mut state, OPEN_POSITION_NOW); state } @@ -356,7 +388,7 @@ fn stablecoin_open_position_then_withdraw_collateral() { // Open the position: deposit collateral from the user's holding into a fresh vault. let open = stablecoin_core::Instruction::OpenPosition { position_nonce: Ids::position_nonce(), - collateral_amount: Balances::collateral_deposit(), + initial_collateral_amount: Balances::collateral_deposit(), }; let message = public_transaction::Message::try_new( Ids::stablecoin_program(), @@ -366,6 +398,8 @@ fn stablecoin_open_position_then_withdraw_collateral() { Ids::vault(), Ids::user_holding(), Ids::collateral_definition(), + compute_protocol_parameters_pda(Ids::stablecoin_program()), + CLOCK_01_PROGRAM_ACCOUNT_ID, ], vec![ current_nonce(&state, Ids::owner()), @@ -503,6 +537,9 @@ fn stablecoin_repay_debt_burns_stablecoins_and_decreases_debt() { /// Protocol parameters the initialized-protocol helper installs. Kept as /// constants so the poke tests can reason about the interval / staleness gates. +/// Wall clock for the open/withdraw fixture, in Unix milliseconds. +const OPEN_POSITION_NOW: u64 = 1_700_000_000_000; + mod protocol_config { use stablecoin_core::math::FIXED_POINT_ONE; diff --git a/programs/stablecoin/core/src/lib.rs b/programs/stablecoin/core/src/lib.rs index e7ccfc28..0a3bb1fa 100644 --- a/programs/stablecoin/core/src/lib.rs +++ b/programs/stablecoin/core/src/lib.rs @@ -138,22 +138,30 @@ pub enum Instruction { RefreshGlobals, /// 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, position_nonce)`) - /// - 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) + /// The position starts with no debt — spec §10.4 deliberately omits an + /// initial-debt parameter; borrowing is a separate `GenerateDebt`. Blocked + /// while the protocol is frozen. + /// + /// Required accounts (7), in order: + /// 1. `owner` — authorized. + /// 2. `position` — uninitialized; address must match `compute_position_pda(self_program_id, + /// owner, position_nonce)`. + /// 3. `vault` — uninitialized position vault token holding; address must match + /// `compute_position_vault_pda(self_program_id, position_id)`. + /// 4. `user_collateral_holding` — authorized, initialized; the owner's source holding for the + /// collateral. + /// 5. `collateral_definition` — initialized; must equal + /// `protocol_parameters.collateral_definition_id`. Its `program_owner` determines the Token + /// Program used by the chained `InitializeAccount` and `Transfer` calls. + /// 6. `protocol_parameters` — initialized, read-only; supplies the single global collateral + /// definition id and the freeze flag. + /// 7. `clock` — the system `CLOCK_01` account; read-only. Stamps `opened_at`. OpenPosition { /// Caller-chosen nonce that, with the owner's account id, forms the /// position PDA's seed pre-image. Lets one owner hold many positions. position_nonce: u64, - /// Amount of collateral tokens to deposit into the position vault. - collateral_amount: u128, + /// Collateral tokens to move into the position vault at open time. + initial_collateral_amount: u128, }, /// Withdraw `amount` collateral tokens from a position back to a user-controlled holding. /// diff --git a/programs/stablecoin/methods/guest/src/bin/stablecoin.rs b/programs/stablecoin/methods/guest/src/bin/stablecoin.rs index 1164e402..15c9b0d3 100644 --- a/programs/stablecoin/methods/guest/src/bin/stablecoin.rs +++ b/programs/stablecoin/methods/guest/src/bin/stablecoin.rs @@ -193,7 +193,13 @@ mod stablecoin { )) } - /// Open a new collateral-only position for the calling owner. + /// Open a new collateral-only position for the calling owner (spec §10.4; + /// host fn `stablecoin_program::open_position`). + /// + /// Reads the single global collateral definition id and the freeze flag from + /// `protocol_parameters`. Wall-clock time for `opened_at` comes from the + /// system `CLOCK_01` account passed as the 7th input — the pinned + /// `ProgramContext` exposes no clock. /// /// # Errors /// Returns the host program's panic-converted error if any precondition fails (see @@ -212,20 +218,24 @@ mod stablecoin { #[account(init)] vault: AccountWithMetadata, #[account(mut, signer)] - user_holding: AccountWithMetadata, - token_definition: AccountWithMetadata, + user_collateral_holding: AccountWithMetadata, + collateral_definition: AccountWithMetadata, + protocol_parameters: AccountWithMetadata, + clock: AccountWithMetadata, position_nonce: u64, - collateral_amount: u128, + initial_collateral_amount: u128, ) -> SpelResult { let (post_states, chained_calls) = stablecoin_program::open_position::open_position( owner, position, vault, - user_holding, - token_definition, + user_collateral_holding, + collateral_definition, + protocol_parameters, + clock, ctx.self_program_id, position_nonce, - collateral_amount, + initial_collateral_amount, ); Ok(spel_framework::SpelOutput::execute( post_states, diff --git a/programs/stablecoin/src/open_position.rs b/programs/stablecoin/src/open_position.rs index ee830fcd..8f0ee0e0 100644 --- a/programs/stablecoin/src/open_position.rs +++ b/programs/stablecoin/src/open_position.rs @@ -2,7 +2,9 @@ use lee_core::{ account::{Account, AccountWithMetadata, Data}, program::{AccountPostState, ChainedCall, Claim, ProgramId}, }; -use stablecoin_core::{verify_position_and_get_seed, verify_position_vault_and_get_seed, Position}; +use stablecoin_core::{ + verify_position_and_get_seed, verify_position_vault_and_get_seed, Position, ProtocolParameters, +}; use token_core::TokenHolding; /// Open a new collateral-only position for `owner`. @@ -17,12 +19,12 @@ use token_core::TokenHolding; /// not parameterized here. /// /// # Panics -/// - `owner` or `user_holding` is not authorized. +/// - `owner` or `user_collateral_holding` is not authorized. /// - `position` or `vault` is already initialized. /// - `position.account_id` / `vault.account_id` do not match their PDA derivations. -/// - `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`. +/// - `user_collateral_holding` cannot be decoded as a [`TokenHolding`]. +/// - `user_collateral_holding`'s definition does not match `collateral_definition`. +/// - `collateral_definition.program_owner` does not match `user_collateral_holding.program_owner`. #[allow( clippy::too_many_arguments, reason = "account inputs + program id + nonce + amount are all required; a param struct would obscure the host-call ABI" @@ -31,15 +33,17 @@ pub fn open_position( owner: AccountWithMetadata, position: AccountWithMetadata, vault: AccountWithMetadata, - user_holding: AccountWithMetadata, - token_definition: AccountWithMetadata, + user_collateral_holding: AccountWithMetadata, + collateral_definition: AccountWithMetadata, + protocol_parameters: AccountWithMetadata, + clock: AccountWithMetadata, stablecoin_program_id: ProgramId, position_nonce: u64, - collateral_amount: u128, + initial_collateral_amount: u128, ) -> (Vec, Vec) { assert!(owner.is_authorized, "Owner authorization is missing"); assert!( - user_holding.is_authorized, + user_collateral_holding.is_authorized, "User collateral holding authorization is missing" ); assert_eq!( @@ -53,16 +57,36 @@ pub fn open_position( "Position vault account must be uninitialized" ); - let user_holding_definition_id = TokenHolding::try_from(&user_holding.account.data) - .expect("User holding must be a valid Token Holding") - .definition_id(); + assert_ne!( + protocol_parameters.account, + Account::default(), + "ProtocolParameters account must be initialized" + ); + assert_eq!( + protocol_parameters.account.program_owner, stablecoin_program_id, + "ProtocolParameters account must be owned by the stablecoin program" + ); + let parameters = ProtocolParameters::try_from(&protocol_parameters.account.data) + .expect("ProtocolParameters must decode"); + assert!(!parameters.is_frozen, "Protocol is frozen"); + assert_eq!( + collateral_definition.account_id, parameters.collateral_definition_id, + "Collateral definition does not match the one bound at initialize_program" + ); + + let now = crate::accrue_stability_fee::read_clock(&clock); + + let user_collateral_holding_definition_id = + TokenHolding::try_from(&user_collateral_holding.account.data) + .expect("User holding must be a valid Token Holding") + .definition_id(); assert_eq!( - user_holding_definition_id, token_definition.account_id, + user_collateral_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; + let token_program_id = user_collateral_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" ); @@ -76,19 +100,19 @@ pub fn open_position( owner_account_id: owner.account_id, position_nonce, vault_account_id: vault.account_id, - collateral_amount, + collateral_amount: initial_collateral_amount, normalized_debt_amount: 0, - // TODO(#173): read from ctx clock once `open_position` is rebuilt with - // the fee-aware flow. Setting 0 keeps #156 a pure refactor. - opened_at: 0, + opened_at: now, }); let post_states = vec![ AccountPostState::new(owner.account), 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(user_collateral_holding.account.clone()), + AccountPostState::new(collateral_definition.account.clone()), + AccountPostState::new(protocol_parameters.account), + AccountPostState::new(clock.account), ]; // Chained Token::InitializeAccount owns the vault as a Token holding. The Stablecoin @@ -97,7 +121,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]); @@ -110,7 +134,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, @@ -120,9 +144,9 @@ pub fn open_position( }; let transfer_call = ChainedCall::new( token_program_id, - vec![user_holding, post_init_vault], + vec![user_collateral_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/tests.rs b/programs/stablecoin/src/tests.rs index 1a21c459..7d049d5f 100644 --- a/programs/stablecoin/src/tests.rs +++ b/programs/stablecoin/src/tests.rs @@ -11,13 +11,17 @@ use lee_core::{ }; use stablecoin_core::{ compute_position_pda, compute_position_pda_seed, compute_position_vault_pda, - compute_position_vault_pda_seed, Position, + compute_position_vault_pda_seed, math::FIXED_POINT_ONE, Position, ProtocolParameters, }; use token_core::{TokenDefinition, TokenHolding}; +use crate::test_support::clock_account; + const STABLECOIN_PROGRAM_ID: ProgramId = [3u32; 8]; const TOKEN_PROGRAM_ID: ProgramId = [2u32; 8]; const TEST_POSITION_NONCE: u64 = 0; +/// Unix milliseconds, matching the `CLOCK_01` account the guest passes in. +const NOW: u64 = 1_700_000_000_000; fn owner_id() -> AccountId { AccountId::new([0x10u8; 32]) @@ -59,6 +63,43 @@ fn vault_id() -> AccountId { compute_position_vault_pda(STABLECOIN_PROGRAM_ID, position_id()) } +fn protocol_parameters_id() -> AccountId { + AccountId::new([0xC0u8; 32]) +} + +fn protocol_parameters_account(is_frozen: bool) -> AccountWithMetadata { + protocol_parameters_account_for(collateral_definition_id(), is_frozen) +} + +fn protocol_parameters_account_for( + collateral_definition_id: AccountId, + is_frozen: bool, +) -> AccountWithMetadata { + AccountWithMetadata { + account: Account { + program_owner: STABLECOIN_PROGRAM_ID, + balance: 0, + data: Data::from(&ProtocolParameters { + admin_account_id: AccountId::new([0xA0u8; 32]), + freeze_authority_account_id: AccountId::new([0xFEu8; 32]), + stablecoin_definition_id: stablecoin_definition_id(), + collateral_definition_id, + market_price_oracle_id: AccountId::new([0xB0u8; 32]), + stability_fee_per_millisecond: FIXED_POINT_ONE, + controller_proportional_gain: 0, + controller_integral_gain: 0, + minimum_collateralization_ratio: FIXED_POINT_ONE * 3 / 2, + minimum_milliseconds_between_rate_updates: 1, + maximum_oracle_price_age_milliseconds: 86_400_000, + is_frozen, + }), + nonce: Nonce(0), + }, + is_authorized: false, + account_id: protocol_parameters_id(), + } +} + fn owner_account() -> AccountWithMetadata { AccountWithMetadata { account: Account::default(), @@ -187,12 +228,14 @@ fn open_position_claims_pda_and_emits_chained_calls() { uninit_vault_account(), user_holding_account(1_000), collateral_definition_account(), + protocol_parameters_account(false), + clock_account(NOW), STABLECOIN_PROGRAM_ID, TEST_POSITION_NONCE, collateral_amount, ); - assert_eq!(post_states.len(), 5); + assert_eq!(post_states.len(), 7); // Position is PDA-claimed and carries the encoded Position state. let position_post = &post_states[1]; @@ -212,7 +255,7 @@ fn open_position_claims_pda_and_emits_chained_calls() { vault_account_id: vault_id(), collateral_amount, normalized_debt_amount: 0, - opened_at: 0, + opened_at: NOW, } ); // The runtime sets the program_owner on the claimed account after validating Claim::Pda. @@ -265,6 +308,8 @@ fn open_position_requires_owner_authorization() { uninit_vault_account(), user_holding_account(1_000), collateral_definition_account(), + protocol_parameters_account(false), + clock_account(NOW), STABLECOIN_PROGRAM_ID, TEST_POSITION_NONCE, 500, @@ -283,6 +328,8 @@ fn open_position_requires_user_holding_authorization() { uninit_vault_account(), holding, collateral_definition_account(), + protocol_parameters_account(false), + clock_account(NOW), STABLECOIN_PROGRAM_ID, TEST_POSITION_NONCE, 500, @@ -316,6 +363,8 @@ fn open_position_rejects_initialized_position() { uninit_vault_account(), user_holding_account(1_000), collateral_definition_account(), + protocol_parameters_account(false), + clock_account(NOW), STABLECOIN_PROGRAM_ID, TEST_POSITION_NONCE, 500, @@ -345,6 +394,8 @@ fn open_position_rejects_initialized_vault() { vault, user_holding_account(1_000), collateral_definition_account(), + protocol_parameters_account(false), + clock_account(NOW), STABLECOIN_PROGRAM_ID, TEST_POSITION_NONCE, 500, @@ -366,6 +417,8 @@ fn open_position_rejects_wrong_position_address() { uninit_vault_account(), user_holding_account(1_000), collateral_definition_account(), + protocol_parameters_account(false), + clock_account(NOW), STABLECOIN_PROGRAM_ID, TEST_POSITION_NONCE, 500, @@ -387,6 +440,8 @@ fn open_position_rejects_wrong_vault_address() { bad_vault, user_holding_account(1_000), collateral_definition_account(), + protocol_parameters_account(false), + clock_account(NOW), STABLECOIN_PROGRAM_ID, TEST_POSITION_NONCE, 500, @@ -418,6 +473,10 @@ fn open_position_rejects_mismatched_token_definition() { uninit_vault_account(), user_holding_account(1_000), other_definition, + // Bind the protocol to the definition under test so the check being + // exercised here is the user-holding mismatch, not the params gate. + protocol_parameters_account_for(AccountId::new([0x21u8; 32]), false), + clock_account(NOW), STABLECOIN_PROGRAM_ID, TEST_POSITION_NONCE, 500, @@ -438,12 +497,113 @@ fn open_position_rejects_definition_with_wrong_token_program() { uninit_vault_account(), user_holding_account(1_000), definition, + protocol_parameters_account(false), + clock_account(NOW), STABLECOIN_PROGRAM_ID, TEST_POSITION_NONCE, 500, ); } +#[test] +#[should_panic(expected = "Protocol is frozen")] +fn open_position_rejects_frozen_protocol() { + crate::open_position::open_position( + owner_account(), + uninit_position_account(), + uninit_vault_account(), + user_holding_account(1_000), + collateral_definition_account(), + protocol_parameters_account(true), + clock_account(NOW), + STABLECOIN_PROGRAM_ID, + TEST_POSITION_NONCE, + 500, + ); +} + +#[test] +#[should_panic(expected = "ProtocolParameters account must be initialized")] +fn open_position_rejects_uninitialized_protocol_parameters() { + crate::open_position::open_position( + owner_account(), + uninit_position_account(), + uninit_vault_account(), + user_holding_account(1_000), + collateral_definition_account(), + AccountWithMetadata { + account: Account::default(), + is_authorized: false, + account_id: protocol_parameters_id(), + }, + clock_account(NOW), + STABLECOIN_PROGRAM_ID, + TEST_POSITION_NONCE, + 500, + ); +} + +#[test] +#[should_panic(expected = "Collateral definition does not match")] +fn open_position_rejects_collateral_definition_not_bound_at_init() { + // The protocol was bootstrapped against a different collateral definition, so + // the one the caller passed must be refused even though it is internally + // consistent with the user's holding. + crate::open_position::open_position( + owner_account(), + uninit_position_account(), + uninit_vault_account(), + user_holding_account(1_000), + collateral_definition_account(), + protocol_parameters_account_for(AccountId::new([0x99u8; 32]), false), + clock_account(NOW), + STABLECOIN_PROGRAM_ID, + TEST_POSITION_NONCE, + 500, + ); +} + +#[test] +fn open_position_stamps_opened_at_from_the_clock() { + let (post_states, _) = crate::open_position::open_position( + owner_account(), + uninit_position_account(), + uninit_vault_account(), + user_holding_account(1_000), + collateral_definition_account(), + protocol_parameters_account(false), + clock_account(NOW), + STABLECOIN_PROGRAM_ID, + TEST_POSITION_NONCE, + 500, + ); + + let position = Position::try_from(&post_states[1].account().data).expect("valid Position"); + assert_eq!(position.opened_at, NOW); +} + +#[test] +fn open_position_echoes_protocol_parameters_and_clock_unchanged() { + let parameters = protocol_parameters_account(false); + let clock = clock_account(NOW); + let (post_states, _) = crate::open_position::open_position( + owner_account(), + uninit_position_account(), + uninit_vault_account(), + user_holding_account(1_000), + collateral_definition_account(), + parameters.clone(), + clock.clone(), + STABLECOIN_PROGRAM_ID, + TEST_POSITION_NONCE, + 500, + ); + + assert_eq!(post_states.len(), 7); + assert_eq!(*post_states[5].account(), parameters.account); + assert_eq!(*post_states[6].account(), clock.account); +} + #[test] fn position_pda_is_deterministic_and_owner_and_nonce_specific() { let id_a = compute_position_pda(STABLECOIN_PROGRAM_ID, owner_id(), TEST_POSITION_NONCE);