Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions programs/metlev-engine/src/errors.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
}
2 changes: 2 additions & 0 deletions programs/metlev-engine/src/instructions/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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::*;
Expand Down
73 changes: 73 additions & 0 deletions programs/metlev-engine/src/instructions/withdraw_collateral.rs
Original file line number Diff line number Diff line change
@@ -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(())
}
}
13 changes: 11 additions & 2 deletions programs/metlev-engine/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -92,8 +92,17 @@ pub mod metlev_engine {
)
}

pub fn close_position(ctx: Context<ClosePosition>) -> Result<()> {
ctx.accounts.close()
pub fn close_position(
ctx: Context<ClosePosition>,
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<WithdrawCollateral>) -> Result<()> {
ctx.accounts.withdraw(&ctx.bumps)
}

pub fn liquidate(ctx: Context<Liquidate>) -> Result<()> {
Expand Down
4 changes: 4 additions & 0 deletions programs/metlev-engine/src/state/position.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand Down
103 changes: 102 additions & 1 deletion tests/deposit.ts → tests/collateral.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);

Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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");
}
});

});
});