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
113 changes: 108 additions & 5 deletions programs/metlev-engine/src/instructions/close_position.rs
Original file line number Diff line number Diff line change
@@ -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;
Expand Down Expand Up @@ -35,7 +38,6 @@ pub struct ClosePosition<'info> {
)]
pub lending_vault: Box<Account<'info, LendingVault>>,

/// Receives the wSOL proceeds (token Y) from DLMM remove_liquidity / swap.
#[account(
mut,
seeds = [b"wsol_vault", lending_vault.key().as_ref()],
Expand All @@ -45,7 +47,23 @@ pub struct ClosePosition<'info> {
)]
pub wsol_vault: Box<InterfaceAccount<'info, TokenAccount>>,

/// 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<InterfaceAccount<'info, TokenAccount>>,

/// 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>,
Expand All @@ -58,7 +76,6 @@ pub struct ClosePosition<'info> {
#[account(mut)]
pub bin_array_bitmap_extension: Option<UncheckedAccount<'info>>,

/// 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,
Expand Down Expand Up @@ -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>,

Expand All @@ -111,13 +128,16 @@ pub struct ClosePosition<'info> {
impl<'info> ClosePosition<'info> {
pub fn close(
&mut self,
bumps: &ClosePositionBumps,
from_bin_id: i32,
to_bin_id: i32,
) -> Result<()> {
let vault_bump = self.lending_vault.bump;
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)?;

Expand All @@ -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(())
}
Expand Down Expand Up @@ -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)
}
}
12 changes: 8 additions & 4 deletions programs/metlev-engine/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<WithdrawCollateral>) -> Result<()> {
ctx.accounts.withdraw(&ctx.bumps)
}

pub fn liquidate(ctx: Context<Liquidate>) -> Result<()> {
ctx.accounts.liquidate()
}
// pub fn liquidate(
// ctx: Context<Liquidate>,
// 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<UpdateConfig>,
Expand Down
61 changes: 60 additions & 1 deletion tests/close-position.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -244,6 +256,8 @@ describe("Close Position", () => {
position: positionPda,
lendingVault: lendingVaultPda,
wsolVault: wsolVaultPda,
userWsolAta: userWsolAta.address,
collateralVault,
metPosition: metPositionPubkey,
lbPair: LB_PAIR,
binArrayBitmapExtension: null,
Expand Down Expand Up @@ -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();
Expand All @@ -862,13 +879,27 @@ 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,
wsolMint: NATIVE_MINT,
position: positionPda,
lendingVault: lendingVaultPda,
wsolVault: wsolVaultPda,
userWsolAta: posUserWsolAta.address,
collateralVault: posCollateralVault,
metPosition: metPositionKp.publicKey,
lbPair: freshLbPair,
binArrayBitmapExtension: null,
Expand Down Expand Up @@ -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");
});
Expand Down