Skip to content

Commit f302726

Browse files
BotID Developerclaude
andcommitted
feat(aerodrome): single-pass LP unwind script
lp_unwind.cjs verifies pool-vs-router roles on-chain (the listed 0xcF77… is the Router, not a pool) and fails closed rather than guessing. Dry-run by default; broadcast double-gated. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent 3433dde commit f302726

1 file changed

Lines changed: 199 additions & 0 deletions

File tree

scripts/lp_unwind.cjs

Lines changed: 199 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,199 @@
1+
#!/usr/bin/env node
2+
/**
3+
* B5 — lp_unwind: plan a single Aerodrome removeLiquidity to evacuate an LP
4+
* position to the Safe. Dry-run by default. Broadcast is double-gated.
5+
*
6+
* ROLE SAFETY: the canonical Aerodrome Router on Base is
7+
* 0xcF77a3Ba9A5CA399B7c97c74d54e5b1Beb874E43
8+
* (source: https://aerodrome.finance — "Router"; verify on
9+
* https://basescan.org/address/0xcF77a3Ba9A5CA399B7c97c74d54e5b1Beb874E43 )
10+
* That address is the ROUTER, NOT a pool. The DECISIONS doc listed it as the
11+
* "pool" — that is a conflation. This script verifies pool-vs-router roles on
12+
* chain and FAILS CLOSED (emits a TODO + non-zero exit) rather than guessing the
13+
* pool. Supply the real LP token via POOL_ADDRESS.
14+
*
15+
* Env:
16+
* SAFE_ADDRESS (required) — destination; TO_ADDRESS must equal this
17+
* POOL_ADDRESS (required) — the Aerodrome LP token / pool to unwind
18+
* FROM_ADDRESS (default 0x9545…6956) — LP holder
19+
* TO_ADDRESS (default = SAFE_ADDRESS)
20+
* AERODROME_ROUTER (default canonical above; verified at runtime)
21+
* SLIPPAGE_BPS (default 50 = 0.5%)
22+
* BASE_MAINNET_RPC|BASE_RPC_URL
23+
* Flags: --broadcast (also needs env I_UNDERSTAND_IRREVERSIBLE=yes)
24+
*/
25+
const fs = require('node:fs');
26+
const path = require('node:path');
27+
const { execFileSync } = require('node:child_process');
28+
const { ethers } = require('ethers');
29+
30+
const RPC = process.env.BASE_MAINNET_RPC || process.env.BASE_RPC_URL || 'https://mainnet.base.org';
31+
const ROUTER_DEFAULT = '0xcF77a3Ba9A5CA399B7c97c74d54e5b1Beb874E43';
32+
const DEFAULT_FROM = '0x9545e2439c5c75d3aA723AcaC1AA6B0fa1DB6956';
33+
const ts = () => new Date().toISOString();
34+
const out = (o) => process.stdout.write(JSON.stringify({ ...o, ts: ts() }) + '\n');
35+
function fail(reason, extra) {
36+
out({ step: 'lp_unwind', ok: false, mode: 'dry-run', reason, ...(extra || {}) });
37+
process.exit(1);
38+
}
39+
40+
const POOL_ABI = [
41+
'function token0() view returns (address)',
42+
'function token1() view returns (address)',
43+
'function stable() view returns (bool)',
44+
'function totalSupply() view returns (uint256)',
45+
'function getReserves() view returns (uint256,uint256,uint256)',
46+
'function balanceOf(address) view returns (uint256)',
47+
'function allowance(address,address) view returns (uint256)',
48+
'function decimals() view returns (uint8)',
49+
];
50+
const ROUTER_ABI = [
51+
'function defaultFactory() view returns (address)',
52+
'function removeLiquidity(address tokenA,address tokenB,bool stable,uint256 liquidity,uint256 amountAMin,uint256 amountBMin,address to,uint256 deadline) returns (uint256 amountA,uint256 amountB)',
53+
];
54+
const ERC20_APPROVE_ABI = ['function approve(address spender,uint256 amount) returns (bool)'];
55+
56+
function ensureSafeVerified(safe) {
57+
const cache = path.join(process.cwd(), '.yennefer-cache', 'safe.json');
58+
let fresh = false;
59+
if (fs.existsSync(cache)) {
60+
try {
61+
const j = JSON.parse(fs.readFileSync(cache, 'utf8'));
62+
const ageMs = Date.now() - new Date(j.verified_at).getTime();
63+
fresh = j.address && j.address.toLowerCase() === safe.toLowerCase() && ageMs < 3600_000;
64+
} catch { /* stale */ }
65+
}
66+
if (!fresh) {
67+
execFileSync('node', ['scripts/verify_safe.cjs'], {
68+
env: { ...process.env, SAFE_ADDRESS: safe }, stdio: ['ignore', 'inherit', 'inherit'],
69+
}); // throws (non-zero) if verification fails -> aborts unwind
70+
}
71+
}
72+
73+
async function main() {
74+
const broadcast = process.argv.includes('--broadcast');
75+
const safe = process.env.SAFE_ADDRESS;
76+
if (!safe || !/^0x[0-9a-fA-F]{40}$/.test(safe)) fail('SAFE_ADDRESS unset/malformed');
77+
const to = process.env.TO_ADDRESS || safe;
78+
if (to.toLowerCase() !== safe.toLowerCase()) fail('TO_ADDRESS must equal SAFE_ADDRESS');
79+
const from = process.env.FROM_ADDRESS || DEFAULT_FROM;
80+
if (!/^0x[0-9a-fA-F]{40}$/.test(from)) fail('FROM_ADDRESS malformed');
81+
82+
const poolAddr = process.env.POOL_ADDRESS;
83+
if (!poolAddr || !/^0x[0-9a-fA-F]{40}$/.test(poolAddr)) {
84+
return fail('POOL_ADDRESS unset — the LP token/pool address is required and was not verified',
85+
{ todo: 'find the real wQFLOP/ETH Aerodrome LP token (NOT the router 0xcF77…874E43) and set POOL_ADDRESS. Verify at https://aerodrome.finance/liquidity or https://basescan.org' });
86+
}
87+
const slippageBps = BigInt(process.env.SLIPPAGE_BPS || '50');
88+
89+
// Gate: Safe must verify before we plan a transfer to it.
90+
ensureSafeVerified(ethers.getAddress(safe));
91+
92+
const provider = new ethers.JsonRpcProvider(RPC);
93+
const net = await provider.getNetwork();
94+
if (net.chainId !== 8453n) fail(`RPC chainId ${net.chainId} != 8453`);
95+
96+
// Verify ROUTER role (must expose defaultFactory()).
97+
const routerAddr = process.env.AERODROME_ROUTER || ROUTER_DEFAULT;
98+
const router = new ethers.Contract(routerAddr, ROUTER_ABI, provider);
99+
try { await router.defaultFactory(); }
100+
catch { return fail(`AERODROME_ROUTER ${routerAddr} does not expose defaultFactory() — not a router`,
101+
{ todo: 'set AERODROME_ROUTER to the canonical Aerodrome Router on Base; verify on basescan' }); }
102+
103+
// Verify POOL role (must expose token0/token1/getReserves). The router will fail here.
104+
const pool = new ethers.Contract(poolAddr, POOL_ABI, provider);
105+
let token0, token1, stable, totalSupply, reserves, lpAmount;
106+
try {
107+
[token0, token1, stable, totalSupply, reserves, lpAmount] = await Promise.all([
108+
pool.token0(), pool.token1(), pool.stable(), pool.totalSupply(),
109+
pool.getReserves(), pool.balanceOf(from),
110+
]);
111+
} catch (e) {
112+
return fail(`POOL_ADDRESS ${poolAddr} is not an Aerodrome pool (token0/getReserves reverted): ${e.shortMessage || e.message}`,
113+
{ todo: 'POOL_ADDRESS may be the router or a non-pool. Supply the real LP token address.' });
114+
}
115+
116+
if (lpAmount === 0n) return fail(`FROM ${from} holds 0 LP tokens in pool ${poolAddr}`);
117+
118+
// Proportional share from reserves, minus slippage.
119+
const reserve0 = reserves[0], reserve1 = reserves[1];
120+
const amount0 = (reserve0 * lpAmount) / totalSupply;
121+
const amount1 = (reserve1 * lpAmount) / totalSupply;
122+
const amount0Min = (amount0 * (10000n - slippageBps)) / 10000n;
123+
const amount1Min = (amount1 * (10000n - slippageBps)) / 10000n;
124+
const deadline = Math.floor(Date.now() / 1000) + 1200;
125+
126+
// Approval check -> plan approve() as tx[0] if needed.
127+
const allowance = await pool.allowance(from, routerAddr);
128+
const bundle = [];
129+
if (allowance < lpAmount) {
130+
const erc20 = new ethers.Contract(poolAddr, ERC20_APPROVE_ABI, provider);
131+
bundle.push({
132+
idx: bundle.length, kind: 'approve', to: poolAddr,
133+
data: erc20.interface.encodeFunctionData('approve', [routerAddr, lpAmount]),
134+
});
135+
}
136+
bundle.push({
137+
idx: bundle.length, kind: 'removeLiquidity', to: routerAddr,
138+
data: router.interface.encodeFunctionData('removeLiquidity',
139+
[token0, token1, stable, lpAmount, amount0Min, amount1Min, ethers.getAddress(to), deadline]),
140+
});
141+
142+
// Gas sufficiency: FROM must hold enough ETH for the bundle.
143+
const feeData = await provider.getFeeData();
144+
const gasPrice = feeData.maxFeePerGas || feeData.gasPrice || 0n;
145+
let totalGas = 0n;
146+
for (const b of bundle) {
147+
let g = b.kind === 'approve' ? 60000n : 250000n;
148+
try { g = await provider.estimateGas({ from, to: b.to, data: b.data }); } catch { /* keep heuristic */ }
149+
b.gas = g.toString();
150+
totalGas += g;
151+
}
152+
const ethBal = await provider.getBalance(from);
153+
const gasCost = totalGas * gasPrice;
154+
const gasOk = ethBal >= gasCost;
155+
156+
const plan = {
157+
from, to: ethers.getAddress(to), router: routerAddr, pool: poolAddr,
158+
token0, token1, stable, lpAmount: lpAmount.toString(),
159+
expected_token0: amount0.toString(), expected_token1: amount1.toString(),
160+
amount0Min: amount0Min.toString(), amount1Min: amount1Min.toString(),
161+
slippage_bps: slippageBps.toString(), deadline,
162+
bundle, est_gas: totalGas.toString(), est_gas_cost_wei: gasCost.toString(),
163+
eth_balance_wei: ethBal.toString(), gas_sufficient: gasOk,
164+
};
165+
fs.mkdirSync(path.join(process.cwd(), 'out'), { recursive: true });
166+
fs.writeFileSync(path.join(process.cwd(), 'out', 'lp_unwind_plan.json'), JSON.stringify(plan, null, 2));
167+
process.stderr.write(JSON.stringify(plan, null, 2) + '\n');
168+
169+
if (!gasOk) {
170+
return fail(`insufficient ETH for gas: have ${ethers.formatEther(ethBal)}, need ~${ethers.formatEther(gasCost)}`,
171+
{ lp_amount: lpAmount.toString(), expected_token0: amount0.toString(), expected_token1: amount1.toString() });
172+
}
173+
174+
if (!broadcast) {
175+
out({ step: 'lp_unwind', ok: true, mode: 'dry-run', lp_amount: lpAmount.toString(),
176+
expected_token0: amount0.toString(), expected_token1: amount1.toString(), tx_hash: null });
177+
return;
178+
}
179+
180+
// ── BROADCAST (gated; owner-run only) ──
181+
if (process.env.I_UNDERSTAND_IRREVERSIBLE !== 'yes') {
182+
return fail('--broadcast requires env I_UNDERSTAND_IRREVERSIBLE=yes', { mode_attempted: 'broadcast' });
183+
}
184+
const rawKey = process.env.ETH_PRIVATE_KEY || process.env.BASE_PRIVATE_KEY || '';
185+
if (!/^0x[0-9a-fA-F]{64}$/.test(rawKey)) {
186+
return fail('no signing key in env (ETH_PRIVATE_KEY) — cannot broadcast', { mode_attempted: 'broadcast' });
187+
}
188+
const signer = new ethers.Wallet(rawKey, provider);
189+
let lastHash = null;
190+
for (const b of bundle) {
191+
const tx = await signer.sendTransaction({ to: b.to, data: b.data });
192+
lastHash = tx.hash;
193+
await tx.wait();
194+
}
195+
out({ step: 'lp_unwind', ok: true, mode: 'broadcast', lp_amount: lpAmount.toString(),
196+
expected_token0: amount0.toString(), expected_token1: amount1.toString(), tx_hash: lastHash });
197+
}
198+
199+
main().catch((e) => fail(e.message));

0 commit comments

Comments
 (0)