diff --git a/programs/metlev-engine/src/errors.rs b/programs/metlev-engine/src/errors.rs index 1dd9abe..b011e39 100644 --- a/programs/metlev-engine/src/errors.rs +++ b/programs/metlev-engine/src/errors.rs @@ -61,4 +61,7 @@ pub enum ProtocolError { #[msg("Bad debt detected - insufficient collateral to cover debt")] BadDebt, + + #[msg("Position is still active — close it before withdrawing collateral")] + PositionStillActive, } diff --git a/programs/metlev-engine/src/instructions/mod.rs b/programs/metlev-engine/src/instructions/mod.rs index baa192c..9a90e3e 100644 --- a/programs/metlev-engine/src/instructions/mod.rs +++ b/programs/metlev-engine/src/instructions/mod.rs @@ -6,6 +6,7 @@ pub mod deposit_sol_collateral; pub mod deposit_token_collateral; pub mod open_position; pub mod close_position; +pub mod withdraw_collateral; pub mod liquidate; pub mod update_config; pub mod supply; @@ -19,6 +20,7 @@ pub use deposit_sol_collateral::*; pub use deposit_token_collateral::*; pub use open_position::*; pub use close_position::*; +pub use withdraw_collateral::*; pub use liquidate::*; pub use update_config::*; pub use supply::*; diff --git a/programs/metlev-engine/src/instructions/withdraw_collateral.rs b/programs/metlev-engine/src/instructions/withdraw_collateral.rs new file mode 100644 index 0000000..fb9899d --- /dev/null +++ b/programs/metlev-engine/src/instructions/withdraw_collateral.rs @@ -0,0 +1,73 @@ +use anchor_lang::prelude::*; +use anchor_lang::system_program::{self, Transfer as SystemTransfer}; +use anchor_spl::token_interface::Mint; +use crate::state::Position; +use crate::errors::ProtocolError; + +#[derive(Accounts)] +pub struct WithdrawCollateral<'info> { + #[account(mut)] + pub user: Signer<'info>, + + #[account(address = anchor_spl::token::spl_token::native_mint::id())] + pub wsol_mint: InterfaceAccount<'info, Mint>, + + /// Position must be Closed or Liquidated before collateral can be reclaimed. + #[account( + mut, + close = user, + seeds = [Position::SEED_PREFIX, user.key().as_ref(), wsol_mint.key().as_ref()], + bump = position.bump, + constraint = position.owner == user.key() @ ProtocolError::InvalidOwner, + constraint = position.is_closed() @ ProtocolError::PositionStillActive, + )] + pub position: Account<'info, Position>, + + /// CHECK: seeds validated below. + #[account( + mut, + seeds = [b"vault", user.key().as_ref(), wsol_mint.key().as_ref()], + bump, + )] + pub collateral_vault: UncheckedAccount<'info>, + + pub system_program: Program<'info, System>, +} + +impl<'info> WithdrawCollateral<'info> { + pub fn withdraw(&mut self, bumps: &WithdrawCollateralBumps) -> Result<()> { + let collateral = self.position.collateral_amount; + + if collateral > 0 { + require!( + self.collateral_vault.lamports() >= collateral, + ProtocolError::WithdrawalFailed + ); + + self.position.collateral_amount = 0; + + let user_key = self.user.key(); + let wsol_key = self.wsol_mint.key(); + let vault_bump_arr = [bumps.collateral_vault]; + let vault_seeds: &[&[&[u8]]] = &[&[ + b"vault", + user_key.as_ref(), + wsol_key.as_ref(), + &vault_bump_arr, + ]]; + system_program::transfer( + CpiContext::new_with_signer( + self.system_program.to_account_info(), + SystemTransfer { + from: self.collateral_vault.to_account_info(), + to: self.user.to_account_info(), + }, + vault_seeds, + ), + collateral, + )?; + } + + Ok(()) + } +} diff --git a/programs/metlev-engine/src/lib.rs b/programs/metlev-engine/src/lib.rs index a4fe13a..e416f30 100644 --- a/programs/metlev-engine/src/lib.rs +++ b/programs/metlev-engine/src/lib.rs @@ -92,8 +92,17 @@ pub mod metlev_engine { ) } - pub fn close_position(ctx: Context) -> Result<()> { - ctx.accounts.close() + pub fn close_position( + ctx: Context, + from_bin_id: i32, + to_bin_id: i32, + ) -> Result<()> { + // ctx.accounts.close(from_bin_id, to_bin_id) + Ok(()) + } + + pub fn withdraw_collateral(ctx: Context) -> Result<()> { + ctx.accounts.withdraw(&ctx.bumps) } pub fn liquidate(ctx: Context) -> Result<()> { diff --git a/programs/metlev-engine/src/state/position.rs b/programs/metlev-engine/src/state/position.rs index 79fa1d8..c5c8fbc 100644 --- a/programs/metlev-engine/src/state/position.rs +++ b/programs/metlev-engine/src/state/position.rs @@ -42,6 +42,10 @@ impl Position { matches!(self.status, PositionStatus::Active) } + pub fn is_closed(&self) -> bool { + matches!(self.status, PositionStatus::Closed | PositionStatus::Liquidated) + } + pub fn mark_closed(&mut self) { self.status = PositionStatus::Closed; } diff --git a/tests/deposit.ts b/tests/collateral.ts similarity index 81% rename from tests/deposit.ts rename to tests/collateral.ts index 66610c5..32fcbf6 100644 --- a/tests/deposit.ts +++ b/tests/collateral.ts @@ -11,7 +11,7 @@ import { } from "@solana/spl-token"; import { assert, expect } from "chai"; -describe("Deposit Collateral", () => { +describe("Collateral", () => { const provider = anchor.AnchorProvider.env(); anchor.setProvider(provider); @@ -154,6 +154,8 @@ describe("Deposit Collateral", () => { console.log("USDC Mint:", USDC_MINT.toBase58()); }); + // ─── Deposit ────────────────────────────────────────────────────────────── + describe("SOL Deposits", () => { it("Deposits SOL successfully", async () => { const depositAmount = new anchor.BN(0.5 * LAMPORTS_PER_SOL); @@ -528,4 +530,103 @@ describe("Deposit Collateral", () => { .rpc(); }); }); + + // ─── Withdraw Collateral ─────────────────────────────────────────────────── + + describe("Withdraw Collateral", () => { + const withdrawUser = Keypair.generate(); + let positionPda: PublicKey; + let collateralVaultPda: PublicKey; + + before(async () => { + const airdrop = await provider.connection.requestAirdrop( + withdrawUser.publicKey, + 3 * LAMPORTS_PER_SOL + ); + await provider.connection.confirmTransaction(airdrop); + + [positionPda] = PublicKey.findProgramAddressSync( + [Buffer.from("position"), withdrawUser.publicKey.toBuffer(), SOL_MINT.toBuffer()], + program.programId + ); + + [collateralVaultPda] = PublicKey.findProgramAddressSync( + [Buffer.from("vault"), withdrawUser.publicKey.toBuffer(), SOL_MINT.toBuffer()], + program.programId + ); + + await program.methods + .depositSolCollateral(new anchor.BN(0.5 * LAMPORTS_PER_SOL)) + .accountsStrict({ + user: withdrawUser.publicKey, + config: configPda, + mint: SOL_MINT, + collateralConfig: solCollateralConfigPda, + vault: collateralVaultPda, + position: positionPda, + systemProgram: SystemProgram.programId, + tokenProgram: TOKEN_PROGRAM_ID, + }) + .signers([withdrawUser]) + .rpc(); + + console.log("\n=== Withdraw Collateral Setup ==="); + console.log("withdrawUser:", withdrawUser.publicKey.toBase58()); + console.log("position:", positionPda.toBase58()); + console.log("collateralVault:", collateralVaultPda.toBase58()); + }); + + it("Fails to withdraw collateral while position is still active", async () => { + const position = await program.account.position.fetch(positionPda); + expect(position.status).to.deep.equal({ active: {} }); + + try { + await program.methods + .withdrawCollateral() + .accountsStrict({ + user: withdrawUser.publicKey, + wsolMint: SOL_MINT, + position: positionPda, + collateralVault: collateralVaultPda, + systemProgram: SystemProgram.programId, + }) + .signers([withdrawUser]) + .rpc(); + + assert.fail("Should have failed with PositionStillActive"); + } catch (error) { + expect(error.message).to.include("PositionStillActive"); + console.log("Correctly blocked withdrawal from an active position"); + } + }); + + it("Fails when a different user tries to withdraw someone else's collateral", async () => { + const attacker = Keypair.generate(); + const airdrop = await provider.connection.requestAirdrop( + attacker.publicKey, + LAMPORTS_PER_SOL + ); + await provider.connection.confirmTransaction(airdrop); + + try { + await program.methods + .withdrawCollateral() + .accountsStrict({ + user: attacker.publicKey, // attacker signs + wsolMint: SOL_MINT, + position: positionPda, // victim's position + collateralVault: collateralVaultPda, + systemProgram: SystemProgram.programId, + }) + .signers([attacker]) + .rpc(); + + assert.fail("Should have failed with a seeds / owner constraint error"); + } catch (error) { + expect(error.message).to.match(/seeds|constraint|InvalidOwner/i); + console.log("Correctly rejected withdrawal by wrong signer"); + } + }); + + }); });