From b7257add2fa5c56713472e11678ab68a95f1c196 Mon Sep 17 00:00:00 2001 From: Andrea Franz Date: Tue, 19 May 2026 15:59:10 +0200 Subject: [PATCH 1/2] feat(stablecoin): implement `withdraw_collateral` closes #92 --- artifacts/stablecoin-idl.json | 35 ++ stablecoin/core/src/lib.rs | 20 ++ .../methods/guest/src/bin/stablecoin.rs | 32 ++ stablecoin/src/lib.rs | 3 + stablecoin/src/tests.rs | 319 ++++++++++++++++++ stablecoin/src/withdraw_collateral.rs | 129 +++++++ 6 files changed, 538 insertions(+) create mode 100644 stablecoin/src/withdraw_collateral.rs diff --git a/artifacts/stablecoin-idl.json b/artifacts/stablecoin-idl.json index 015b818..f4063f2 100644 --- a/artifacts/stablecoin-idl.json +++ b/artifacts/stablecoin-idl.json @@ -42,6 +42,41 @@ "type": "u128" } ] + }, + { + "name": "withdraw_collateral", + "accounts": [ + { + "name": "owner", + "writable": false, + "signer": false, + "init": false + }, + { + "name": "position", + "writable": false, + "signer": false, + "init": false + }, + { + "name": "vault", + "writable": false, + "signer": false, + "init": false + }, + { + "name": "destination", + "writable": false, + "signer": false, + "init": false + } + ], + "args": [ + { + "name": "amount", + "type": "u128" + } + ] } ], "accounts": [ diff --git a/stablecoin/core/src/lib.rs b/stablecoin/core/src/lib.rs index 11527c7..34c7d4d 100644 --- a/stablecoin/core/src/lib.rs +++ b/stablecoin/core/src/lib.rs @@ -30,6 +30,26 @@ pub enum Instruction { /// Amount of collateral tokens to deposit into the position vault. collateral_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, + }, } /// Persistent state held by a Stablecoin [`Position`] account. diff --git a/stablecoin/methods/guest/src/bin/stablecoin.rs b/stablecoin/methods/guest/src/bin/stablecoin.rs index d9ff26e..89d7999 100644 --- a/stablecoin/methods/guest/src/bin/stablecoin.rs +++ b/stablecoin/methods/guest/src/bin/stablecoin.rs @@ -40,4 +40,36 @@ mod stablecoin { chained_calls, )) } + + /// 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). + #[instruction] + pub fn withdraw_collateral( + ctx: ProgramContext, + owner: AccountWithMetadata, + position: AccountWithMetadata, + vault: AccountWithMetadata, + destination: AccountWithMetadata, + amount: u128, + ) -> SpelResult { + let (post_states, chained_calls) = + stablecoin_program::withdraw_collateral::withdraw_collateral( + owner, + position, + vault, + destination, + ctx.self_program_id, + amount, + ); + Ok(spel_framework::SpelOutput::execute( + post_states, + chained_calls, + )) + } } diff --git a/stablecoin/src/lib.rs b/stablecoin/src/lib.rs index 7f5c47e..12d3de4 100644 --- a/stablecoin/src/lib.rs +++ b/stablecoin/src/lib.rs @@ -5,5 +5,8 @@ pub use stablecoin_core as core; /// Open a new collateral-only position for a calling owner. pub mod open_position; +/// Withdraw collateral from an existing position back to a user-controlled holding. +pub mod withdraw_collateral; + #[cfg(test)] mod tests; diff --git a/stablecoin/src/tests.rs b/stablecoin/src/tests.rs index b7fe339..df73cd8 100644 --- a/stablecoin/src/tests.rs +++ b/stablecoin/src/tests.rs @@ -99,6 +99,60 @@ fn uninit_vault_account() -> AccountWithMetadata { } } +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 { + 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(), + } +} + +fn destination_holding_account() -> AccountWithMetadata { + 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: destination_holding_id(), + } +} + #[test] fn open_position_claims_pda_and_emits_chained_calls() { let collateral_amount: u128 = 500; @@ -392,3 +446,268 @@ fn position_pda_and_vault_pda_do_not_collide() { 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(), + 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( + TOKEN_PROGRAM_ID, + vec![vault_authorized, destination_holding_account()], + &token_core::Instruction::Transfer { + amount_to_transfer: amount, + }, + ) + .with_pda_seeds(vec![compute_position_vault_pda_seed(position_id())]); + assert_eq!(chained_calls[0], expected_transfer); +} + +#[test] +fn withdraw_collateral_allows_full_drain() { + let amount: u128 = 500; + let (post_states, _chained_calls) = crate::withdraw_collateral::withdraw_collateral( + 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(), + STABLECOIN_PROGRAM_ID, + 100, + ); +} + +#[test] +#[should_panic(expected = "Position account must be initialized")] +fn withdraw_collateral_rejects_uninitialized_position() { + crate::withdraw_collateral::withdraw_collateral( + owner_account(), + uninit_position_account(), + init_vault_account(), + destination_holding_account(), + 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, + ); +} diff --git a/stablecoin/src/withdraw_collateral.rs b/stablecoin/src/withdraw_collateral.rs new file mode 100644 index 0000000..259c663 --- /dev/null +++ b/stablecoin/src/withdraw_collateral.rs @@ -0,0 +1,129 @@ +use nssa_core::{ + account::{Account, AccountWithMetadata, Data}, + program::{AccountPostState, ChainedCall, ProgramId}, +}; +use stablecoin_core::{verify_position_and_get_seed, verify_position_vault_and_get_seed, Position}; +use token_core::TokenHolding; + +/// Withdraw `amount` collateral tokens from `position`'s vault back to `destination`. +/// +/// Decreases `Position.collateral_amount` by `amount` and emits a single chained +/// `Token::Transfer` from the vault to `destination`, authorized by the vault +/// PDA seed. The position post-state uses plain [`AccountPostState::new`] — +/// 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)`. +/// - `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. +/// - `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. +/// - `amount > Position.collateral_amount`. +pub fn withdraw_collateral( + owner: AccountWithMetadata, + position: AccountWithMetadata, + vault: AccountWithMetadata, + destination: AccountWithMetadata, + stablecoin_program_id: ProgramId, + amount: u128, +) -> (Vec, Vec) { + assert!(owner.is_authorized, "Owner authorization is missing"); + 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"); + // `verify_position_and_get_seed` asserts the position address matches the + // (owner, collateral_definition) 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, + stablecoin_program_id, + ); + let vault_seed = + verify_position_vault_and_get_seed(&vault, position.account_id, stablecoin_program_id); + + 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" + ); + + let token_program_id = vault.account.program_owner; + assert_ne!( + destination.account, + Account::default(), + "Destination must be initialized" + ); + assert_eq!( + destination.account.program_owner, token_program_id, + "Destination must be owned by the same Token Program as the vault" + ); + let destination_holding = TokenHolding::try_from(&destination.account.data) + .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" + ); + + 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"); + + let updated_position = Position { + collateral_vault_id: position_data.collateral_vault_id, + collateral_definition_id: position_data.collateral_definition_id, + collateral_amount: new_collateral, + debt_amount: position_data.debt_amount, + }; + 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(vault.account.clone()), + AccountPostState::new(destination.account.clone()), + ]; + + let mut vault_authorized = vault.clone(); + vault_authorized.is_authorized = true; + let transfer_call = ChainedCall::new( + token_program_id, + vec![vault_authorized, destination], + &token_core::Instruction::Transfer { + amount_to_transfer: amount, + }, + ) + .with_pda_seeds(vec![vault_seed]); + + (post_states, vec![transfer_call]) +} From a6f38aae3600d12870cdff7c40966295b14630c5 Mon Sep 17 00:00:00 2001 From: Ricardo Guilherme Schmidt <3esmit@gmail.com> Date: Wed, 20 May 2026 10:02:12 -0300 Subject: [PATCH 2/2] test(stablecoin): cover invalid withdraw transfer pre-states --- Cargo.lock | 1 + stablecoin/Cargo.toml | 3 ++ stablecoin/src/tests.rs | 88 +++++++++++++++++++++++------------------ 3 files changed, 53 insertions(+), 39 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 531e40b..5259a34 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3054,6 +3054,7 @@ dependencies = [ "nssa_core", "stablecoin_core", "token_core", + "token_program", ] [[package]] diff --git a/stablecoin/Cargo.toml b/stablecoin/Cargo.toml index 83a0155..af342fb 100644 --- a/stablecoin/Cargo.toml +++ b/stablecoin/Cargo.toml @@ -7,3 +7,6 @@ edition = "2021" nssa_core = { git = "https://github.com/logos-blockchain/logos-execution-zone.git", tag = "v0.2.0-rc3", features = ["host"] } stablecoin_core = { path = "core" } token_core = { path = "../token/core" } + +[dev-dependencies] +token_program.workspace = true diff --git a/stablecoin/src/tests.rs b/stablecoin/src/tests.rs index df73cd8..42ad660 100644 --- a/stablecoin/src/tests.rs +++ b/stablecoin/src/tests.rs @@ -30,6 +30,26 @@ fn user_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, @@ -68,19 +88,9 @@ fn collateral_definition_account() -> AccountWithMetadata { } fn user_holding_account(balance: u128) -> AccountWithMetadata { - AccountWithMetadata { - account: Account { - program_owner: TOKEN_PROGRAM_ID, - balance: 0, - data: Data::from(&TokenHolding::Fungible { - definition_id: collateral_definition_id(), - balance, - }), - nonce: Nonce(0), - }, - is_authorized: true, - account_id: user_holding_id(), - } + let mut account = token_holding_account(user_holding_id(), collateral_definition_id(), balance); + account.is_authorized = true; + account } fn uninit_position_account() -> AccountWithMetadata { @@ -122,35 +132,11 @@ fn init_position_account(collateral_amount: u128, debt_amount: u128) -> AccountW } fn init_vault_account() -> AccountWithMetadata { - 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(), - } + token_holding_account(vault_id(), collateral_definition_id(), 0) } fn destination_holding_account() -> AccountWithMetadata { - 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: destination_holding_id(), - } + token_holding_account(destination_holding_id(), collateral_definition_id(), 0) } #[test] @@ -499,6 +485,30 @@ fn withdraw_collateral_updates_position_and_emits_transfer() { assert_eq!(chained_calls[0], expected_transfer); } +#[test] +#[should_panic(expected = "Insufficient balance")] +fn withdraw_collateral_transfer_pre_states_should_not_be_executable() { + 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(), + STABLECOIN_PROGRAM_ID, + amount, + ); + + let transfer_call = chained_calls + .into_iter() + .next() + .expect("withdraw emits transfer"); + let [sender, recipient] = + <[_; 2]>::try_from(transfer_call.pre_states).expect("token transfer accounts"); + + token_program::transfer::transfer(sender, recipient, amount); +} + #[test] fn withdraw_collateral_allows_full_drain() { let amount: u128 = 500;