Skip to content

Commit 9dfcd2d

Browse files
committed
feat: add script to force liquidate and fix pool tracking when opening position
1 parent 35ad070 commit 9dfcd2d

5 files changed

Lines changed: 520 additions & 7 deletions

File tree

Anchor.toml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,3 +24,5 @@ init-protocol = "npx ts-node scripts/init-protocol.ts"
2424
update-oracle = "npx ts-node scripts/update-oracle.ts"
2525
supply = "npx ts-node scripts/supply.ts"
2626
withdraw-lp = "npx ts-node scripts/withdraw-lp.ts"
27+
setup-pool = "npx ts-node scripts/setup-pool.ts"
28+
force-liquidate = "npx ts-node scripts/force-liquidate.ts"

README.md

Lines changed: 41 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -81,7 +81,9 @@ scripts/
8181
├── init-protocol.ts # Bootstrap protocol on devnet
8282
├── update-oracle.ts # Update mock oracle price
8383
├── supply.ts # Supply wSOL to lending vault
84-
└── withdraw-lp.ts # Withdraw wSOL + interest from vault
84+
├── withdraw-lp.ts # Withdraw wSOL + interest from vault
85+
├── setup-pool.ts # Create DLMM pool on devnet
86+
└── force-liquidate.ts # Force-liquidate a stuck position
8587
```
8688

8789
### State Accounts
@@ -381,15 +383,44 @@ The pipeline:
381383
5. Installs Node dependencies (yarn cache)
382384
6. Runs `anchor build` + `anchor test`
383385

384-
## Future Enhancements (Post-POC)
386+
## Known Limitations (V1)
385387

388+
### Static LTV / Health Check
389+
390+
The current health check uses the **static collateral and debt amounts** recorded at position open time. Since both collateral and debt are denominated in SOL, oracle price changes cancel out and do not affect the LTV ratio.
391+
392+
In reality, the DLMM position value can diverge from the original debt:
393+
- **Price moves through the position's bins** → SOL gets swapped to the paired token (impermanent loss)
394+
- **On close/liquidation**, the LP proceeds may be less than the original borrowed amount
395+
- The protocol handles this correctly at settlement (bad debt absorption, shortfall from collateral), but the **health check cannot detect it in advance**
396+
397+
**V2 fix**: Compute dynamic health by reading the DLMM position's bin shares on-chain and calculating their current SOL-equivalent value against the outstanding debt. This requires cross-program reads of Meteora's position and bin array accounts.
398+
399+
### Same-Asset Collateral and Debt
400+
401+
V1 uses SOL as both collateral and borrowed asset. This means:
402+
- LTV is fixed at open time and never changes from market movements
403+
- Liquidation can only be triggered by admin threshold changes or DLMM position value loss (which isn't tracked)
404+
405+
**V2 fix**: Support cross-asset collateral (e.g., deposit USDC, borrow SOL). When SOL price rises, debt value increases relative to collateral, naturally pushing LTV up and enabling market-driven liquidations.
406+
407+
## Future Enhancements (V2+)
408+
409+
### Health & Risk
410+
- Dynamic health factor based on live DLMM position value
411+
- Cross-asset collateral (USDC, mSOL, jitoSOL)
412+
- Real oracle integration (Pyth, Switchboard)
413+
- Partial liquidations
414+
415+
### Yield & Capital Efficiency
386416
- Dynamic APY based on vault utilization (kink rate model)
387-
- Multiple leverage presets (2x, 3x, 5x)
388-
- Auto-rebalancing based on volatility
389417
- Fee compounding / auto-reinvestment
418+
- Auto-rebalancing based on volatility
390419
- Partial position closes
420+
421+
### Infrastructure
422+
- Multiple leverage presets (2x, 3x, 5x)
391423
- Integration with real lending protocols (Kamino, Solend)
392-
- Real oracle integration (Pyth, Switchboard)
393424
- Support for additional DLMM pairs
394425
- Frontend dashboard
395426

@@ -418,6 +449,9 @@ The pipeline:
418449
- [x] Liquidation system with collateral-based penalty distribution
419450
- [x] Collateral withdrawal after position closed
420451
- [x] Admin config updates (pause, LTV params, penalty, oracle, min deposit, enable/disable)
421-
- [x] Deployment scripts (init-protocol, update-oracle, supply, withdraw-lp)
422-
- [ ] Frontend dashboard
452+
- [x] Deployment scripts (init-protocol, update-oracle, supply, withdraw-lp, setup-pool, force-liquidate)
453+
- [x] Frontend dashboard (Next.js + wallet adapter)
454+
- [x] DLMM pool setup script for devnet
455+
- [ ] Dynamic health factor based on live DLMM position value
456+
- [ ] Cross-asset collateral support
423457
- [ ] Dynamic APY based on vault utilization (kink rate model)

programs/metlev-engine/src/instructions/open_position.rs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -200,6 +200,8 @@ impl<'info> OpenPosition<'info> {
200200
},
201201
)?;
202202

203+
self.position.meteora_position = self.met_position.key();
204+
203205
Ok(())
204206
}
205207
}

scripts/force-liquidate.ts

Lines changed: 273 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,273 @@
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

Comments
 (0)