diff --git a/programs/metlev-engine/src/instructions/liquidate.rs b/programs/metlev-engine/src/instructions/liquidate.rs index 09eddf5..1cd6f71 100644 --- a/programs/metlev-engine/src/instructions/liquidate.rs +++ b/programs/metlev-engine/src/instructions/liquidate.rs @@ -1,7 +1,11 @@ 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 crate::state::{Config, Position, LendingVault, CollateralConfig}; use crate::errors::ProtocolError; -use crate::utils::{read_oracle_price, calculate_collateral_value, calculate_ltv}; +use crate::utils::{read_oracle_price, calculate_collateral_value, calculate_ltv, calculate_liquidation_penalty}; +use crate::dlmm; #[derive(Accounts)] pub struct Liquidate<'info> { @@ -12,28 +16,31 @@ pub struct Liquidate<'info> { seeds = [Config::SEED_PREFIX], bump = config.bump, )] - pub config: Account<'info, Config>, + pub config: Box>, + + #[account(address = anchor_spl::token::spl_token::native_mint::id())] + pub wsol_mint: Box>, #[account( mut, - seeds = [Position::SEED_PREFIX, position.owner.key().as_ref(), position.collateral_mint.as_ref()], + seeds = [Position::SEED_PREFIX, position.owner.key().as_ref(), wsol_mint.key().as_ref()], bump = position.bump, constraint = position.is_active() @ ProtocolError::PositionNotActive, )] - pub position: Account<'info, Position>, + pub position: Box>, #[account( mut, seeds = [LendingVault::SEED_PREFIX], bump = lending_vault.bump, )] - pub lending_vault: Account<'info, LendingVault>, + pub lending_vault: Box>, #[account( - seeds = [CollateralConfig::SEED_PREFIX, position.collateral_mint.as_ref()], + seeds = [CollateralConfig::SEED_PREFIX, wsol_mint.key().as_ref()], bump = collateral_config.bump, )] - pub collateral_config: Account<'info, CollateralConfig>, + pub collateral_config: Box>, /// CHECK: verified via collateral_config.oracle constraint #[account( @@ -41,24 +48,103 @@ pub struct Liquidate<'info> { )] pub price_oracle: UncheckedAccount<'info>, - /// CHECK: Position owner to receive remaining collateral (if any) - #[account(mut)] + /// Receives the wSOL proceeds (token Y) from DLMM remove_liquidity / swap. + #[account( + mut, + seeds = [b"wsol_vault", lending_vault.key().as_ref()], + bump = lending_vault.vault_bump, + token::mint = wsol_mint, + token::authority = lending_vault, + )] + pub wsol_vault: Box>, + + /// CHECK: Position owner to receive remaining collateral. + #[account( + mut, + constraint = position_owner.key() == position.owner @ ProtocolError::InvalidOwner, + )] pub position_owner: UncheckedAccount<'info>, - /// TODO: Add Meteora DLMM program and accounts - /// CHECK: Meteora program - pub meteora_program: UncheckedAccount<'info>, + /// User's collateral vault — holds native SOL. + /// CHECK: PDA validated by seeds. + #[account( + mut, + seeds = [b"vault", position.owner.key().as_ref(), wsol_mint.key().as_ref()], + bump, + )] + pub collateral_vault: UncheckedAccount<'info>, + + // ── DLMM accounts ── + /// CHECK: Verified by the DLMM program. + #[account(mut)] + pub met_position: UncheckedAccount<'info>, + + /// CHECK: Verified by the DLMM program. + #[account(mut)] + pub lb_pair: UncheckedAccount<'info>, + + /// CHECK: Verified by the DLMM program. + #[account(mut)] + pub bin_array_bitmap_extension: Option>, + + /// Lending vault's token X ATA — created if it doesn't exist yet. + #[account( + init_if_needed, + payer = liquidator, + associated_token::mint = token_x_mint, + associated_token::authority = lending_vault, + associated_token::token_program = token_program, + )] + pub user_token_x: Box>, + + /// CHECK: Verified by the DLMM program. + #[account(mut)] + pub reserve_x: UncheckedAccount<'info>, + + /// CHECK: Verified by the DLMM program. + #[account(mut)] + pub reserve_y: UncheckedAccount<'info>, + + pub token_x_mint: Box>, + + /// CHECK: Verified by the DLMM program. + pub token_y_mint: UncheckedAccount<'info>, + + /// CHECK: Verified by the DLMM program. + #[account(mut)] + pub bin_array_lower: UncheckedAccount<'info>, + + /// CHECK: Verified by the DLMM program. + #[account(mut)] + pub bin_array_upper: UncheckedAccount<'info>, + + /// CHECK: Pool TWAP oracle — required by DLMM swap. + #[account(mut)] + pub oracle: UncheckedAccount<'info>, + + /// CHECK: Verified by the DLMM program. + pub event_authority: UncheckedAccount<'info>, + + pub token_program: Interface<'info, TokenInterface>, + + pub associated_token_program: Program<'info, AssociatedToken>, + + /// CHECK: Address constrained to dlmm::ID. + #[account(address = dlmm::ID)] + pub dlmm_program: UncheckedAccount<'info>, pub system_program: Program<'info, System>, } impl<'info> Liquidate<'info> { - pub fn liquidate(&mut self) -> Result<()> { + pub fn liquidate( + &mut self, + bumps: &LiquidateBumps, + from_bin_id: i32, + to_bin_id: i32, + ) -> Result<()> { let oracle_info = self.price_oracle.to_account_info(); - let (price, _) = read_oracle_price( - &oracle_info, - self.collateral_config.oracle_max_age, - )?; + let (price, _) = read_oracle_price(&oracle_info, self.collateral_config.oracle_max_age)?; let collateral_value = calculate_collateral_value( self.position.collateral_amount, @@ -70,32 +156,210 @@ impl<'info> Liquidate<'info> { price, self.collateral_config.decimals, )?; - let ltv = calculate_ltv(collateral_value, debt_value)?; + // LTV = debt / (collateral + debt) — same formula as open_position + let total_value = collateral_value + .checked_add(debt_value) + .ok_or(ProtocolError::MathOverflow)?; + let ltv = calculate_ltv(total_value, debt_value)?; require!( self.collateral_config.is_liquidatable(ltv), ProtocolError::PositionHealthy ); - // TODO: CPI to Meteora to remove liquidity - // let total_proceeds = remove_liquidity_from_meteora(); - - // Repay debt + // Remove DLMM liquidity + let vault_bump = self.lending_vault.bump; + let signer_seeds: &[&[&[u8]]] = &[&[LendingVault::SEED_PREFIX, &[vault_bump]]]; let debt = self.position.debt_amount; - self.lending_vault.repay(debt)?; - // TODO: Calculate liquidation penalty - // let penalty = calculate_penalty(total_proceeds, self.config.liquidation_penalty); - // transfer(penalty, liquidator); + let vault_before = self.wsol_vault.amount; - // TODO: Return remaining to position owner (if any) - // let remaining = total_proceeds.saturating_sub(debt + penalty); - // if remaining > 0 { - // transfer(remaining, position_owner); - // } + self.cpi_remove_liquidity(signer_seeds, from_bin_id, to_bin_id)?; + self.cpi_claim_fee(signer_seeds)?; - // Mark position as liquidated - self.position.mark_liquidated(); + self.user_token_x.reload()?; + let x_balance = self.user_token_x.amount; + if x_balance > 0 { + self.cpi_swap(signer_seeds, x_balance)?; + } + + self.cpi_close_position(signer_seeds)?; + + // Repay debt from LP proceeds + self.wsol_vault.reload()?; + let vault_after = self.wsol_vault.amount; + let proceeds = vault_after.saturating_sub(vault_before); + + if proceeds >= debt { + self.lending_vault.repay(debt)?; + } else if proceeds > 0 { + // Bad debt: repay whatever we can, vault absorbs the loss. + self.lending_vault.repay(proceeds)?; + } + + // Distribute collateral: penalty to liquidator, remainder to owner + let collateral = self.position.collateral_amount; + if collateral > 0 { + let penalty = calculate_liquidation_penalty( + collateral, + self.collateral_config.liquidation_penalty, + )?; + let remainder = collateral + .checked_sub(penalty) + .ok_or(ProtocolError::MathOverflow)?; + + let owner_key = self.position.owner; + let mint_key = self.wsol_mint.key(); + let vault_bump_arr = [bumps.collateral_vault]; + let collateral_seeds: &[&[&[u8]]] = &[&[ + b"vault", + owner_key.as_ref(), + mint_key.as_ref(), + &vault_bump_arr, + ]]; + if penalty > 0 { + self.transfer_collateral(collateral_seeds, self.liquidator.to_account_info(), penalty)?; + } + if remainder > 0 { + self.transfer_collateral(collateral_seeds, self.position_owner.to_account_info(), remainder)?; + } + } + + self.position.debt_amount = 0; + self.position.collateral_amount = 0; + self.position.mark_liquidated(); Ok(()) } + + #[inline(never)] + fn cpi_remove_liquidity( + &self, + signer_seeds: &[&[&[u8]]], + from_bin_id: i32, + to_bin_id: i32, + ) -> Result<()> { + let ctx = CpiContext::new_with_signer( + self.dlmm_program.to_account_info(), + dlmm::cpi::accounts::RemoveLiquidityByRange { + position: self.met_position.to_account_info(), + lb_pair: self.lb_pair.to_account_info(), + bin_array_bitmap_extension: self + .bin_array_bitmap_extension + .as_ref() + .map(|a| a.to_account_info()), + user_token_x: self.user_token_x.to_account_info(), + user_token_y: self.wsol_vault.to_account_info(), + reserve_x: self.reserve_x.to_account_info(), + reserve_y: self.reserve_y.to_account_info(), + token_x_mint: self.token_x_mint.to_account_info(), + token_y_mint: self.token_y_mint.to_account_info(), + bin_array_lower: self.bin_array_lower.to_account_info(), + bin_array_upper: self.bin_array_upper.to_account_info(), + sender: self.lending_vault.to_account_info(), + token_x_program: self.token_program.to_account_info(), + token_y_program: self.token_program.to_account_info(), + event_authority: self.event_authority.to_account_info(), + program: self.dlmm_program.to_account_info(), + }, + signer_seeds, + ); + dlmm::cpi::remove_liquidity_by_range(ctx, from_bin_id, to_bin_id, 10_000) + } + + #[inline(never)] + fn cpi_claim_fee(&self, signer_seeds: &[&[&[u8]]]) -> Result<()> { + let ctx = CpiContext::new_with_signer( + self.dlmm_program.to_account_info(), + dlmm::cpi::accounts::ClaimFee { + lb_pair: self.lb_pair.to_account_info(), + position: self.met_position.to_account_info(), + bin_array_lower: self.bin_array_lower.to_account_info(), + bin_array_upper: self.bin_array_upper.to_account_info(), + sender: self.lending_vault.to_account_info(), + reserve_x: self.reserve_x.to_account_info(), + reserve_y: self.reserve_y.to_account_info(), + user_token_x: self.user_token_x.to_account_info(), + user_token_y: self.wsol_vault.to_account_info(), + token_x_mint: self.token_x_mint.to_account_info(), + token_y_mint: self.token_y_mint.to_account_info(), + token_program: self.token_program.to_account_info(), + event_authority: self.event_authority.to_account_info(), + program: self.dlmm_program.to_account_info(), + }, + signer_seeds, + ); + dlmm::cpi::claim_fee(ctx) + } + + #[inline(never)] + fn cpi_swap(&self, signer_seeds: &[&[&[u8]]], amount: u64) -> Result<()> { + let ctx = CpiContext::new_with_signer( + self.dlmm_program.to_account_info(), + dlmm::cpi::accounts::Swap { + lb_pair: self.lb_pair.to_account_info(), + bin_array_bitmap_extension: self + .bin_array_bitmap_extension + .as_ref() + .map(|a| a.to_account_info()), + reserve_x: self.reserve_x.to_account_info(), + reserve_y: self.reserve_y.to_account_info(), + user_token_in: self.user_token_x.to_account_info(), + user_token_out: self.wsol_vault.to_account_info(), + token_x_mint: self.token_x_mint.to_account_info(), + token_y_mint: self.token_y_mint.to_account_info(), + oracle: self.oracle.to_account_info(), + host_fee_in: None, + user: self.lending_vault.to_account_info(), + token_x_program: self.token_program.to_account_info(), + token_y_program: self.token_program.to_account_info(), + event_authority: self.event_authority.to_account_info(), + program: self.dlmm_program.to_account_info(), + }, + signer_seeds, + ) + .with_remaining_accounts(vec![ + self.bin_array_lower.to_account_info(), + self.bin_array_upper.to_account_info(), + ]); + dlmm::cpi::swap(ctx, amount, 0) + } + + #[inline(never)] + fn cpi_close_position(&self, signer_seeds: &[&[&[u8]]]) -> Result<()> { + let ctx = CpiContext::new_with_signer( + self.dlmm_program.to_account_info(), + dlmm::cpi::accounts::ClosePosition { + position: self.met_position.to_account_info(), + lb_pair: self.lb_pair.to_account_info(), + bin_array_lower: self.bin_array_lower.to_account_info(), + bin_array_upper: self.bin_array_upper.to_account_info(), + sender: self.lending_vault.to_account_info(), + rent_receiver: self.liquidator.to_account_info(), + event_authority: self.event_authority.to_account_info(), + program: self.dlmm_program.to_account_info(), + }, + signer_seeds, + ); + dlmm::cpi::close_position(ctx) + } + + #[inline(never)] + fn transfer_collateral( + &self, + collateral_seeds: &[&[&[u8]]], + destination: AccountInfo<'info>, + amount: u64, + ) -> Result<()> { + system_program::transfer( + CpiContext::new_with_signer( + self.system_program.to_account_info(), + SystemTransfer { + from: self.collateral_vault.to_account_info(), + to: destination, + }, + collateral_seeds, + ), + amount, + ) + } } diff --git a/programs/metlev-engine/src/lib.rs b/programs/metlev-engine/src/lib.rs index de5d9b4..dcf7dda 100644 --- a/programs/metlev-engine/src/lib.rs +++ b/programs/metlev-engine/src/lib.rs @@ -104,13 +104,13 @@ pub mod metlev_engine { ctx.accounts.withdraw(&ctx.bumps) } - // 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 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/liquidation.ts b/tests/liquidation.ts new file mode 100644 index 0000000..11e8c0c --- /dev/null +++ b/tests/liquidation.ts @@ -0,0 +1,646 @@ +import * as anchor from "@coral-xyz/anchor"; +import { Program, BN } from "@coral-xyz/anchor"; +import { MetlevEngine } from "../target/types/metlev_engine"; +import DLMM from "@meteora-ag/dlmm"; +import { + PublicKey, + Keypair, + SystemProgram, + LAMPORTS_PER_SOL, + SYSVAR_RENT_PUBKEY, + Transaction, + ComputeBudgetProgram, +} from "@solana/web3.js"; +import { + getOrCreateAssociatedTokenAccount, + createSyncNativeInstruction, + TOKEN_PROGRAM_ID, + ASSOCIATED_TOKEN_PROGRAM_ID, + NATIVE_MINT, +} from "@solana/spl-token"; +import { expect } from "chai"; + +const DLMM_PROGRAM_ID = new PublicKey( + "LBUZKhRxPF3XUpBCjp4YzTKgLccjZhTSDM9YuVaPwxo" +); +const LB_PAIR = new PublicKey("9zUvxwFTcuumU6Dkq68wWEAiLEmA4sp1amdG96aY7Tmq"); + +const POSITION_WIDTH = 5; +const BIN_ARRAY_SIZE = 70; + +function binArrayIndex(binId: number): BN { + const quotient = Math.trunc(binId / BIN_ARRAY_SIZE); + const remainder = binId % BIN_ARRAY_SIZE; + const index = remainder < 0 ? quotient - 1 : quotient; + return new BN(index); +} + +function deriveBinArrayPda(lbPair: PublicKey, index: BN): PublicKey { + const indexBuf = Buffer.alloc(8); + indexBuf.writeBigInt64LE(BigInt(index.toString())); + const [pda] = PublicKey.findProgramAddressSync( + [Buffer.from("bin_array"), lbPair.toBuffer(), indexBuf], + DLMM_PROGRAM_ID + ); + return pda; +} + +function deriveEventAuthority(): PublicKey { + const [pda] = PublicKey.findProgramAddressSync( + [Buffer.from("__event_authority")], + DLMM_PROGRAM_ID + ); + return pda; +} + +describe("Liquidation", () => { + const provider = anchor.AnchorProvider.env(); + anchor.setProvider(provider); + + const program = anchor.workspace.metlevEngine as Program; + const authority = provider.wallet.publicKey; + + let configPda: PublicKey; + let lendingVaultPda: PublicKey; + let wsolVaultPda: PublicKey; + let collateralConfigPda: PublicKey; + let priceOraclePda: PublicKey; + let dlmmPool: DLMM; + + // Original config values to restore after tests + const ORIGINAL_MAX_LTV = 7500; + const ORIGINAL_LIQUIDATION_THRESHOLD = 8000; + + // ─── Helpers ────────────────────────────────────────────────────────────── + + async function wrapSol(payer: Keypair, recipient: PublicKey, lamports: number): Promise { + const ata = await getOrCreateAssociatedTokenAccount( + provider.connection, + provider.wallet.payer, + NATIVE_MINT, + recipient + ); + if (lamports > 0) { + const tx = new Transaction().add( + SystemProgram.transfer({ fromPubkey: payer.publicKey, toPubkey: ata.address, lamports }), + createSyncNativeInstruction(ata.address) + ); + await provider.sendAndConfirm(tx, [payer]); + } + return ata.address; + } + + async function ensureBinArrayExists(index: BN): Promise { + const pda = deriveBinArrayPda(LB_PAIR, index); + if (await provider.connection.getAccountInfo(pda)) return; + const ixs = await dlmmPool.initializeBinArrays([index], authority); + if (ixs.length > 0) await provider.sendAndConfirm(new Transaction().add(...ixs)); + } + + async function openPosition( + user: Keypair, + positionPda: PublicKey, + wsolVault: PublicKey, + ): Promise<{ metPositionKp: Keypair; minBinId: number; maxBinId: number }> { + await dlmmPool.refetchStates(); + const activeBin = await dlmmPool.getActiveBin(); + const activeBinId = activeBin.binId; + const isWsolX = dlmmPool.lbPair.tokenXMint.equals(NATIVE_MINT); + + const activeArrayIdx = binArrayIndex(activeBinId).toNumber(); + const half = Math.floor(POSITION_WIDTH / 2); + + let minBinId: number, maxBinId: number; + + if (isWsolX) { + minBinId = activeBinId + 1; + maxBinId = activeBinId + POSITION_WIDTH; + } else { + minBinId = activeBinId - POSITION_WIDTH + 1; + maxBinId = activeBinId; + } + + let lowerIdx = binArrayIndex(minBinId); + let upperIdx = binArrayIndex(maxBinId); + + if (lowerIdx.eq(upperIdx)) { + if (isWsolX) { + let boundary = (activeArrayIdx + 1) * BIN_ARRAY_SIZE; + if (boundary - half <= activeBinId) boundary += BIN_ARRAY_SIZE; + minBinId = boundary - half; + maxBinId = minBinId + POSITION_WIDTH - 1; + } else { + let boundary = activeArrayIdx * BIN_ARRAY_SIZE; + if (boundary + (POSITION_WIDTH - 1 - half) > activeBinId) boundary -= BIN_ARRAY_SIZE; + minBinId = boundary - half; + maxBinId = minBinId + POSITION_WIDTH - 1; + } + lowerIdx = binArrayIndex(minBinId); + upperIdx = binArrayIndex(maxBinId); + } + + await ensureBinArrayExists(lowerIdx); + await ensureBinArrayExists(upperIdx); + + const binArrayLower = deriveBinArrayPda(LB_PAIR, lowerIdx); + const binArrayUpper = deriveBinArrayPda(LB_PAIR, upperIdx); + + const reserve = isWsolX ? dlmmPool.lbPair.reserveX : dlmmPool.lbPair.reserveY; + const tokenMint = isWsolX ? dlmmPool.lbPair.tokenXMint : dlmmPool.lbPair.tokenYMint; + + const binLiquidityDist = []; + for (let i = minBinId; i <= maxBinId; i++) { + binLiquidityDist.push({ binId: i, weight: 1000 }); + } + + // Refresh oracle timestamp before opening + await program.methods + .updateMockOracle(new BN(150_000_000)) + .accountsStrict({ authority, config: configPda, mint: NATIVE_MINT, mockOracle: priceOraclePda }) + .rpc(); + + const metPositionKp = Keypair.generate(); + + await program.methods + .openPosition( + new BN(20_000), // 2x leverage + minBinId, + maxBinId - minBinId + 1, + activeBinId, + 10, + binLiquidityDist + ) + .accountsStrict({ + user: user.publicKey, + config: configPda, + wsolMint: NATIVE_MINT, + position: positionPda, + lendingVault: lendingVaultPda, + wsolVault: wsolVault, + collateralConfig: collateralConfigPda, + priceOracle: priceOraclePda, + metPosition: metPositionKp.publicKey, + lbPair: LB_PAIR, + binArrayBitmapExtension: null, + reserve, + tokenMint, + binArrayLower, + binArrayUpper, + eventAuthority: deriveEventAuthority(), + tokenProgram: TOKEN_PROGRAM_ID, + dlmmProgram: DLMM_PROGRAM_ID, + systemProgram: SystemProgram.programId, + rent: SYSVAR_RENT_PUBKEY, + }) + .signers([user, metPositionKp]) + .preInstructions([ComputeBudgetProgram.setComputeUnitLimit({ units: 400_000 })]) + .rpc({ commitment: "confirmed" }); + + console.log(" Opened DLMM position:", metPositionKp.publicKey.toBase58()); + return { metPositionKp, minBinId, maxBinId }; + } + + async function buildLiquidateAccounts( + liquidator: PublicKey, + positionOwner: PublicKey, + positionPda: PublicKey, + metPositionPubkey: PublicKey, + fromBinId: number, + toBinId: number, + ) { + await dlmmPool.refetchStates(); + + const lowerIdx = binArrayIndex(fromBinId); + const upperIdx = binArrayIndex(toBinId); + const binArrayLower = deriveBinArrayPda(LB_PAIR, lowerIdx); + const binArrayUpper = deriveBinArrayPda(LB_PAIR, upperIdx); + + const userTokenXAccount = await getOrCreateAssociatedTokenAccount( + provider.connection, + provider.wallet.payer, + dlmmPool.lbPair.tokenXMint, + lendingVaultPda, + true + ); + + const [collateralVault] = PublicKey.findProgramAddressSync( + [Buffer.from("vault"), positionOwner.toBuffer(), NATIVE_MINT.toBuffer()], + program.programId + ); + + return { + accounts: { + liquidator, + config: configPda, + wsolMint: NATIVE_MINT, + position: positionPda, + lendingVault: lendingVaultPda, + collateralConfig: collateralConfigPda, + priceOracle: priceOraclePda, + wsolVault: wsolVaultPda, + positionOwner, + collateralVault, + metPosition: metPositionPubkey, + lbPair: LB_PAIR, + binArrayBitmapExtension: null, + userTokenX: userTokenXAccount.address, + reserveX: dlmmPool.lbPair.reserveX, + reserveY: dlmmPool.lbPair.reserveY, + tokenXMint: dlmmPool.lbPair.tokenXMint, + tokenYMint: dlmmPool.lbPair.tokenYMint, + binArrayLower, + binArrayUpper, + oracle: dlmmPool.lbPair.oracle, + eventAuthority: deriveEventAuthority(), + tokenProgram: TOKEN_PROGRAM_ID, + associatedTokenProgram: ASSOCIATED_TOKEN_PROGRAM_ID, + dlmmProgram: DLMM_PROGRAM_ID, + systemProgram: SystemProgram.programId, + }, + }; + } + + // ─── Shared protocol setup ───────────────────────────────────────────────── + + before("Init protocol and DLMM SDK", async function () { + [configPda] = PublicKey.findProgramAddressSync( + [Buffer.from("config")], + program.programId + ); + [lendingVaultPda] = PublicKey.findProgramAddressSync( + [Buffer.from("lending_vault")], + program.programId + ); + [wsolVaultPda] = PublicKey.findProgramAddressSync( + [Buffer.from("wsol_vault"), lendingVaultPda.toBuffer()], + program.programId + ); + [collateralConfigPda] = PublicKey.findProgramAddressSync( + [Buffer.from("collateral_config"), NATIVE_MINT.toBuffer()], + program.programId + ); + [priceOraclePda] = PublicKey.findProgramAddressSync( + [Buffer.from("mock_oracle"), NATIVE_MINT.toBuffer()], + program.programId + ); + + // Idempotent: config + try { + await program.account.config.fetch(configPda); + } catch { + await program.methods.initialize() + .accountsStrict({ authority, config: configPda, systemProgram: SystemProgram.programId }) + .rpc(); + } + + // Idempotent: lending vault + try { + await program.account.lendingVault.fetch(lendingVaultPda); + } catch { + await program.methods.initializeLendingVault() + .accountsStrict({ + authority, config: configPda, lendingVault: lendingVaultPda, + wsolMint: NATIVE_MINT, wsolVault: wsolVaultPda, + tokenProgram: TOKEN_PROGRAM_ID, systemProgram: SystemProgram.programId, + }) + .rpc(); + } + + // Idempotent: mock oracle for wSOL + try { + await program.account.mockOracle.fetch(priceOraclePda); + } catch { + await program.methods.initializeMockOracle(new BN(150_000_000)) + .accountsStrict({ + authority, config: configPda, mint: NATIVE_MINT, mockOracle: priceOraclePda, + systemProgram: SystemProgram.programId, + }) + .rpc(); + console.log(" Mock oracle initialized."); + } + + // Idempotent: collateral config for wSOL + try { + const existing = await program.account.collateralConfig.fetch(collateralConfigPda); + if (existing.oracle.toBase58() !== priceOraclePda.toBase58()) { + await program.methods.updateCollateralOracle(NATIVE_MINT, priceOraclePda) + .accountsStrict({ authority, config: configPda, collateralConfig: collateralConfigPda }) + .rpc(); + } + } catch { + await program.methods.registerCollateral( + priceOraclePda, + ORIGINAL_MAX_LTV, + ORIGINAL_LIQUIDATION_THRESHOLD, + 500, // liquidation_penalty (5%) + new BN(Math.floor(0.1 * LAMPORTS_PER_SOL)), + 500, // interest_rate_bps (5%) + new BN(3600), + ) + .accountsStrict({ + authority, config: configPda, mint: NATIVE_MINT, + collateralConfig: collateralConfigPda, + tokenProgram: TOKEN_PROGRAM_ID, + systemProgram: SystemProgram.programId, + }) + .rpc(); + console.log(" CollateralConfig registered."); + } + + // Ensure we start with original LTV params (may have been changed by prior test runs) + await program.methods + .updateCollateralLtvParams(NATIVE_MINT, ORIGINAL_MAX_LTV, ORIGINAL_LIQUIDATION_THRESHOLD) + .accountsStrict({ authority, config: configPda, collateralConfig: collateralConfigPda }) + .rpc(); + + // Supply wSOL to lending vault + const lp = Keypair.generate(); + const lpSig = await provider.connection.requestAirdrop(lp.publicKey, 15 * LAMPORTS_PER_SOL); + await provider.connection.confirmTransaction(lpSig); + const lpWsolAta = await wrapSol(lp, lp.publicKey, 10 * LAMPORTS_PER_SOL); + const [lpPositionPda] = PublicKey.findProgramAddressSync( + [Buffer.from("lp_position"), lp.publicKey.toBuffer()], + program.programId + ); + try { + await program.account.lpPosition.fetch(lpPositionPda); + } catch { + await program.methods.supply(new BN(8 * LAMPORTS_PER_SOL)) + .accountsStrict({ + signer: lp.publicKey, lendingVault: lendingVaultPda, + wsolMint: NATIVE_MINT, wsolVault: wsolVaultPda, signerWsolAta: lpWsolAta, + lpPosition: lpPositionPda, tokenProgram: TOKEN_PROGRAM_ID, + systemProgram: SystemProgram.programId, + }) + .signers([lp]) + .rpc(); + console.log(" LP supplied 8 wSOL to vault."); + } + + // Init DLMM SDK + try { + dlmmPool = await DLMM.create(provider.connection, LB_PAIR, { cluster: "devnet" }); + await dlmmPool.refetchStates(); + } catch { + console.log(" LB pair not found - skipping liquidation tests (requires devnet)"); + this.skip(); + } + + console.log("\n=== Liquidation Setup ==="); + console.log(" configPda :", configPda.toBase58()); + console.log(" lendingVaultPda :", lendingVaultPda.toBase58()); + console.log(" wsolVaultPda :", wsolVaultPda.toBase58()); + }); + + after("Restore original LTV params", async function () { + try { + await program.methods + .updateCollateralLtvParams(NATIVE_MINT, ORIGINAL_MAX_LTV, ORIGINAL_LIQUIDATION_THRESHOLD) + .accountsStrict({ authority, config: configPda, collateralConfig: collateralConfigPda }) + .rpc(); + } catch { + // best-effort restore + } + }); + + // ─── Happy path ─────────────────────────────────────────────────────────── + + describe("liquidate -- happy path", () => { + const positionUser = Keypair.generate(); + const liquidator = Keypair.generate(); + let positionPda: PublicKey; + let collateralVaultPda: PublicKey; + let metPositionKp: Keypair; + let openedMinBinId: number; + let openedMaxBinId: number; + let debtBefore: BN; + const depositAmount = new BN(2 * LAMPORTS_PER_SOL); + + before("Fund, deposit collateral, open leveraged position, lower threshold", async function () { + [positionPda] = PublicKey.findProgramAddressSync( + [Buffer.from("position"), positionUser.publicKey.toBuffer(), NATIVE_MINT.toBuffer()], + program.programId + ); + [collateralVaultPda] = PublicKey.findProgramAddressSync( + [Buffer.from("vault"), positionUser.publicKey.toBuffer(), NATIVE_MINT.toBuffer()], + program.programId + ); + + // Fund users + const sigs = await Promise.all([ + provider.connection.requestAirdrop(positionUser.publicKey, 10 * LAMPORTS_PER_SOL), + provider.connection.requestAirdrop(liquidator.publicKey, 5 * LAMPORTS_PER_SOL), + ]); + await Promise.all(sigs.map(s => provider.connection.confirmTransaction(s))); + + // Deposit collateral + await program.methods.depositSolCollateral(depositAmount) + .accountsStrict({ + user: positionUser.publicKey, config: configPda, mint: NATIVE_MINT, + collateralConfig: collateralConfigPda, vault: collateralVaultPda, + position: positionPda, systemProgram: SystemProgram.programId, + tokenProgram: TOKEN_PROGRAM_ID, + }) + .signers([positionUser]) + .rpc(); + console.log(" Deposited", depositAmount.toNumber() / LAMPORTS_PER_SOL, "SOL as collateral"); + + // Open position (2x leverage -> LTV ~50%) + const result = await openPosition(positionUser, positionPda, wsolVaultPda); + metPositionKp = result.metPositionKp; + openedMinBinId = result.minBinId; + openedMaxBinId = result.maxBinId; + + const pos = await program.account.position.fetch(positionPda); + debtBefore = pos.debtAmount; + console.log(" Position opened: debt =", debtBefore.toString(), + " collateral =", pos.collateralAmount.toString()); + + // Compute LTV: debt / (collateral + debt) + const collateral = pos.collateralAmount.toNumber(); + const debt = pos.debtAmount.toNumber(); + const ltv = Math.floor((debt * 10000) / (collateral + debt)); + console.log(" Current LTV:", ltv, "bps (", (ltv / 100).toFixed(1), "%)"); + + // Lower liquidation threshold below current LTV to make position unhealthy + const newThreshold = ltv - 100; // 1% below current LTV + const newMaxLtv = newThreshold - 100; // max_ltv must be < threshold + console.log(" Lowering threshold to", newThreshold, "bps (max_ltv:", newMaxLtv, ")"); + + await program.methods + .updateCollateralLtvParams(NATIVE_MINT, newMaxLtv, newThreshold) + .accountsStrict({ authority, config: configPda, collateralConfig: collateralConfigPda }) + .rpc(); + + // Refresh oracle timestamp + await program.methods + .updateMockOracle(new BN(150_000_000)) + .accountsStrict({ authority, config: configPda, mint: NATIVE_MINT, mockOracle: priceOraclePda }) + .rpc(); + }); + + it("Liquidates unhealthy position, repays debt, sends penalty to liquidator", async () => { + const vaultBefore = await program.account.lendingVault.fetch(lendingVaultPda); + const wsolVaultBalanceBefore = await provider.connection.getTokenAccountBalance(wsolVaultPda); + + const { accounts } = await buildLiquidateAccounts( + liquidator.publicKey, + positionUser.publicKey, + positionPda, + metPositionKp.publicKey, + openedMinBinId, + openedMaxBinId, + ); + + // Track native SOL balances (penalty + remainder are native SOL from collateral vault) + const liquidatorLamportsBefore = await provider.connection.getBalance(liquidator.publicKey); + const ownerLamportsBefore = await provider.connection.getBalance(positionUser.publicKey); + const collateralVaultBefore = await provider.connection.getBalance(accounts.collateralVault); + + const tx = await program.methods + .liquidate(openedMinBinId, openedMaxBinId) + .accountsStrict(accounts) + .signers([liquidator]) + .preInstructions([ComputeBudgetProgram.setComputeUnitLimit({ units: 400_000 })]) + .rpc({ commitment: "confirmed" }) + .catch((e) => { + console.log("\n liquidate error:", e.message); + if (e.logs) console.log(" logs:\n ", e.logs.join("\n ")); + throw e; + }); + + console.log("\n liquidate tx:", tx); + + // Verify position state + const positionAfter = await program.account.position.fetch(positionPda); + expect(positionAfter.status).to.deep.equal( + { liquidated: {} }, + "Position status must be Liquidated" + ); + expect(positionAfter.debtAmount.toNumber()).to.equal(0, "debt_amount must be zeroed"); + expect(positionAfter.collateralAmount.toNumber()).to.equal(0, "collateral_amount must be zeroed"); + + // Verify lending vault accounting + const vaultAfter = await program.account.lendingVault.fetch(lendingVaultPda); + expect(vaultAfter.totalBorrowed.toString()).to.equal( + vaultBefore.totalBorrowed.sub(debtBefore).toString(), + "totalBorrowed must decrease by the debt amount" + ); + + // Verify DLMM position account is gone + const metPositionInfo = await provider.connection.getAccountInfo(metPositionKp.publicKey); + expect(metPositionInfo).to.be.null; + + // Verify wSOL vault balance: LP proceeds returned to vault (repays debt) + const wsolVaultBalanceAfter = await provider.connection.getTokenAccountBalance(wsolVaultPda); + const vaultDelta = parseInt(wsolVaultBalanceAfter.value.amount) - parseInt(wsolVaultBalanceBefore.value.amount); + expect(vaultDelta).to.be.greaterThanOrEqual(0, + "wSOL vault must not lose funds from liquidation" + ); + + // Collateral vault should be drained (penalty + remainder distributed) + const collateralVaultAfter = await provider.connection.getBalance(accounts.collateralVault); + const collateralDistributed = collateralVaultBefore - collateralVaultAfter; + + // Liquidator receives penalty from collateral (native SOL) + // Note: liquidator also pays tx fee, so net delta may be slightly less + const liquidatorLamportsAfter = await provider.connection.getBalance(liquidator.publicKey); + const liquidatorDelta = liquidatorLamportsAfter - liquidatorLamportsBefore; + + // Position owner receives remainder from collateral (native SOL) + const ownerLamportsAfter = await provider.connection.getBalance(positionUser.publicKey); + const ownerDelta = ownerLamportsAfter - ownerLamportsBefore; + + // Collateral should be fully distributed (penalty + remainder = original collateral) + const collateral = depositAmount.toNumber(); + const expectedPenalty = Math.floor(collateral * 500 / 10000); // 5% penalty + const expectedRemainder = collateral - expectedPenalty; + + expect(collateralDistributed).to.equal(collateral, + "Full collateral must be distributed from vault" + ); + expect(ownerDelta).to.equal(expectedRemainder, + "Owner must receive collateral minus penalty" + ); + // Liquidator delta includes penalty minus tx fees + rent from DLMM close + expect(liquidatorDelta).to.be.greaterThan(0, + "Liquidator must receive penalty (net of tx fees)" + ); + + const debt = debtBefore.toNumber(); + console.log(" Position status : liquidated"); + console.log(" Debt repaid :", debt / LAMPORTS_PER_SOL, "SOL"); + console.log(" totalBorrowed delta :", debtBefore.toString(), "->", vaultAfter.totalBorrowed.toString()); + console.log(" wSOL vault delta :", vaultDelta / LAMPORTS_PER_SOL, "SOL (LP proceeds)"); + console.log(" Collateral deposited :", collateral / LAMPORTS_PER_SOL, "SOL"); + console.log(" Expected penalty (5%):", expectedPenalty / LAMPORTS_PER_SOL, "SOL"); + console.log(" Owner remainder :", ownerDelta / LAMPORTS_PER_SOL, "SOL"); + console.log(" Liquidator net delta :", liquidatorDelta / LAMPORTS_PER_SOL, "SOL (penalty - tx fees + rent)"); + console.log(" DLMM position : closed on-chain"); + }); + }); + + // ─── Constraints ────────────────────────────────────────────────────────── + + describe("liquidate -- constraints", () => { + it("Rejects liquidation of healthy position", async () => { + // Restore original thresholds so position would be healthy + await program.methods + .updateCollateralLtvParams(NATIVE_MINT, ORIGINAL_MAX_LTV, ORIGINAL_LIQUIDATION_THRESHOLD) + .accountsStrict({ authority, config: configPda, collateralConfig: collateralConfigPda }) + .rpc(); + + // Create a fresh position + const user = Keypair.generate(); + const sig = await provider.connection.requestAirdrop(user.publicKey, 10 * LAMPORTS_PER_SOL); + await provider.connection.confirmTransaction(sig); + + const [positionPda] = PublicKey.findProgramAddressSync( + [Buffer.from("position"), user.publicKey.toBuffer(), NATIVE_MINT.toBuffer()], + program.programId + ); + const [collateralVaultPda] = PublicKey.findProgramAddressSync( + [Buffer.from("vault"), user.publicKey.toBuffer(), NATIVE_MINT.toBuffer()], + program.programId + ); + + await program.methods.depositSolCollateral(new BN(2 * LAMPORTS_PER_SOL)) + .accountsStrict({ + user: user.publicKey, config: configPda, mint: NATIVE_MINT, + collateralConfig: collateralConfigPda, vault: collateralVaultPda, + position: positionPda, systemProgram: SystemProgram.programId, + tokenProgram: TOKEN_PROGRAM_ID, + }) + .signers([user]) + .rpc(); + + const result = await openPosition(user, positionPda, wsolVaultPda); + + const liquidator = Keypair.generate(); + const liqSig = await provider.connection.requestAirdrop(liquidator.publicKey, 5 * LAMPORTS_PER_SOL); + await provider.connection.confirmTransaction(liqSig); + + const { accounts } = await buildLiquidateAccounts( + liquidator.publicKey, + user.publicKey, + positionPda, + result.metPositionKp.publicKey, + result.minBinId, + result.maxBinId, + ); + + try { + await program.methods + .liquidate(result.minBinId, result.maxBinId) + .accountsStrict(accounts) + .signers([liquidator]) + .preInstructions([ComputeBudgetProgram.setComputeUnitLimit({ units: 400_000 })]) + .rpc(); + throw new Error("Should have failed"); + } catch (e) { + expect((e as Error).message).to.match(/PositionHealthy|6007/i); + console.log(" Correctly rejected liquidation of healthy position"); + } + }); + }); +});