|
| 1 | +/** |
| 2 | + * force-liquidate.ts |
| 3 | + * |
| 4 | + * Emergency script to liquidate a stuck position by: |
| 5 | + * 1. Dropping the oracle price to make the position unhealthy |
| 6 | + * 2. Calling liquidate with the known DLMM position address |
| 7 | + * 3. Restoring the oracle price |
| 8 | + * |
| 9 | + * Usage: |
| 10 | + * anchor run force-liquidate |
| 11 | + */ |
| 12 | + |
| 13 | +import * as anchor from "@coral-xyz/anchor"; |
| 14 | +import { Program, BN } from "@coral-xyz/anchor"; |
| 15 | +import { MetlevEngine } from "../target/types/metlev_engine"; |
| 16 | +import { PublicKey, SystemProgram } from "@solana/web3.js"; |
| 17 | +import { |
| 18 | + TOKEN_PROGRAM_ID, |
| 19 | + ASSOCIATED_TOKEN_PROGRAM_ID, |
| 20 | + NATIVE_MINT, |
| 21 | + getOrCreateAssociatedTokenAccount, |
| 22 | +} from "@solana/spl-token"; |
| 23 | +import DLMM from "@meteora-ag/dlmm"; |
| 24 | + |
| 25 | +// ── Known addresses from the stuck transaction ── |
| 26 | +const DLMM_POSITION = new PublicKey( |
| 27 | + "Eh4MVpB9Ykvwpa7dhZrQzJoHdGUwWbYFbDEniTFRinFi" |
| 28 | +); |
| 29 | +const LB_PAIR = new PublicKey( |
| 30 | + "49SMeRravr4WEfbJQY9d38PAoA3E5pxKxtvKoYN8wp3a" |
| 31 | +); |
| 32 | +const DLMM_PROGRAM_ID = new PublicKey( |
| 33 | + "LBUZKhRxPF3XUpBCjp4YzTKgLccjZhTSDM9YuVaPwxo" |
| 34 | +); |
| 35 | + |
| 36 | +// The wallet that opened the position (Phantom browser wallet) |
| 37 | +const POSITION_OWNER = new PublicKey( |
| 38 | + "hry6ZGfH4mMmF4LBDFiWjV23bScUQ3NjVrWb9esFS5S" |
| 39 | +); |
| 40 | + |
| 41 | +// Position was opened with lower_bin_id = -4, width = 5 → bins -4 to 0 |
| 42 | +const FROM_BIN_ID = -4; |
| 43 | +const TO_BIN_ID = 0; |
| 44 | + |
| 45 | +function binArrayIndex(binId: number): BN { |
| 46 | + const BIN_ARRAY_SIZE = 70; |
| 47 | + const quotient = Math.trunc(binId / BIN_ARRAY_SIZE); |
| 48 | + const remainder = binId % BIN_ARRAY_SIZE; |
| 49 | + const index = remainder < 0 ? quotient - 1 : quotient; |
| 50 | + return new BN(index); |
| 51 | +} |
| 52 | + |
| 53 | +function deriveBinArrayPda(lbPair: PublicKey, index: BN): PublicKey { |
| 54 | + const indexBuf = Buffer.alloc(8); |
| 55 | + const val = index.toNumber(); |
| 56 | + const lo = val & 0xffffffff; |
| 57 | + const hi = val < 0 ? -1 : 0; |
| 58 | + indexBuf.writeInt32LE(lo, 0); |
| 59 | + indexBuf.writeInt32LE(hi, 4); |
| 60 | + const [pda] = PublicKey.findProgramAddressSync( |
| 61 | + [Buffer.from("bin_array"), lbPair.toBuffer(), indexBuf], |
| 62 | + DLMM_PROGRAM_ID |
| 63 | + ); |
| 64 | + return pda; |
| 65 | +} |
| 66 | + |
| 67 | +function deriveEventAuthority(): PublicKey { |
| 68 | + const [pda] = PublicKey.findProgramAddressSync( |
| 69 | + [Buffer.from("__event_authority")], |
| 70 | + DLMM_PROGRAM_ID |
| 71 | + ); |
| 72 | + return pda; |
| 73 | +} |
| 74 | + |
| 75 | +async function main() { |
| 76 | + const provider = anchor.AnchorProvider.env(); |
| 77 | + anchor.setProvider(provider); |
| 78 | + |
| 79 | + const program = anchor.workspace.metlevEngine as Program<MetlevEngine>; |
| 80 | + const authority = provider.wallet.publicKey; |
| 81 | + const connection = provider.connection; |
| 82 | + |
| 83 | + console.log("=== Force Liquidate Stuck Position ==="); |
| 84 | + console.log("Authority:", authority.toBase58()); |
| 85 | + |
| 86 | + // Derive PDAs |
| 87 | + const [configPda] = PublicKey.findProgramAddressSync( |
| 88 | + [Buffer.from("config")], |
| 89 | + program.programId |
| 90 | + ); |
| 91 | + const [lendingVaultPda] = PublicKey.findProgramAddressSync( |
| 92 | + [Buffer.from("lending_vault")], |
| 93 | + program.programId |
| 94 | + ); |
| 95 | + const [wsolVaultPda] = PublicKey.findProgramAddressSync( |
| 96 | + [Buffer.from("wsol_vault"), lendingVaultPda.toBuffer()], |
| 97 | + program.programId |
| 98 | + ); |
| 99 | + const [collateralConfigPda] = PublicKey.findProgramAddressSync( |
| 100 | + [Buffer.from("collateral_config"), NATIVE_MINT.toBuffer()], |
| 101 | + program.programId |
| 102 | + ); |
| 103 | + const [priceOraclePda] = PublicKey.findProgramAddressSync( |
| 104 | + [Buffer.from("mock_oracle"), NATIVE_MINT.toBuffer()], |
| 105 | + program.programId |
| 106 | + ); |
| 107 | + const [positionPda] = PublicKey.findProgramAddressSync( |
| 108 | + [Buffer.from("position"), POSITION_OWNER.toBuffer(), NATIVE_MINT.toBuffer()], |
| 109 | + program.programId |
| 110 | + ); |
| 111 | + const [collateralVault] = PublicKey.findProgramAddressSync( |
| 112 | + [Buffer.from("vault"), POSITION_OWNER.toBuffer(), NATIVE_MINT.toBuffer()], |
| 113 | + program.programId |
| 114 | + ); |
| 115 | + |
| 116 | + // Fetch current state |
| 117 | + const position = await program.account.position.fetch(positionPda); |
| 118 | + const oracleBefore = await program.account.mockOracle.fetch(priceOraclePda); |
| 119 | + const priceBefore = oracleBefore.price.toNumber() / 1_000_000; |
| 120 | + |
| 121 | + console.log("\nPosition state:"); |
| 122 | + console.log(" Debt:", position.debtAmount.toNumber() / 1e9, "SOL"); |
| 123 | + console.log(" Collateral:", position.collateralAmount.toNumber() / 1e9, "SOL"); |
| 124 | + console.log(" Status:", Object.keys(position.status)[0]); |
| 125 | + console.log(" Meteora position:", position.meteoraPosition.toBase58()); |
| 126 | + console.log(" Oracle price: $" + priceBefore); |
| 127 | + |
| 128 | + if (position.debtAmount.isZero()) { |
| 129 | + console.log("\nPosition has no debt, nothing to liquidate."); |
| 130 | + return; |
| 131 | + } |
| 132 | + |
| 133 | + // Step 1: Drop oracle price to make position unhealthy |
| 134 | + // LTV = debt / (collateral + debt). With 1 SOL collateral and 2 SOL debt, |
| 135 | + // LTV = 2/3 = 66.7%. Liq threshold is typically 80%. |
| 136 | + // We need LTV > threshold. Setting price very low doesn't change LTV since |
| 137 | + // both collateral and debt are in SOL. |
| 138 | + // But the LTV formula uses collateral_value and debt_value independently. |
| 139 | + // Actually since both are SOL-denominated, the price cancels out. |
| 140 | + // Let me check the collateral config threshold. |
| 141 | + const collateralConfig = await program.account.collateralConfig.fetch(collateralConfigPda); |
| 142 | + const liqThreshold = collateralConfig.liquidationThreshold / 100; |
| 143 | + const currentLtv = position.debtAmount.toNumber() / |
| 144 | + (position.collateralAmount.toNumber() + position.debtAmount.toNumber()) * 100; |
| 145 | + |
| 146 | + console.log("\n Current LTV:", currentLtv.toFixed(1) + "%"); |
| 147 | + console.log(" Liq threshold:", liqThreshold + "%"); |
| 148 | + |
| 149 | + if (currentLtv < liqThreshold) { |
| 150 | + // Need to lower the threshold temporarily or raise LTV. |
| 151 | + // Since both sides are SOL, price changes don't help. |
| 152 | + // We need to lower the liquidation threshold below current LTV. |
| 153 | + console.log("\n Position is healthy at current LTV. Lowering liquidation threshold..."); |
| 154 | + |
| 155 | + // Must satisfy: liquidation_threshold > max_ltv, and threshold < currentLtv |
| 156 | + // So set both below currentLtv with threshold slightly above maxLtv. |
| 157 | + const newMaxLtv = Math.floor(currentLtv * 100) - 200; // e.g., 6470 |
| 158 | + const newThreshold = Math.floor(currentLtv * 100) - 100; // e.g., 6570 |
| 159 | + await program.methods |
| 160 | + .updateCollateralLtvParams( |
| 161 | + NATIVE_MINT, |
| 162 | + newMaxLtv, |
| 163 | + newThreshold, |
| 164 | + ) |
| 165 | + .accountsStrict({ |
| 166 | + authority, |
| 167 | + config: configPda, |
| 168 | + collateralConfig: collateralConfigPda, |
| 169 | + }) |
| 170 | + .rpc(); |
| 171 | + console.log(" Max LTV lowered to " + (newMaxLtv / 100) + "%, threshold to " + (newThreshold / 100) + "%"); |
| 172 | + } |
| 173 | + |
| 174 | + // Step 2: Load DLMM pool for reserve/mint info |
| 175 | + console.log("\n[2] Loading DLMM pool..."); |
| 176 | + const dlmmPool = await DLMM.create(connection, LB_PAIR, { cluster: "devnet" }); |
| 177 | + |
| 178 | + const lowerIdx = binArrayIndex(FROM_BIN_ID); |
| 179 | + const upperIdx = binArrayIndex(TO_BIN_ID); |
| 180 | + const binArrayLower = deriveBinArrayPda(LB_PAIR, lowerIdx); |
| 181 | + const binArrayUpper = deriveBinArrayPda(LB_PAIR, upperIdx); |
| 182 | + |
| 183 | + // Get or create the lending vault's token X ATA |
| 184 | + const userTokenXAccount = await getOrCreateAssociatedTokenAccount( |
| 185 | + connection, |
| 186 | + provider.wallet.payer, |
| 187 | + dlmmPool.lbPair.tokenXMint, |
| 188 | + lendingVaultPda, |
| 189 | + true // allowOwnerOffCurve |
| 190 | + ); |
| 191 | + |
| 192 | + console.log(" Bin arrays:", lowerIdx.toString(), "to", upperIdx.toString()); |
| 193 | + console.log(" Token X mint:", dlmmPool.lbPair.tokenXMint.toBase58()); |
| 194 | + |
| 195 | + // Step 3: Update oracle (refresh timestamp so it's not stale) |
| 196 | + console.log("\n[3] Refreshing oracle timestamp..."); |
| 197 | + await program.methods |
| 198 | + .updateMockOracle(oracleBefore.price) |
| 199 | + .accountsStrict({ |
| 200 | + authority, |
| 201 | + config: configPda, |
| 202 | + mint: NATIVE_MINT, |
| 203 | + mockOracle: priceOraclePda, |
| 204 | + }) |
| 205 | + .rpc(); |
| 206 | + |
| 207 | + // Step 4: Call liquidate |
| 208 | + console.log("\n[4] Calling liquidate..."); |
| 209 | + const tx = await program.methods |
| 210 | + .liquidate(FROM_BIN_ID, TO_BIN_ID) |
| 211 | + .accountsStrict({ |
| 212 | + liquidator: authority, |
| 213 | + config: configPda, |
| 214 | + wsolMint: NATIVE_MINT, |
| 215 | + position: positionPda, |
| 216 | + lendingVault: lendingVaultPda, |
| 217 | + collateralConfig: collateralConfigPda, |
| 218 | + priceOracle: priceOraclePda, |
| 219 | + wsolVault: wsolVaultPda, |
| 220 | + positionOwner: POSITION_OWNER, |
| 221 | + collateralVault, |
| 222 | + metPosition: DLMM_POSITION, |
| 223 | + lbPair: LB_PAIR, |
| 224 | + binArrayBitmapExtension: null, |
| 225 | + userTokenX: userTokenXAccount.address, |
| 226 | + reserveX: dlmmPool.lbPair.reserveX, |
| 227 | + reserveY: dlmmPool.lbPair.reserveY, |
| 228 | + tokenXMint: dlmmPool.lbPair.tokenXMint, |
| 229 | + tokenYMint: dlmmPool.lbPair.tokenYMint, |
| 230 | + binArrayLower, |
| 231 | + binArrayUpper, |
| 232 | + oracle: dlmmPool.lbPair.oracle, |
| 233 | + eventAuthority: deriveEventAuthority(), |
| 234 | + tokenProgram: TOKEN_PROGRAM_ID, |
| 235 | + associatedTokenProgram: ASSOCIATED_TOKEN_PROGRAM_ID, |
| 236 | + dlmmProgram: DLMM_PROGRAM_ID, |
| 237 | + systemProgram: SystemProgram.programId, |
| 238 | + }) |
| 239 | + .preInstructions([ |
| 240 | + anchor.web3.ComputeBudgetProgram.setComputeUnitLimit({ units: 600_000 }), |
| 241 | + ]) |
| 242 | + .rpc(); |
| 243 | + |
| 244 | + console.log(" Liquidation tx:", tx); |
| 245 | + |
| 246 | + // Step 5: Restore liquidation threshold |
| 247 | + console.log("\n[5] Restoring liquidation threshold..."); |
| 248 | + await program.methods |
| 249 | + .updateCollateralLtvParams( |
| 250 | + NATIVE_MINT, |
| 251 | + collateralConfig.maxLtv, // restore original maxLtv |
| 252 | + collateralConfig.liquidationThreshold, // restore original threshold |
| 253 | + ) |
| 254 | + .accountsStrict({ |
| 255 | + authority, |
| 256 | + config: configPda, |
| 257 | + collateralConfig: collateralConfigPda, |
| 258 | + }) |
| 259 | + .rpc(); |
| 260 | + |
| 261 | + // Verify |
| 262 | + const positionAfter = await program.account.position.fetch(positionPda); |
| 263 | + console.log("\nPosition after liquidation:"); |
| 264 | + console.log(" Debt:", positionAfter.debtAmount.toNumber() / 1e9, "SOL"); |
| 265 | + console.log(" Collateral:", positionAfter.collateralAmount.toNumber() / 1e9, "SOL"); |
| 266 | + console.log(" Status:", Object.keys(positionAfter.status)[0]); |
| 267 | + console.log("\nDone! Position cleared."); |
| 268 | +} |
| 269 | + |
| 270 | +main().catch((err) => { |
| 271 | + console.error(err); |
| 272 | + process.exit(1); |
| 273 | +}); |
0 commit comments