|
| 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