From 0dda4e6f295824aa6425e55e359fb24f982b7a2e Mon Sep 17 00:00:00 2001 From: 0xRektified Date: Wed, 4 Mar 2026 18:00:23 +0800 Subject: [PATCH] fix: make sure collateral is used when final position is loosing, update tests --- .../src/instructions/close_position.rs | 113 +++++++++++++++++- programs/metlev-engine/src/lib.rs | 12 +- tests/close-position.ts | 61 +++++++++- 3 files changed, 176 insertions(+), 10 deletions(-) diff --git a/programs/metlev-engine/src/instructions/close_position.rs b/programs/metlev-engine/src/instructions/close_position.rs index 50aac70..00ebb4f 100644 --- a/programs/metlev-engine/src/instructions/close_position.rs +++ b/programs/metlev-engine/src/instructions/close_position.rs @@ -1,6 +1,9 @@ use anchor_lang::prelude::*; +use anchor_lang::system_program::{self, Transfer as SystemTransfer}; use anchor_spl::associated_token::AssociatedToken; -use anchor_spl::token_interface::{Mint, TokenAccount, TokenInterface}; +use anchor_spl::token_interface::{ + transfer_checked, Mint, TokenAccount, TokenInterface, TransferChecked, +}; use crate::state::{Config, Position, LendingVault}; use crate::errors::ProtocolError; use crate::dlmm; @@ -35,7 +38,6 @@ pub struct ClosePosition<'info> { )] pub lending_vault: Box>, - /// Receives the wSOL proceeds (token Y) from DLMM remove_liquidity / swap. #[account( mut, seeds = [b"wsol_vault", lending_vault.key().as_ref()], @@ -45,7 +47,23 @@ pub struct ClosePosition<'info> { )] pub wsol_vault: Box>, - /// DLMM position — owned by lending_vault, not a signer on close. + #[account( + init_if_needed, + payer = user, + associated_token::mint = wsol_mint, + associated_token::authority = user, + associated_token::token_program = token_program, + )] + pub user_wsol_ata: Box>, + + /// CHECK: PDA validated by seeds. + #[account( + mut, + seeds = [b"vault", user.key().as_ref(), wsol_mint.key().as_ref()], + bump, + )] + pub collateral_vault: UncheckedAccount<'info>, + /// CHECK: Verified by the DLMM program. #[account(mut)] pub met_position: UncheckedAccount<'info>, @@ -58,7 +76,6 @@ pub struct ClosePosition<'info> { #[account(mut)] pub bin_array_bitmap_extension: Option>, - /// Lending vault's token X ATA — created if it doesn't exist yet. /// Any X-side tokens returned by remove_liquidity land here, then get swapped to wSOL. #[account( init_if_needed, @@ -90,7 +107,7 @@ pub struct ClosePosition<'info> { #[account(mut)] pub bin_array_upper: UncheckedAccount<'info>, - /// CHECK: Pool TWAP oracle — required by DLMM swap to update price tracking. + /// CHECK: Pool TWAP oracle required by DLMM swap to update price tracking. #[account(mut)] pub oracle: UncheckedAccount<'info>, @@ -111,6 +128,7 @@ pub struct ClosePosition<'info> { impl<'info> ClosePosition<'info> { pub fn close( &mut self, + bumps: &ClosePositionBumps, from_bin_id: i32, to_bin_id: i32, ) -> Result<()> { @@ -118,6 +136,8 @@ impl<'info> ClosePosition<'info> { let signer_seeds: &[&[&[u8]]] = &[&[LendingVault::SEED_PREFIX, &[vault_bump]]]; let debt = self.position.debt_amount; + let vault_before = self.wsol_vault.amount; + self.cpi_remove_liquidity(signer_seeds, from_bin_id, to_bin_id)?; self.cpi_claim_fee(signer_seeds)?; @@ -129,8 +149,35 @@ impl<'info> ClosePosition<'info> { self.cpi_close_position(signer_seeds)?; + self.wsol_vault.reload()?; + let vault_after = self.wsol_vault.amount; + let proceeds = vault_after.saturating_sub(vault_before); + + // If LP lost value (proceeds < debt), cover shortfall from collateral. + // Transfer SOL from collateral vault wsol_vault, then sync_native + // so the wSOL token balance reflects the added lamports. + if proceeds < debt { + let shortfall = debt + .checked_sub(proceeds) + .ok_or(ProtocolError::MathOverflow)?; + let covered = std::cmp::min(shortfall, self.position.collateral_amount); + if covered > 0 { + self.cover_shortfall(bumps, covered)?; + self.position.collateral_amount = self.position.collateral_amount + .checked_sub(covered) + .ok_or(ProtocolError::MathOverflow)?; + } + } + self.position.debt_amount = 0; self.lending_vault.repay(debt)?; + + // If LP gained value (proceeds > debt), send surplus to user. + let surplus = proceeds.saturating_sub(debt); + if surplus > 0 { + self.transfer_surplus(signer_seeds, surplus)?; + } + self.position.mark_closed(); Ok(()) } @@ -246,4 +293,60 @@ impl<'info> ClosePosition<'info> { ); dlmm::cpi::close_position(ctx) } + + /// Transfer SOL from collateral vault to wsol_vault and sync_native + /// to cover the debt shortfall when LP lost value. + #[inline(never)] + fn cover_shortfall(&self, bumps: &ClosePositionBumps, amount: u64) -> Result<()> { + let user_key = self.user.key(); + let mint_key = self.wsol_mint.key(); + let vault_bump_arr = [bumps.collateral_vault]; + let collateral_seeds: &[&[&[u8]]] = &[&[ + b"vault", + user_key.as_ref(), + mint_key.as_ref(), + &vault_bump_arr, + ]]; + + // Add lamports to wsol_vault token account + system_program::transfer( + CpiContext::new_with_signer( + self.system_program.to_account_info(), + SystemTransfer { + from: self.collateral_vault.to_account_info(), + to: self.wsol_vault.to_account_info(), + }, + collateral_seeds, + ), + amount, + )?; + + // Sync wSOL token balance to match new lamports + let ix = anchor_spl::token::spl_token::instruction::sync_native( + &anchor_spl::token::spl_token::id(), + &self.wsol_vault.key(), + ) + .map_err(|_| ProtocolError::MathOverflow)?; + anchor_lang::solana_program::program::invoke( + &ix, + &[self.wsol_vault.to_account_info()], + )?; + + Ok(()) + } + + #[inline(never)] + fn transfer_surplus(&self, signer_seeds: &[&[&[u8]]], amount: u64) -> Result<()> { + let ctx = CpiContext::new_with_signer( + self.token_program.to_account_info(), + TransferChecked { + from: self.wsol_vault.to_account_info(), + mint: self.wsol_mint.to_account_info(), + to: self.user_wsol_ata.to_account_info(), + authority: self.lending_vault.to_account_info(), + }, + signer_seeds, + ); + transfer_checked(ctx, amount, self.wsol_mint.decimals) + } } diff --git a/programs/metlev-engine/src/lib.rs b/programs/metlev-engine/src/lib.rs index 30a1439..de5d9b4 100644 --- a/programs/metlev-engine/src/lib.rs +++ b/programs/metlev-engine/src/lib.rs @@ -97,16 +97,20 @@ pub mod metlev_engine { from_bin_id: i32, to_bin_id: i32, ) -> Result<()> { - ctx.accounts.close(from_bin_id, to_bin_id) + ctx.accounts.close(&ctx.bumps, from_bin_id, to_bin_id) } pub fn withdraw_collateral(ctx: Context) -> Result<()> { ctx.accounts.withdraw(&ctx.bumps) } - pub fn liquidate(ctx: Context) -> Result<()> { - ctx.accounts.liquidate() - } + // pub fn liquidate( + // ctx: Context, + // from_bin_id: i32, + // to_bin_id: i32, + // ) -> Result<()> { + // ctx.accounts.liquidate(&ctx.bumps, from_bin_id, to_bin_id) + // } pub fn update_pause_state( ctx: Context, diff --git a/tests/close-position.ts b/tests/close-position.ts index da7993e..367116e 100644 --- a/tests/close-position.ts +++ b/tests/close-position.ts @@ -236,6 +236,18 @@ describe("Close Position", () => { true // allowOwnerOffCurve — lending_vault is a PDA ); + const userWsolAta = await getOrCreateAssociatedTokenAccount( + provider.connection, + provider.wallet.payer, + NATIVE_MINT, + user + ); + + const [collateralVault] = PublicKey.findProgramAddressSync( + [Buffer.from("vault"), user.toBuffer(), NATIVE_MINT.toBuffer()], + program.programId + ); + return { accounts: { user, @@ -244,6 +256,8 @@ describe("Close Position", () => { position: positionPda, lendingVault: lendingVaultPda, wsolVault: wsolVaultPda, + userWsolAta: userWsolAta.address, + collateralVault, metPosition: metPositionPubkey, lbPair: LB_PAIR, binArrayBitmapExtension: null, @@ -845,6 +859,9 @@ describe("Close Position", () => { it("Closes in-range position with internal X to wSOL swap", async () => { const vaultBefore = await program.account.lendingVault.fetch(lendingVaultPda); const wsolVaultBalanceBefore = await provider.connection.getTokenAccountBalance(wsolVaultPda); + const positionBefore = await program.account.position.fetch(positionPda); + const collateralBefore = positionBefore.collateralAmount.toNumber(); + const collateralVaultLamportsBefore = await provider.connection.getBalance(collateralVaultPda); // Build close accounts for the fresh pool await freshPool.refetchStates(); @@ -862,6 +879,18 @@ describe("Close Position", () => { true ); + const posUserWsolAta = await getOrCreateAssociatedTokenAccount( + provider.connection, + provider.wallet.payer, + NATIVE_MINT, + posUser.publicKey + ); + + const [posCollateralVault] = PublicKey.findProgramAddressSync( + [Buffer.from("vault"), posUser.publicKey.toBuffer(), NATIVE_MINT.toBuffer()], + program.programId + ); + const accounts = { user: posUser.publicKey, config: configPda, @@ -869,6 +898,8 @@ describe("Close Position", () => { position: positionPda, lendingVault: lendingVaultPda, wsolVault: wsolVaultPda, + userWsolAta: posUserWsolAta.address, + collateralVault: posCollateralVault, metPosition: metPositionKp.publicKey, lbPair: freshLbPair, binArrayBitmapExtension: null, @@ -926,18 +957,46 @@ describe("Close Position", () => { "All token X must be swapped to wSOL" ); - // Log the vault wSOL delta (shows the "loss" from price movement) + // Verify collateral was used to cover shortfall const wsolVaultBalanceAfter = await provider.connection.getTokenAccountBalance(wsolVaultPda); const before = parseInt(wsolVaultBalanceBefore.value.amount); const after = parseInt(wsolVaultBalanceAfter.value.amount); const delta = after - before; const borrowed = debtBefore.toNumber(); + // LP proceeds < debt → shortfall covered from collateral + // After cover_shortfall, vault delta == debt (shortfall was added from collateral). + // So: shortfall = collateralBefore - collateralAfter + const collateralAfter = positionAfter.collateralAmount.toNumber(); + const collateralUsed = collateralBefore - collateralAfter; + const collateralVaultLamportsAfter = await provider.connection.getBalance(collateralVaultPda); + const collateralVaultDelta = collateralVaultLamportsBefore - collateralVaultLamportsAfter; + + expect(collateralUsed).to.be.greaterThan( + 0, + "Collateral must decrease when LP lost value (shortfall covered from collateral)" + ); + expect(collateralAfter).to.be.lessThan( + collateralBefore, + "position.collateral_amount must be reduced by the shortfall" + ); + expect(collateralVaultDelta).to.equal( + collateralUsed, + "Collateral vault lamports must decrease by exactly the shortfall amount" + ); + expect(delta).to.equal( + borrowed, + "Vault delta must equal full debt (shortfall topped up from collateral)" + ); + console.log("\n Position status : closed"); console.log(" Debt repaid :", borrowed / LAMPORTS_PER_SOL, "SOL"); console.log(" wSOL vault before :", before / LAMPORTS_PER_SOL, "SOL"); console.log(" wSOL vault after :", after / LAMPORTS_PER_SOL, "SOL"); console.log(" Vault delta :", delta / LAMPORTS_PER_SOL, "SOL"); + console.log(" Collateral before :", collateralBefore / LAMPORTS_PER_SOL, "SOL"); + console.log(" Collateral after :", collateralAfter / LAMPORTS_PER_SOL, "SOL"); + console.log(" Shortfall covered :", collateralUsed / LAMPORTS_PER_SOL, "SOL"); console.log(" Token X ATA balance : 0 (all swapped)"); console.log(" DLMM position : closed on-chain"); });