From 2d3392393a4981c4c7bb00be77d94cc33da6e245 Mon Sep 17 00:00:00 2001 From: Andrea Franz Date: Tue, 18 Aug 2026 15:33:32 +0000 Subject: [PATCH] =?UTF-8?q?refactor(stablecoin)!:=20migrate=20Position=20t?= =?UTF-8?q?o=20spec=20=C2=A74.4=20shape?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit closes #161 --- artifacts/stablecoin-idl.json | 18 ++- .../integration_tests/tests/stablecoin.rs | 27 ++-- programs/stablecoin/core/src/lib.rs | 78 ++++++---- .../methods/guest/src/bin/stablecoin.rs | 6 + programs/stablecoin/src/open_position.rs | 23 +-- programs/stablecoin/src/repay_debt.rs | 41 +++--- programs/stablecoin/src/tests.rs | 133 ++++++++---------- .../stablecoin/src/withdraw_collateral.rs | 42 +++--- 8 files changed, 201 insertions(+), 167 deletions(-) diff --git a/artifacts/stablecoin-idl.json b/artifacts/stablecoin-idl.json index 21f71e1..c8cb135 100644 --- a/artifacts/stablecoin-idl.json +++ b/artifacts/stablecoin-idl.json @@ -37,6 +37,10 @@ } ], "args": [ + { + "name": "position_nonce", + "type": "u64" + }, { "name": "collateral_amount", "type": "u128" @@ -217,11 +221,15 @@ "kind": "struct", "fields": [ { - "name": "collateral_vault_id", + "name": "owner_account_id", "type": "account_id" }, { - "name": "collateral_definition_id", + "name": "position_nonce", + "type": "u64" + }, + { + "name": "vault_account_id", "type": "account_id" }, { @@ -229,8 +237,12 @@ "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..653643a 100644 --- a/programs/integration_tests/tests/stablecoin.rs +++ b/programs/integration_tests/tests/stablecoin.rs @@ -56,11 +56,15 @@ impl Ids { )) } + fn position_nonce() -> u64 { + 0 + } + fn position() -> AccountId { compute_position_pda( Self::stablecoin_program(), Self::owner(), - Self::collateral_definition(), + Self::position_nonce(), ) } @@ -157,10 +161,12 @@ impl Accounts { program_owner: stablecoin_methods::STABLECOIN_ID, balance: 0_u128, data: Data::from(&Position { - collateral_vault_id: Ids::vault(), - collateral_definition_id: Ids::collateral_definition(), + owner_account_id: Ids::owner(), + position_nonce: Ids::position_nonce(), + vault_account_id: Ids::vault(), collateral_amount: Balances::collateral_deposit(), - debt_amount: Balances::initial_debt(), + normalized_debt_amount: Balances::initial_debt(), + opened_at: 0, }), nonce: Nonce(0), } @@ -223,12 +229,10 @@ 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() - ); + assert_eq!(position.normalized_debt_amount, 0); + assert_eq!(position.vault_account_id, Ids::vault()); + assert_eq!(position.owner_account_id, Ids::owner()); + assert_eq!(position.position_nonce, Ids::position_nonce()); } fn assert_fungible_balance(state: &V03State, account_id: AccountId, expected_balance: u128) { @@ -254,6 +258,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(), }; let message = public_transaction::Message::try_new( @@ -363,7 +368,7 @@ fn stablecoin_repay_debt_burns_stablecoins_and_decreases_debt() { let position = Position::try_from(&state.get_account_by_id(Ids::position()).data).expect("valid Position"); assert_eq!( - position.debt_amount, + position.normalized_debt_amount, Balances::initial_debt() - Balances::debt_repay_amount() ); assert_eq!(position.collateral_amount, Balances::collateral_deposit()); diff --git a/programs/stablecoin/core/src/lib.rs b/programs/stablecoin/core/src/lib.rs index 719f9c8..c14bb3a 100644 --- a/programs/stablecoin/core/src/lib.rs +++ b/programs/stablecoin/core/src/lib.rs @@ -48,6 +48,9 @@ pub enum Instruction { /// its `program_owner` determines the Token Program used by the chained `InitializeAccount` /// / `Transfer` calls) 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, }, @@ -101,19 +104,30 @@ pub enum Instruction { /// 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`. +/// See spec §4.4. `normalized_debt_amount` is the RAI-style "shares in a debt +/// pool whose value per share is the stability-fee accumulator" — multiply by +/// the current accumulator to get the position's nominal debt at any moment. #[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, - /// Amount of collateral tokens deposited. + /// Owner of the position. Must be `is_authorized` for every position op. + /// Stored for client discovery (PDA seed isn't reversible). + pub owner_account_id: AccountId, + /// Caller-chosen nonce; together with `owner_account_id` forms the PDA + /// seed for this position. + pub position_nonce: u64, + /// Collateral vault PDA (= `compute_position_vault_pda(program, position_id)`). + /// Stored explicitly for op-time efficiency. + pub vault_account_id: AccountId, + /// Collateral tokens currently held in the vault. Invariant: + /// equals `vault_holding.balance` after every modifying op. pub collateral_amount: u128, - /// Outstanding stablecoin debt against this position. - pub debt_amount: u128, + /// Stablecoin atomic units divided by the accumulator at mint time. + /// Nominal debt at time T = `normalized_debt_amount * accumulated_rate(T) / FIXED_POINT_ONE`. + pub normalized_debt_amount: u128, + /// Unix milliseconds when the position was first opened. UX/analytics only; + /// not used in protocol logic. + pub opened_at: u64, } impl TryFrom<&Data> for Position { @@ -126,26 +140,27 @@ 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") + let len = borsh::object_length(position).expect("Position length must be known"); + let mut buf = Vec::with_capacity(len); + BorshSerialize::serialize(position, &mut buf) + .expect("Position serialization should not fail"); + Self::try_from(buf).expect("Position encoded data should fit into Data") } } -/// PDA seed for the [`Position`] account owned by `owner_id` for `collateral_definition_id`. +/// PDA seed for the [`Position`] account at `(owner_id, position_nonce)`. /// -/// 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 { +/// The single-instance protocol has only one collateral definition globally +/// (stored on `ProtocolParameters`), so it no longer factors into the seed. +/// The 64-bit `position_nonce` is caller-chosen and lets one owner hold +/// many positions (spec §3.2). +#[must_use] +pub fn compute_position_pda_seed(owner_id: AccountId, position_nonce: u64) -> 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_nonce.to_le_bytes()); bytes.extend_from_slice(POSITION_PDA_DOMAIN); let mut out = [0u8; 32]; @@ -153,15 +168,17 @@ pub fn compute_position_pda_seed( PdaSeed::new(out) } -/// Account id of the [`Position`] PDA owned by `owner_id` under `stablecoin_program_id`. +/// Account id of the [`Position`] PDA for `(owner_id, position_nonce)` 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), ) } @@ -192,20 +209,19 @@ 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 the position account's address matches `(stablecoin_program_id, +/// owner, position_nonce)` and return the [`PdaSeed`] for use in 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 derived PDA. 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, diff --git a/programs/stablecoin/methods/guest/src/bin/stablecoin.rs b/programs/stablecoin/methods/guest/src/bin/stablecoin.rs index 3cd28b1..c9de0bd 100644 --- a/programs/stablecoin/methods/guest/src/bin/stablecoin.rs +++ b/programs/stablecoin/methods/guest/src/bin/stablecoin.rs @@ -18,6 +18,10 @@ mod stablecoin { /// Returns the host program's panic-converted error if any precondition fails (see /// [`stablecoin_program::open_position::open_position`] for the full list). #[instruction] + #[allow( + clippy::too_many_arguments, + reason = "account inputs + nonce + amount mirror the host function's ABI" + )] pub fn open_position( ctx: ProgramContext, #[account(signer)] @@ -29,6 +33,7 @@ mod stablecoin { #[account(mut, signer)] user_holding: AccountWithMetadata, token_definition: AccountWithMetadata, + position_nonce: u64, collateral_amount: u128, ) -> SpelResult { let (post_states, chained_calls) = stablecoin_program::open_position::open_position( @@ -38,6 +43,7 @@ mod stablecoin { user_holding, token_definition, ctx.self_program_id, + position_nonce, collateral_amount, ); Ok(spel_framework::SpelOutput::execute( diff --git a/programs/stablecoin/src/open_position.rs b/programs/stablecoin/src/open_position.rs index 8c33ae8..0933915 100644 --- a/programs/stablecoin/src/open_position.rs +++ b/programs/stablecoin/src/open_position.rs @@ -23,6 +23,10 @@ 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`. +#[allow( + clippy::too_many_arguments, + reason = "account inputs + program id + nonce + amount are all required; a param struct would obscure the host-call ABI" +)] pub fn open_position( owner: AccountWithMetadata, position: AccountWithMetadata, @@ -30,6 +34,7 @@ pub fn open_position( user_holding: AccountWithMetadata, token_definition: AccountWithMetadata, stablecoin_program_id: ProgramId, + position_nonce: u64, collateral_amount: u128, ) -> (Vec, Vec) { assert!(owner.is_authorized, "Owner authorization is missing"); @@ -61,21 +66,21 @@ pub fn open_position( "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 mut position_post = position.account; position_post.data = Data::from(&Position { - collateral_vault_id: vault.account_id, - collateral_definition_id: token_definition.account_id, + owner_account_id: owner.account_id, + position_nonce, + vault_account_id: vault.account_id, collateral_amount, - debt_amount: 0, + normalized_debt_amount: 0, + // TODO(Plan 3): read from ctx clock once `open_position` is rebuilt with + // the fee-aware flow. Setting 0 keeps Plan 1 a pure refactor. + opened_at: 0, }); let post_states = vec![ diff --git a/programs/stablecoin/src/repay_debt.rs b/programs/stablecoin/src/repay_debt.rs index dfe72bd..25ddd5e 100644 --- a/programs/stablecoin/src/repay_debt.rs +++ b/programs/stablecoin/src/repay_debt.rs @@ -8,17 +8,17 @@ use token_core::TokenHolding; /// 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 -/// position post-state uses plain [`AccountPostState::new`] — the PDA was -/// already claimed at `open_position` time. +/// `Token::Burn` and decreases `Position.normalized_debt_amount` by the same +/// amount. 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 Plan 3 (stability fee accrual) lands, the fee-accrual step is a +/// no-op (every position structurally has `normalized_debt_amount = 0` today +/// because `generate_debt` is unimplemented; "fees-accrued" is therefore +/// vacuously true). A `// TODO(Plan 3)` 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 +/// Until Plan 3 (`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. /// @@ -26,12 +26,12 @@ use token_core::TokenHolding; /// - `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 > Position.normalized_debt_amount`. pub fn repay_debt( owner: AccountWithMetadata, position: AccountWithMetadata, @@ -59,7 +59,7 @@ pub fn repay_debt( let _position_seed = verify_position_and_get_seed( &position, &owner, - position_data.collateral_definition_id, + position_data.position_nonce, stablecoin_program_id, ); @@ -89,19 +89,22 @@ 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. + // TODO(Plan 3): accrue stability fees onto position_data.normalized_debt_amount + // here, before the checked_sub below. Today every position has + // normalized_debt_amount = 0 (no generate_debt yet), so the precondition is + // trivially met. let new_debt = position_data - .debt_amount + .normalized_debt_amount .checked_sub(amount) .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_debt, + opened_at: position_data.opened_at, }; let mut position_post = position.account.clone(); position_post.data = Data::from(&updated_position); diff --git a/programs/stablecoin/src/tests.rs b/programs/stablecoin/src/tests.rs index 57a64ed..3582b7b 100644 --- a/programs/stablecoin/src/tests.rs +++ b/programs/stablecoin/src/tests.rs @@ -17,6 +17,7 @@ use token_core::{TokenDefinition, TokenHolding}; const STABLECOIN_PROGRAM_ID: ProgramId = [3u32; 8]; const TOKEN_PROGRAM_ID: ProgramId = [2u32; 8]; +const TEST_POSITION_NONCE: u64 = 0; fn owner_id() -> AccountId { AccountId::new([0x10u8; 32]) @@ -51,11 +52,7 @@ fn token_holding_account( } fn position_id() -> AccountId { - compute_position_pda( - STABLECOIN_PROGRAM_ID, - owner_id(), - collateral_definition_id(), - ) + compute_position_pda(STABLECOIN_PROGRAM_ID, owner_id(), TEST_POSITION_NONCE) } fn vault_id() -> AccountId { @@ -114,16 +111,21 @@ fn destination_holding_id() -> AccountId { AccountId::new([0x40u8; 32]) } -fn init_position_account(collateral_amount: u128, debt_amount: u128) -> AccountWithMetadata { +fn init_position_account( + collateral_amount: u128, + normalized_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(), + owner_account_id: owner_id(), + position_nonce: TEST_POSITION_NONCE, + vault_account_id: vault_id(), collateral_amount, - debt_amount, + normalized_debt_amount, + opened_at: 0, }), nonce: Nonce(0), }, @@ -186,6 +188,7 @@ fn open_position_claims_pda_and_emits_chained_calls() { user_holding_account(1_000), collateral_definition_account(), STABLECOIN_PROGRAM_ID, + TEST_POSITION_NONCE, collateral_amount, ); @@ -197,17 +200,19 @@ fn open_position_claims_pda_and_emits_chained_calls() { position_post.required_claim(), Some(Claim::Pda(compute_position_pda_seed( owner_id(), - collateral_definition_id() + TEST_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(), + owner_account_id: owner_id(), + position_nonce: TEST_POSITION_NONCE, + vault_account_id: vault_id(), collateral_amount, - debt_amount: 0, + normalized_debt_amount: 0, + opened_at: 0, } ); // The runtime sets the program_owner on the claimed account after validating Claim::Pda. @@ -261,6 +266,7 @@ fn open_position_requires_owner_authorization() { user_holding_account(1_000), collateral_definition_account(), STABLECOIN_PROGRAM_ID, + TEST_POSITION_NONCE, 500, ); } @@ -278,6 +284,7 @@ fn open_position_requires_user_holding_authorization() { holding, collateral_definition_account(), STABLECOIN_PROGRAM_ID, + TEST_POSITION_NONCE, 500, ); } @@ -290,10 +297,12 @@ fn open_position_rejects_initialized_position() { program_owner: STABLECOIN_PROGRAM_ID, balance: 0, data: Data::from(&Position { - collateral_vault_id: vault_id(), - collateral_definition_id: collateral_definition_id(), + owner_account_id: owner_id(), + position_nonce: TEST_POSITION_NONCE, + vault_account_id: vault_id(), collateral_amount: 1, - debt_amount: 0, + normalized_debt_amount: 0, + opened_at: 0, }), nonce: Nonce(0), }, @@ -308,6 +317,7 @@ fn open_position_rejects_initialized_position() { user_holding_account(1_000), collateral_definition_account(), STABLECOIN_PROGRAM_ID, + TEST_POSITION_NONCE, 500, ); } @@ -336,6 +346,7 @@ fn open_position_rejects_initialized_vault() { user_holding_account(1_000), collateral_definition_account(), STABLECOIN_PROGRAM_ID, + TEST_POSITION_NONCE, 500, ); } @@ -356,6 +367,7 @@ fn open_position_rejects_wrong_position_address() { user_holding_account(1_000), collateral_definition_account(), STABLECOIN_PROGRAM_ID, + TEST_POSITION_NONCE, 500, ); } @@ -376,6 +388,7 @@ fn open_position_rejects_wrong_vault_address() { user_holding_account(1_000), collateral_definition_account(), STABLECOIN_PROGRAM_ID, + TEST_POSITION_NONCE, 500, ); } @@ -406,6 +419,7 @@ fn open_position_rejects_mismatched_token_definition() { user_holding_account(1_000), other_definition, STABLECOIN_PROGRAM_ID, + TEST_POSITION_NONCE, 500, ); } @@ -425,37 +439,26 @@ fn open_position_rejects_definition_with_wrong_token_program() { user_holding_account(1_000), definition, STABLECOIN_PROGRAM_ID, + TEST_POSITION_NONCE, 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(), - ); +fn position_pda_is_deterministic_and_owner_and_nonce_specific() { + let id_a = compute_position_pda(STABLECOIN_PROGRAM_ID, owner_id(), TEST_POSITION_NONCE); + let id_b = compute_position_pda(STABLECOIN_PROGRAM_ID, owner_id(), TEST_POSITION_NONCE); 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() - ), + compute_position_pda(STABLECOIN_PROGRAM_ID, other_owner, TEST_POSITION_NONCE), id_a ); - let other_definition = AccountId::new([0x21u8; 32]); + let other_nonce = TEST_POSITION_NONCE + 1; assert_ne!( - compute_position_pda(STABLECOIN_PROGRAM_ID, owner_id(), other_definition), + compute_position_pda(STABLECOIN_PROGRAM_ID, owner_id(), other_nonce), id_a ); } @@ -463,11 +466,7 @@ fn position_pda_is_deterministic_and_owner_and_collateral_specific() { #[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 position = compute_position_pda(STABLECOIN_PROGRAM_ID, owner_id(), TEST_POSITION_NONCE); let vault = compute_position_vault_pda(STABLECOIN_PROGRAM_ID, position); assert_ne!(position, vault); } @@ -494,10 +493,12 @@ fn withdraw_collateral_updates_position_and_emits_transfer() { assert_eq!( position, Position { - collateral_vault_id: vault_id(), - collateral_definition_id: collateral_definition_id(), + owner_account_id: owner_id(), + position_nonce: TEST_POSITION_NONCE, + vault_account_id: vault_id(), collateral_amount: initial_collateral - amount, - debt_amount: 0, + normalized_debt_amount: 0, + opened_at: 0, } ); assert_eq!(position_post.account().program_owner, STABLECOIN_PROGRAM_ID); @@ -537,7 +538,7 @@ fn withdraw_collateral_allows_full_drain() { ); 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); + assert_eq!(position.normalized_debt_amount, 0); } #[test] @@ -641,18 +642,20 @@ fn withdraw_collateral_rejects_wrong_vault_address() { } #[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 { +#[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), - vault, - destination_holding_account(), + init_vault_account(), + destination, STABLECOIN_PROGRAM_ID, 100, ); @@ -691,26 +694,6 @@ fn withdraw_collateral_rejects_destination_with_wrong_token_program() { ); } -#[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() { @@ -762,10 +745,12 @@ fn repay_debt_decreases_debt_and_emits_burn() { assert_eq!( position, Position { - collateral_vault_id: vault_id(), - collateral_definition_id: collateral_definition_id(), + owner_account_id: owner_id(), + position_nonce: TEST_POSITION_NONCE, + vault_account_id: vault_id(), collateral_amount: initial_collateral, - debt_amount: initial_debt - amount, + normalized_debt_amount: initial_debt - amount, + opened_at: 0, } ); assert_eq!(position_post.account().program_owner, STABLECOIN_PROGRAM_ID); @@ -807,7 +792,7 @@ fn repay_debt_allows_full_repayment() { debt, ); let position = Position::try_from(&post_states[1].account().data).expect("valid Position"); - assert_eq!(position.debt_amount, 0); + assert_eq!(position.normalized_debt_amount, 0); assert_eq!(position.collateral_amount, 500); } @@ -823,7 +808,7 @@ fn repay_debt_allows_zero_amount() { 0, ); let position = Position::try_from(&post_states[1].account().data).expect("valid Position"); - assert_eq!(position.debt_amount, initial_debt); + assert_eq!(position.normalized_debt_amount, initial_debt); let expected_burn = ChainedCall::new( TOKEN_PROGRAM_ID, diff --git a/programs/stablecoin/src/withdraw_collateral.rs b/programs/stablecoin/src/withdraw_collateral.rs index 259c663..e25d751 100644 --- a/programs/stablecoin/src/withdraw_collateral.rs +++ b/programs/stablecoin/src/withdraw_collateral.rs @@ -13,22 +13,22 @@ 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 +/// Until Plan 3 lands (redemption price, price feed, stability fee accrual), +/// this instruction hard-asserts `Position.normalized_debt_amount == 0`. +/// When that lands, 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. +/// `compute_position_vault_pda(stablecoin_program_id, position_id)`. /// - `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 vault holding's collateral +/// definition. +/// - `Position.normalized_debt_amount` is non-zero. /// - `amount > Position.collateral_amount`. pub fn withdraw_collateral( owner: AccountWithMetadata, @@ -57,7 +57,7 @@ pub fn withdraw_collateral( let _position_seed = verify_position_and_get_seed( &position, &owner, - position_data.collateral_definition_id, + position_data.position_nonce, stablecoin_program_id, ); let vault_seed = @@ -65,11 +65,11 @@ pub fn withdraw_collateral( let vault_holding = TokenHolding::try_from(&vault.account.data) .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" - ); + // The vault PDA is verified to belong to this position, so its holding's + // definition is the authoritative collateral definition. Plan 1 dropped the + // redundant copy from `Position`; `ProtocolParameters` owns the global + // collateral definition from Plan 3 onward. + let collateral_definition_id = vault_holding.definition_id(); let token_program_id = vault.account.program_owner; assert_ne!( @@ -85,13 +85,13 @@ pub fn withdraw_collateral( .expect("Destination account must hold a valid TokenHolding"); assert_eq!( destination_holding.definition_id(), - position_data.collateral_definition_id, + collateral_definition_id, "Destination token definition does not match the position's 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" + position_data.normalized_debt_amount, 0, + "withdraw_collateral with debt is not supported yet — fee accrual + collateralization check land in Plan 3" ); let new_collateral = position_data .collateral_amount @@ -99,10 +99,12 @@ pub fn withdraw_collateral( .expect("Withdrawal amount exceeds position collateral"); 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);