@blackpool/cli 0.0.0-stage → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (3) hide show
  1. package/README.md +128 -2
  2. package/bin/blackpool.js +1386 -0
  3. package/package.json +47 -4
@@ -0,0 +1,1386 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * blackpool CLI - private payments for onchain agents.
4
+ *
5
+ * Usage:
6
+ * blackpool login -- sign BLACKPOOL message, cache signature for this wallet
7
+ * blackpool balance [--pool p] -- print private balance
8
+ * blackpool send <to> <amount> -- send privately to an address
9
+ *
10
+ * Auth: BLACKPOOL_KEY env var (agent private key, hex). No mnemonic support in MVP.
11
+ * Config: ~/.blackpool/config.json stores cached signatures (per address).
12
+ */
13
+
14
+ 'use strict'
15
+
16
+ const path = require('path')
17
+ const fs = require('fs')
18
+ const os = require('os')
19
+ const { ethers } = require('ethers')
20
+
21
+ // === constants ===
22
+ const API = process.env.BLACKPOOL_API || 'https://api.blackpool.cash'
23
+ const SIGN_MESSAGE =
24
+ 'Welcome to Blackpool\n\n' +
25
+ 'Sign this message to access your private account.\n\n' +
26
+ '- Gas free: no transaction is sent\n' +
27
+ '- Your keys are derived locally and never leave this device\n' +
28
+ '- This signature cannot move funds or approve spending'
29
+ const CONFIG_DIR = path.join(os.homedir(), '.blackpool')
30
+ const CONFIG_FILE = path.join(CONFIG_DIR, 'config.json') // cached BLACKPOOL sig
31
+ const WALLET_FILE = path.join(CONFIG_DIR, 'wallet.json') // stored priv key (chmod 600)
32
+
33
+ // === tiny utilities ===
34
+ const c = {
35
+ reset: '\x1b[0m', bold: '\x1b[1m', dim: '\x1b[2m',
36
+ red: '\x1b[31m', green: '\x1b[32m', yellow: '\x1b[33m',
37
+ blue: '\x1b[34m', cyan: '\x1b[36m', lime: '\x1b[38;5;155m'
38
+ }
39
+ function color(text, col) {
40
+ return process.stdout.isTTY ? `${col}${text}${c.reset}` : text
41
+ }
42
+ function ok(msg) { console.log(color('✓ ', c.green) + msg) }
43
+ function fail(msg) { console.error(color('✗ ', c.red) + msg); process.exit(1) }
44
+ // Extract a human revert reason from ethers' verbose errors (which otherwise dump the entire tx,
45
+ // calldata, and JSON-RPC request body into .message — unreadable in a terminal).
46
+ function cleanErr(e) {
47
+ if (!e) return 'unknown error'
48
+ // Most robust: search the ENTIRE stringified error tree for an on-chain revert reason. ethers v5
49
+ // buries "execution reverted: <reason>" at unpredictable depths (e.message, e.error.body,
50
+ // e.error.error.message, ...), so instead of guessing the path we regex the whole blob.
51
+ let blob = ''
52
+ try { blob = JSON.stringify(e) } catch { /* circular */ }
53
+ blob += ' ' + (e.message || '') + ' ' + (e.error?.body || '')
54
+ const rev = blob.match(/execution reverted:?\s*([^"'\\}\]]+?)\s*["'\\}\]]/i)
55
+ if (rev && rev[1].trim()) return rev[1].trim()
56
+ // Fallback: first clean reason field, else first line of message before ethers noise.
57
+ const reason = e.reason || e.error?.reason
58
+ if (reason && !/cannot estimate gas|processing response/i.test(reason)) return String(reason)
59
+ const m = e.message || String(e)
60
+ return m.split(/\s*[\(\[]\s*(?:See:|error=|reason=)/)[0].trim().slice(0, 200)
61
+ }
62
+ function info(msg) { console.log(color(' ', c.dim) + msg) }
63
+ function log(msg) { console.log(msg) }
64
+
65
+ // === config ===
66
+ function loadConfig() {
67
+ try {
68
+ if (!fs.existsSync(CONFIG_FILE)) return { signatures: {} }
69
+ return JSON.parse(fs.readFileSync(CONFIG_FILE, 'utf8'))
70
+ } catch (e) {
71
+ fail(`config unreadable at ${CONFIG_FILE}: ${e.message}`)
72
+ }
73
+ }
74
+ function saveConfig(cfg) {
75
+ try {
76
+ if (!fs.existsSync(CONFIG_DIR)) fs.mkdirSync(CONFIG_DIR, { recursive: true, mode: 0o700 })
77
+ fs.writeFileSync(CONFIG_FILE, JSON.stringify(cfg, null, 2), { mode: 0o600 })
78
+ } catch (e) {
79
+ fail(`could not write config: ${e.message}`)
80
+ }
81
+ }
82
+
83
+ // === hidden input prompt (like sudo password, no echo) ===
84
+ // TTY path: setRawMode + no echo, backspace-aware. Non-TTY path: read line from stdin (for scripting / CI).
85
+ function promptHidden(question) {
86
+ return new Promise((resolve, reject) => {
87
+ const stdin = process.stdin
88
+ // Non-TTY (piped): read one line from stdin without echo suppression (already invisible in pipes).
89
+ if (!stdin.isTTY) {
90
+ process.stdout.write(question + color('(non-tty, reading from stdin)\n', c.dim))
91
+ let buf = ''
92
+ stdin.setEncoding('utf8')
93
+ stdin.on('data', d => { buf += d })
94
+ stdin.on('end', () => resolve(buf.replace(/\r?\n$/, '')))
95
+ stdin.on('error', reject)
96
+ return
97
+ }
98
+ // TTY: hidden input via raw mode.
99
+ process.stdout.write(question)
100
+ const wasRaw = stdin.isRaw
101
+ stdin.setRawMode(true); stdin.resume(); stdin.setEncoding('utf8')
102
+ let buf = ''
103
+ const cleanup = () => { stdin.removeListener('data', onData); stdin.setRawMode(!!wasRaw); stdin.pause() }
104
+ const onData = (data) => {
105
+ for (const ch of data) {
106
+ if (ch === '\u0003') { cleanup(); process.stdout.write('\n'); reject(new Error('aborted')); return } // Ctrl+C
107
+ if (ch === '\r' || ch === '\n') { cleanup(); process.stdout.write('\n'); resolve(buf); return }
108
+ if (ch === '\u007f' || ch === '\b') { if (buf.length > 0) buf = buf.slice(0, -1); continue }
109
+ buf += ch
110
+ }
111
+ }
112
+ stdin.on('data', onData)
113
+ })
114
+ }
115
+
116
+ // === wallet storage ===
117
+ function loadStoredKey() {
118
+ try {
119
+ if (!fs.existsSync(WALLET_FILE)) return null
120
+ const raw = JSON.parse(fs.readFileSync(WALLET_FILE, 'utf8'))
121
+ return raw.privateKey || null
122
+ } catch { return null }
123
+ }
124
+ function saveStoredKey(pk) {
125
+ if (!fs.existsSync(CONFIG_DIR)) fs.mkdirSync(CONFIG_DIR, { recursive: true, mode: 0o700 })
126
+ fs.writeFileSync(WALLET_FILE, JSON.stringify({ privateKey: pk }, null, 2), { mode: 0o600 })
127
+ }
128
+
129
+ // === wallet ===
130
+ // Priority: BLACKPOOL_KEY env var (for agents / CI) > stored wallet.json (from `blackpool login`) > error.
131
+ function getWallet() {
132
+ const key = process.env.BLACKPOOL_KEY || loadStoredKey()
133
+ if (!key) {
134
+ fail('no wallet configured. Run `blackpool login` to set one up (interactive, hidden input), or set BLACKPOOL_KEY env var.')
135
+ }
136
+ try {
137
+ return new ethers.Wallet(key)
138
+ } catch (e) {
139
+ fail(`stored key invalid: ${e.message}`)
140
+ }
141
+ }
142
+
143
+ function getCachedSignature(address) {
144
+ const cfg = loadConfig()
145
+ return cfg.signatures?.[address.toLowerCase()]
146
+ }
147
+
148
+ async function ensureSignature(wallet, { forceNew = false } = {}) {
149
+ const address = wallet.address
150
+ if (!forceNew) {
151
+ const cached = getCachedSignature(address)
152
+ if (cached) return cached
153
+ }
154
+ const sig = await wallet.signMessage(SIGN_MESSAGE)
155
+ const cfg = loadConfig()
156
+ cfg.signatures = cfg.signatures || {}
157
+ cfg.signatures[address.toLowerCase()] = sig
158
+ saveConfig(cfg)
159
+ return sig
160
+ }
161
+
162
+ // === HTTP ===
163
+ async function apiPost(endpoint, body, { timeoutMs = 90_000 } = {}) {
164
+ const controller = new AbortController()
165
+ const t = setTimeout(() => controller.abort(), timeoutMs)
166
+ try {
167
+ const r = await fetch(`${API}${endpoint}`, {
168
+ method: 'POST',
169
+ headers: { 'Content-Type': 'application/json', 'User-Agent': `blackpool-cli/${require('../package.json').version}` },
170
+ body: JSON.stringify(body),
171
+ signal: controller.signal,
172
+ })
173
+ let json = {}
174
+ try { json = await r.json() } catch {}
175
+ if (!r.ok) {
176
+ const msg = json?.error || `HTTP ${r.status}`
177
+ throw new Error(msg)
178
+ }
179
+ return json
180
+ } finally { clearTimeout(t) }
181
+ }
182
+
183
+ // === commands ===
184
+ async function cmdLogin() {
185
+ let wallet
186
+ // If BLACKPOOL_KEY env is set, use it (agent/CI path). Otherwise prompt with hidden input.
187
+ if (process.env.BLACKPOOL_KEY) {
188
+ try { wallet = new ethers.Wallet(process.env.BLACKPOOL_KEY) }
189
+ catch (e) { fail(`invalid BLACKPOOL_KEY: ${e.message}`) }
190
+ info('using BLACKPOOL_KEY from environment')
191
+ } else {
192
+ log(color('Paste your agent private key (input hidden, like a password).', c.dim))
193
+ log(color('It is stored locally at ~/.blackpool/wallet.json (chmod 600), never sent anywhere.', c.dim))
194
+ let pk
195
+ try { pk = (await promptHidden(' private key: ')).trim() }
196
+ catch (e) { fail(e.message) }
197
+ if (!pk) fail('no key entered')
198
+ if (!pk.startsWith('0x')) pk = '0x' + pk
199
+ try { wallet = new ethers.Wallet(pk) }
200
+ catch (e) { fail(`invalid private key: ${e.message}`) }
201
+ saveStoredKey(pk)
202
+ ok(`wallet stored at ${WALLET_FILE} (chmod 600)`)
203
+ }
204
+ log(color(`agent ${wallet.address}`, c.dim))
205
+ info('signing BLACKPOOL message...')
206
+ const sig = await ensureSignature(wallet, { forceNew: true })
207
+ ok(`logged in as ${color(wallet.address, c.cyan)}`)
208
+ info(`signature cached at ${CONFIG_FILE}`)
209
+ }
210
+
211
+ async function cmdLogout() {
212
+ let removed = []
213
+ for (const f of [WALLET_FILE, CONFIG_FILE]) {
214
+ try { if (fs.existsSync(f)) { fs.unlinkSync(f); removed.push(f) } } catch {}
215
+ }
216
+ if (removed.length) { removed.forEach(f => ok(`removed ${f}`)) }
217
+ else info('nothing to remove (already logged out)')
218
+ }
219
+
220
+ // === Multi-chain registry (mirrors frontend app/page.tsx: CHAIN_POOLS + UNISWAP + POOL_META) ===
221
+ // Each chain has private pools (native ETH + a stablecoin) and its own Uniswap V3 addresses for swap.
222
+ // Robinhood is an Arbitrum Orbit L2 (chainId 4663); its stablecoin is USDG (Global Dollar).
223
+
224
+ // Minimum size for any flow that pays the withdraw fee. send, swap and both bridge paths all start
225
+ // with a relayer withdraw, so they share one floor rather than each carrying its own number. These
226
+ // mirror MIN_TX_NATIVE / MIN_TX_STABLE in frontend/app/page.tsx -- keep the two in step, otherwise
227
+ // the CLI accepts amounts the app refuses.
228
+ //
229
+ // Without this the CLI let a too-small send through to apiServer.js, which rejects anything where
230
+ // fee*2 >= amount (a hard floor of 0.00030211 ETH at flatFee 0.00015). The user got a backend error
231
+ // instead of a clear message, and on the swap/bridge paths that failure lands *after* funds have
232
+ // already moved to a burner, needing `blackpool recover`. Failing early is the whole point.
233
+ const MIN_TX_NATIVE = 0.001 // ETH / BNB: send, swap, bridge
234
+ const MIN_TX_STABLE = 2 // USDC / USDG / USDT
235
+
236
+ // Floor for one pool of one chain, plus the message the CLI fails with. `pool` is a key into
237
+ // CHAINS[chain].pools, so this works for the native and stablecoin pool of every chain.
238
+ function minTxFor(chain, pool) {
239
+ const p = CHAINS[chain].pools[pool]
240
+ return { min: p.native ? MIN_TX_NATIVE : MIN_TX_STABLE, symbol: p.symbol }
241
+ }
242
+
243
+ // Fail unless `n` clears the floor for that pool. `flow` is the word used in the message ("send",
244
+ // "swap", "bridge") so the error names the command the user actually ran.
245
+ function requireMinTx(n, chain, pool, flow) {
246
+ const { min, symbol } = minTxFor(chain, pool)
247
+ if (n < min) {
248
+ fail(`minimum ${flow} is ${min} ${symbol} on ${CHAINS[chain].label} — smaller amounts are mostly consumed by the withdraw fee (flat + 0.35%)`)
249
+ }
250
+ }
251
+
252
+ const CHAINS = {
253
+ base: {
254
+ label: 'Base',
255
+ chainId: 8453,
256
+ rpc: process.env.BLACKPOOL_BASE_RPC || process.env.BLACKPOOL_RPC || 'https://mainnet.base.org',
257
+ stablePool: 'usdc',
258
+ nativePool: 'eth',
259
+ // pool key -> { symbol, decimals, native, minDeposit }. minDeposit mirrors the frontend
260
+ // POOL_META floor and must stay in step with it: every stablecoin pool is a flat 3 units (~$3)
261
+ // for a uniform entry point across chains, native pools are 0.001 ETH / 0.02 BNB. These are an
262
+ // operator choice, not a fee-ratio rule -- see the note in frontend POOL_META for what the
263
+ // withdraw fee costs at each floor, including the base:usdc fee*2 edge at a 2 USDC withdraw.
264
+ pools: { eth: { symbol: 'ETH', decimals: 18, native: true, minDeposit: 0.001 }, usdc: { symbol: 'USDC', decimals: 6, native: false, minDeposit: 3 } },
265
+ uni: { router: '0x2626664c2603336E57B271c5C0b26F421741e481', quoter: '0x3d4e44Eb1374240CE5F1B871ab261CD16335B76a', weth: '0x4200000000000000000000000000000000000006', stable: '0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913', fee: 100 },
266
+ // realistic gas ceiling (gwei) — never let ethers default to 1.5 gwei
267
+ gasCapGwei: '0.05',
268
+ },
269
+ robinhood: {
270
+ label: 'Robinhood',
271
+ chainId: 4663,
272
+ rpc: process.env.BLACKPOOL_ROBINHOOD_RPC || 'https://rpc.mainnet.chain.robinhood.com',
273
+ stablePool: 'usdg',
274
+ nativePool: 'eth',
275
+ pools: { eth: { symbol: 'ETH', decimals: 18, native: true, minDeposit: 0.001 }, usdg: { symbol: 'USDG', decimals: 6, native: false, minDeposit: 3 } },
276
+ uni: { router: '0xcaf681a66d020601342297493863e78c959e5cb2', quoter: '0x33e885ed0ec9bf04ecfb19341582aadcb4c8a9e7', weth: '0x0Bd7D308f8E1639FAb988df18A8011f41EAcAD73', stable: '0x5fc5360D0400a0Fd4f2af552ADD042D716F1d168', fee: 500 },
277
+ gasCapGwei: '0.1',
278
+ },
279
+ arbitrum: {
280
+ label: 'Arbitrum',
281
+ chainId: 42161,
282
+ rpc: process.env.BLACKPOOL_ARBITRUM_RPC || 'https://arb1.arbitrum.io/rpc',
283
+ stablePool: 'usdc',
284
+ nativePool: 'eth',
285
+ pools: { eth: { symbol: 'ETH', decimals: 18, native: true, minDeposit: 0.001 }, usdc: { symbol: 'USDC', decimals: 6, native: false, minDeposit: 3 } },
286
+ uni: { router: '0x68b3465833fb72A70ecDF485E0e4C7bD8665Fc45', quoter: '0x61fFE014bA17989E743c5F6cB21bF9697530B21e', weth: '0x82aF49447D8a07e3bd95BD0d56f35241523fBab1', stable: '0xaf88d065e77c8cC2239327C5EDb3A432268e5831', fee: 500 },
287
+ gasCapGwei: '0.5',
288
+ },
289
+ // BSC runs PancakeSwap V3, not Uniswap. Router is the SmartRouter (SwapRouter02-compatible);
290
+ // the frontend verified by bytecode + estimateGas that the other commonly-cited address lacks
291
+ // the exactInputSingle selector and reverts. `stable` is USDT because its WBNB pool is far
292
+ // deeper than USDC's. Binance-Peg stables are 18 decimals, NOT 6.
293
+ bsc: {
294
+ label: 'BSC',
295
+ chainId: 56,
296
+ rpc: process.env.BLACKPOOL_BSC_RPC || 'https://bsc-dataseed.binance.org',
297
+ stablePool: 'usdt',
298
+ nativePool: 'bnb',
299
+ pools: {
300
+ bnb: { symbol: 'BNB', decimals: 18, native: true, minDeposit: 0.02 },
301
+ usdc: { symbol: 'USDC', decimals: 18, native: false, minDeposit: 3 },
302
+ usdt: { symbol: 'USDT', decimals: 18, native: false, minDeposit: 3 },
303
+ },
304
+ uni: { router: '0x13f4EA83D0bd40E75C8222255bc855a974568Dd4', quoter: '0xB048Bbc1Ee6b733FFfCFb9e9CeF7375518e25997', weth: '0xbb4CdB9CBd36B01bD1cBaEBF2De08d9173bc095c', stable: '0x55d398326f99059fF775485246999027B3197955', fee: 100 },
305
+ // BSC has no EIP-1559 baseFee (it reads 0) and validators enforce a ~0.05 gwei floor, so this
306
+ // is used as a legacy gasPrice rather than a 1559 cap. See chainGasOverrides.
307
+ gasCapGwei: '0.06',
308
+ legacyGas: true,
309
+ },
310
+ }
311
+ // Usage strings list chains from CHAINS so adding a chain can't leave a stale `base|robinhood` hint.
312
+ const CHAIN_LIST = Object.keys(CHAINS).join('|')
313
+ // resolve --chain flag (default base for backward compat). Accepts base | robinhood | rh.
314
+ function resolveChain(args) {
315
+ const raw = (getFlag(args, '--chain') || 'base').toLowerCase()
316
+ const key = raw === 'rh' ? 'robinhood' : raw === 'arb' ? 'arbitrum' : raw === 'bnb' ? 'bsc' : raw
317
+ if (!CHAINS[key]) fail(`unknown chain "${raw}" (supported: ${Object.keys(CHAINS).join(', ')})`)
318
+ return key
319
+ }
320
+ // pool keys valid for a chain (e.g. ['eth','usdc'] on base, ['eth','usdg'] on robinhood)
321
+ function poolsForChain(chain) { return Object.keys(CHAINS[chain].pools) }
322
+ // block explorer tx link per chain
323
+ const EXPLORERS = { base: 'https://basescan.org/tx/', robinhood: 'https://robin.etherscan.io/tx/', arbitrum: 'https://arbiscan.io/tx/', bsc: 'https://bscscan.com/tx/' }
324
+ function explorerTx(chain, hash) { return (EXPLORERS[chain] || EXPLORERS.base) + hash }
325
+ // Build an RPC provider with the network pinned explicitly. Plain JsonRpcProvider defers its first
326
+ // call until an eth_chainId "detect network" round-trip succeeds — and when a public RPC (notably
327
+ // Robinhood's) blips, that surfaces as a fatal "could not detect network" even though we already
328
+ // know the chainId. StaticJsonRpcProvider + an explicit network skips that probe entirely and never
329
+ // re-detects, so a transient hiccup no longer aborts deposit/swap/bridge before they even start.
330
+ function rpcProvider(chain) {
331
+ const C = CHAINS[chain]
332
+ return new ethers.providers.StaticJsonRpcProvider(C.rpc, { chainId: C.chainId, name: chain })
333
+ }
334
+ // Connection-level errors worth retrying (the RPC dropped the request/response entirely — NOT a
335
+ // revert or an insufficient-funds error, which are real failures and must NOT be retried).
336
+ function isRetryableRpcError(e) {
337
+ const msg = String(e && (e.reason || e.message) || '').toLowerCase()
338
+ const code = e && e.code
339
+ return code === 'SERVER_ERROR' || code === 'NETWORK_ERROR' || code === 'TIMEOUT'
340
+ || msg.includes('missing response') || msg.includes('could not detect network')
341
+ || msg.includes('econnreset') || msg.includes('etimedout') || msg.includes('socket hang up')
342
+ || msg.includes('fetch failed') || msg.includes('bad gateway') || msg.includes('502')
343
+ || msg.includes('503') || msg.includes('timeout')
344
+ }
345
+ // Retry a read-only RPC call (getBalance, allowance, quote, getFeeData, etc) across transient
346
+ // connection drops on public RPCs. Real errors (revert, invalid params) surface immediately.
347
+ async function withRpcRetry(fn, { attempts = 5, baseDelayMs = 600, label = 'rpc call' } = {}) {
348
+ let lastErr
349
+ for (let i = 0; i < attempts; i++) {
350
+ try { return await fn() }
351
+ catch (e) {
352
+ lastErr = e
353
+ if (!isRetryableRpcError(e)) throw e
354
+ if (i < attempts - 1) await new Promise(r => setTimeout(r, baseDelayMs * (i + 1)))
355
+ }
356
+ }
357
+ throw new Error(`${label} failed after ${attempts} attempts (connection kept dropping): ${(lastErr && (lastErr.reason || lastErr.message)) || lastErr}`)
358
+ }
359
+ // Sign a transaction ONCE (fixes nonce + gas), then broadcast it — retrying the *exact same signed
360
+ // bytes* if the connection drops before we get a response. This is safe: resending identical raw tx
361
+ // bytes is idempotent (same hash), so a dropped response can never cause a double-send. If the first
362
+ // attempt actually landed but the response was lost, the resend just gets "already known" back.
363
+ // Then polls for the receipt with its own retry loop instead of ethers' non-resilient tx.wait().
364
+ async function broadcastAndWait(signer, provider, txRequest, { label = 'transaction', confirmations = 1 } = {}) {
365
+ const populated = await withRpcRetry(() => signer.populateTransaction(txRequest), { label: `${label} (populate)` })
366
+ const rawTx = await signer.signTransaction(populated)
367
+ const txHash = ethers.utils.keccak256(rawTx)
368
+
369
+ let sent = false
370
+ for (let i = 0; i < 6 && !sent; i++) {
371
+ try {
372
+ await provider.sendTransaction(rawTx)
373
+ sent = true
374
+ } catch (e) {
375
+ const msg = String(e && (e.reason || e.message) || '').toLowerCase()
376
+ // Already broadcast by a previous attempt (or picked up by another node) — not a failure.
377
+ if (msg.includes('already known') || msg.includes('nonce too low') || msg.includes('already exists')) { sent = true; break }
378
+ if (!isRetryableRpcError(e)) throw e
379
+ if (i < 5) await new Promise(r => setTimeout(r, 700 * (i + 1)))
380
+ }
381
+ }
382
+ if (!sent) throw new Error(`${label}: could not broadcast after repeated connection drops`)
383
+
384
+ // Poll for the receipt ourselves (retrying connection errors) instead of tx.wait(), which throws
385
+ // fatally on the first dropped response even though the tx may already be mining.
386
+ const deadline = Date.now() + 180_000
387
+ while (Date.now() < deadline) {
388
+ try {
389
+ const receipt = await provider.getTransactionReceipt(txHash)
390
+ if (receipt && receipt.confirmations >= confirmations) {
391
+ if (receipt.status === 0) throw new Error(`${label} reverted on-chain (tx ${txHash})`)
392
+ return { hash: txHash, receipt }
393
+ }
394
+ } catch (e) {
395
+ if (!isRetryableRpcError(e)) throw e
396
+ }
397
+ await new Promise(r => setTimeout(r, 2000))
398
+ }
399
+ throw new Error(`${label}: broadcast (tx ${txHash}) but no confirmation after 180s — check the explorer, funds are not lost`)
400
+ }
401
+ // EIP-1559 gas overrides capped at the chain's realistic ceiling. Robinhood's RPC "suggests"
402
+ // ~1.6 gwei while baseFee is ~0.047 gwei (34x inflated) — using the suggestion inflates every tx
403
+ // and can overshoot a burner's tiny ETH. Cap at gasCapGwei; floor priority at 0.001 gwei.
404
+ async function chainGasOverrides(provider, chain) {
405
+ // Legacy chains (BSC) report baseFee=0 and reject EIP-1559 maxFee with "gas price below minimum";
406
+ // validators enforce a ~0.05 gwei floor. Use a single legacy gasPrice at/above the floor instead.
407
+ if (CHAINS[chain].legacyGas) {
408
+ const floor = ethers.utils.parseUnits(CHAINS[chain].gasCapGwei, 'gwei')
409
+ let gp = floor
410
+ try { const fd = await provider.getFeeData(); if (fd.gasPrice && fd.gasPrice.gt(floor)) gp = fd.gasPrice } catch {}
411
+ return { gasPrice: gp }
412
+ }
413
+ const cap = ethers.utils.parseUnits(CHAINS[chain].gasCapGwei, 'gwei')
414
+ let netMax
415
+ try { const fd = await provider.getFeeData(); netMax = fd.maxFeePerGas } catch {}
416
+ const maxFeePerGas = netMax && netMax.lt(cap) ? netMax : cap
417
+ const minPrio = ethers.utils.parseUnits('0.001', 'gwei')
418
+ const maxPriorityFeePerGas = maxFeePerGas.lt(minPrio) ? maxFeePerGas : minPrio
419
+ return { maxFeePerGas, maxPriorityFeePerGas }
420
+ }
421
+ // Sync version (fixed chain cap, no network fetch) — for burner txs where we set an explicit gasLimit
422
+ // and just need a chain-realistic price. Replaces the old Base-only baseGasOverrides().
423
+ function chainGasOverridesSync(chain) {
424
+ // Legacy chains (BSC): single gasPrice at the validator floor, not an EIP-1559 pair.
425
+ if (CHAINS[chain].legacyGas) {
426
+ return { gasPrice: ethers.utils.parseUnits(CHAINS[chain].gasCapGwei, 'gwei') }
427
+ }
428
+ const maxFeePerGas = ethers.utils.parseUnits(CHAINS[chain].gasCapGwei, 'gwei')
429
+ return { maxFeePerGas, maxPriorityFeePerGas: ethers.utils.parseUnits('0.001', 'gwei') }
430
+ }
431
+ // back-compat: some helpers reference the base RPC + usdc directly
432
+ const BASE_RPC = CHAINS.base.rpc
433
+ const BASE_USDC = CHAINS.base.uni.stable
434
+
435
+ async function getPublicBalances(address, chain = 'base') {
436
+ const C = CHAINS[chain]
437
+ const provider = rpcProvider(chain)
438
+ const usdc = new ethers.Contract(C.uni.stable, ['function balanceOf(address) view returns (uint256)'], provider)
439
+ // Read the stable's decimals from the pool config instead of assuming 6: BSC's Binance-Peg
440
+ // USDC/USDT are 18-decimal, so a hardcoded 6 would report balances 1e12x too large.
441
+ const stableDecimals = C.pools[C.stablePool].decimals
442
+ // Robinhood's public RPC is flaky and intermittently drops requests, which used to surface as a
443
+ // bare "?" in the balance view. Retry a couple of times with a short backoff before giving up.
444
+ let lastErr
445
+ for (let attempt = 0; attempt < 3; attempt++) {
446
+ try {
447
+ const [ethBal, usdcBal] = await Promise.all([
448
+ provider.getBalance(address),
449
+ usdc.balanceOf(address),
450
+ ])
451
+ return {
452
+ eth: ethers.utils.formatEther(ethBal),
453
+ usdc: ethers.utils.formatUnits(usdcBal, stableDecimals),
454
+ }
455
+ } catch (e) {
456
+ lastErr = e
457
+ if (attempt < 2) await new Promise(r => setTimeout(r, 800 * (attempt + 1)))
458
+ }
459
+ }
460
+ throw new Error(`RPC unavailable for ${C.label} public balances: ${cleanErr(lastErr)}`)
461
+ }
462
+
463
+ // Poll /api/balance until the indexer reflects at least `minBalance` (as a number), or timeout.
464
+ // Fixes the indexer-lag window right after deposit/send where balance can transiently look stale.
465
+ // Returns { balance, utxoCount, symbol } from the last successful response, or throws on timeout.
466
+ async function waitForBalance(sig, address, pool, minBalance, maxMs = 30_000) {
467
+ const deadline = Date.now() + maxMs
468
+ let last
469
+ while (Date.now() < deadline) {
470
+ try {
471
+ last = await apiPost('/api/balance', { signature: sig, address, pool })
472
+ if (parseFloat(last.balance || '0') >= minBalance) return last
473
+ } catch { /* transient */ }
474
+ await new Promise(r => setTimeout(r, 2000))
475
+ }
476
+ return last || { balance: '?', utxoCount: '?', symbol: pool.toUpperCase() }
477
+ }
478
+
479
+ // Render the full private+public balance view for ONE chain. Shared by single-chain and --chain all.
480
+ async function showChainBalance(chain, wallet, sig, wait) {
481
+ const C = CHAINS[chain]
482
+ const validPools = poolsForChain(chain)
483
+ // BSC's native pool key is 'bnb', not 'eth' — read it from the chain config.
484
+ const [ethKey, stableKey] = [C.nativePool, C.stablePool]
485
+ const stableSym = C.pools[stableKey].symbol
486
+ const nativeSym = C.pools[ethKey].symbol
487
+ info(`fetching balances for ${color(wallet.address, c.cyan)} on ${C.label}...`)
488
+ let [ethPriv, stablePriv, pub] = await Promise.all([
489
+ apiPost('/api/balance', { signature: sig, address: wallet.address, pool: ethKey, chain }).catch(e => ({ balance: '?', utxoCount: '?', err: e.message })),
490
+ apiPost('/api/balance', { signature: sig, address: wallet.address, pool: stableKey, chain }).catch(e => ({ balance: '?', utxoCount: '?', err: e.message })),
491
+ getPublicBalances(wallet.address, chain).catch(e => ({ eth: '?', usdc: '?', err: e.message })),
492
+ ])
493
+ // With --wait, poll briefly if a private balance reads 0 (indexer may be catching up post-tx).
494
+ if (wait && (parseFloat(ethPriv.balance || '0') === 0 || parseFloat(stablePriv.balance || '0') === 0)) {
495
+ const deadline = Date.now() + 30_000
496
+ while (Date.now() < deadline && (parseFloat(ethPriv.balance || '0') === 0 || parseFloat(stablePriv.balance || '0') === 0)) {
497
+ await new Promise(r => setTimeout(r, 2500))
498
+ try {
499
+ ;[ethPriv, stablePriv] = await Promise.all([
500
+ apiPost('/api/balance', { signature: sig, address: wallet.address, pool: ethKey, chain }),
501
+ apiPost('/api/balance', { signature: sig, address: wallet.address, pool: stableKey, chain }),
502
+ ])
503
+ } catch {}
504
+ }
505
+ }
506
+ log('')
507
+ log(color(` private (shielded, spendable via BLACKPOOL) — ${C.label}`, c.dim))
508
+ log(` ${color(nativeSym.padEnd(5), c.dim)} ${color(ethPriv.balance, c.lime)} ${color(`(${ethPriv.utxoCount} utxo)`, c.dim)}`)
509
+ log(` ${color(stableSym.padEnd(5), c.dim)} ${color(stablePriv.balance, c.lime)} ${color(`(${stablePriv.utxoCount} utxo)`, c.dim)}`)
510
+ log('')
511
+ log(color(` public (wallet on ${C.label}, can be shielded)`, c.dim))
512
+ log(` ${color(nativeSym.padEnd(5), c.dim)} ${color(pub.eth, c.cyan)}`)
513
+ log(` ${color(stableSym.padEnd(5), c.dim)} ${color(pub.usdc, c.cyan)}`)
514
+ if (pub.err) info(` (public balance unavailable: ${pub.err} — retry, or set BLACKPOOL_${chain.toUpperCase()}_RPC to a private RPC)`)
515
+ log('')
516
+ }
517
+
518
+ async function cmdBalance(args) {
519
+ const wallet = getWallet()
520
+ const wait = args.includes('--wait')
521
+ const rawChain = (getFlag(args, '--chain') || 'base').toLowerCase()
522
+
523
+ // --chain all: loop every chain, full view each. Ignores --pool (pool names differ per chain).
524
+ if (rawChain === 'all') {
525
+ const sig = await ensureSignature(wallet)
526
+ for (const chainKey of Object.keys(CHAINS)) {
527
+ await showChainBalance(chainKey, wallet, sig, wait)
528
+ }
529
+ info(`shield public into private: ${color(`blackpool deposit <amount> [--pool <pool>] [--chain ${CHAIN_LIST}]`, c.yellow)}`)
530
+ log('')
531
+ return
532
+ }
533
+
534
+ const chain = resolveChain(args)
535
+ const C = CHAINS[chain]
536
+ const pool = getFlag(args, '--pool')
537
+ const validPools = poolsForChain(chain)
538
+
539
+ // if --pool specified, only that pool (private). Otherwise, show both pools private + public wallet.
540
+ if (pool) {
541
+ if (!validPools.includes(pool)) fail(`unknown pool "${pool}" on ${C.label} (supported: ${validPools.join(', ')})`)
542
+ const sig = await ensureSignature(wallet)
543
+ info(`fetching private ${pool.toUpperCase()} balance on ${C.label}${wait ? ' (waiting for indexer...)' : ''}...`)
544
+ // With --wait, poll a bit in case a tx was just broadcast and the indexer is catching up.
545
+ let res = await apiPost('/api/balance', { signature: sig, address: wallet.address, pool, chain })
546
+ if (wait && parseFloat(res.balance || '0') === 0) {
547
+ const deadline = Date.now() + 30_000
548
+ while (Date.now() < deadline && parseFloat(res.balance || '0') === 0) {
549
+ await new Promise(r => setTimeout(r, 2500))
550
+ try { res = await apiPost('/api/balance', { signature: sig, address: wallet.address, pool, chain }) } catch {}
551
+ }
552
+ }
553
+ log('')
554
+ log(` ${color('private balance', c.dim)} ${color(res.balance, c.lime)} ${color(res.symbol, c.cyan)} ${color(`on ${C.label}`, c.dim)}`)
555
+ log(` ${color('utxos', c.dim)} ${res.utxoCount}`)
556
+ log('')
557
+ return
558
+ }
559
+
560
+ const sig = await ensureSignature(wallet)
561
+ await showChainBalance(chain, wallet, sig, wait)
562
+ info(`shield public into private: ${color(`blackpool deposit <amount> [--pool ${validPools.join('|')}] [--chain ${chain}]`, c.yellow)}`)
563
+ log('')
564
+ }
565
+
566
+ async function cmdDeposit(args) {
567
+ const chain = resolveChain(args)
568
+ const C = CHAINS[chain]
569
+ const amount = args[0]
570
+ const validPools = poolsForChain(chain)
571
+ const pool = getFlag(args, '--pool') || C.stablePool
572
+ if (!amount || amount.startsWith('--')) fail(`usage: blackpool deposit <amount> [--pool ${validPools.join('|')}] [--chain ${CHAIN_LIST}]`)
573
+ const n = Number(amount)
574
+ if (isNaN(n) || n <= 0) fail(`invalid amount: ${amount}`)
575
+ if (!validPools.includes(pool)) fail(`unknown pool "${pool}" on ${C.label} (supported: ${validPools.join(', ')})`)
576
+ // Reject deposits the withdraw fee would eat. Mirrors the frontend floor: the withdraw fee is
577
+ // flatFee + 0.35%, so an amount near the flat fee is mostly consumed on the way out, and one
578
+ // under 2x the fee cannot be withdrawn at all (relayer guard: fee*2 < amount).
579
+ const minDep = C.pools[pool].minDeposit
580
+ if (minDep && n < minDep) {
581
+ fail(`minimum deposit is ${minDep} ${C.pools[pool].symbol} on ${C.label} — smaller amounts get eaten by the withdraw fee`)
582
+ }
583
+
584
+ const wallet = getWallet()
585
+ const sig = await ensureSignature(wallet)
586
+ const provider = rpcProvider(chain)
587
+ const signer = new ethers.Wallet(wallet.privateKey, provider)
588
+ // Chain-realistic gas cap — never let ethers default to ~1.5 gwei (on Robinhood that inflates the
589
+ // ~1.3M-gas deposit ~34x and can overshoot the wallet's ETH). Cap at the chain's realistic ceiling.
590
+ const gasOv = await chainGasOverrides(provider, chain)
591
+
592
+ log('')
593
+ log(color(` shielding ${amount} ${pool.toUpperCase()} on ${C.label}`, c.bold))
594
+ log(color(` from public wallet ${wallet.address}`, c.dim))
595
+ log('')
596
+ info('preparing deposit... proof generation can take ~60-90 seconds')
597
+
598
+ const prep = await apiPost('/api/deposit/prepare', {
599
+ signature: sig, address: wallet.address, amount: n, pool, chain,
600
+ }, { timeoutMs: 180_000 })
601
+
602
+ // ERC pool with insufficient allowance -> approve first
603
+ if (prep.approveTx) {
604
+ const decimals = C.pools[pool].decimals
605
+ const needed = ethers.utils.parseUnits(String(n), decimals)
606
+ info(`approving ${prep.symbol} spend by BLACKPOOL pool...`)
607
+ const ap = await broadcastAndWait(signer, provider, {
608
+ to: prep.approveTx.to,
609
+ data: prep.approveTx.data,
610
+ value: prep.approveTx.value === '0' ? 0 : ethers.BigNumber.from(prep.approveTx.value),
611
+ ...gasOv,
612
+ }, { label: 'approve' })
613
+ info(`approve tx: ${ap.hash}`)
614
+ ok('approve confirmed')
615
+ // Poll allowance on-chain before deposit tx (mirrors frontend fix 8f92163).
616
+ // Public RPC is load-balanced; the deposit gas-estimate can hit a node that
617
+ // hasn't seen the approve yet -> "transfer amount exceeds allowance". Poll until it reflects.
618
+ const token = new ethers.Contract(prep.approveTx.to, ['function allowance(address,address) view returns (uint256)'], provider)
619
+ info('waiting for allowance to propagate on-chain...')
620
+ let ok2 = false
621
+ for (let i = 0; i < 15; i++) {
622
+ try {
623
+ const allow = await token.allowance(wallet.address, prep.to)
624
+ if (allow.gte(needed)) { ok2 = true; break }
625
+ } catch {}
626
+ await new Promise(r => setTimeout(r, 2000))
627
+ }
628
+ if (!ok2) fail('allowance did not propagate after 30s. Re-run `blackpool deposit` - the approve is already on-chain, so it will skip approve and go straight to deposit.')
629
+ }
630
+
631
+ info('broadcasting deposit tx...')
632
+ const dep = await broadcastAndWait(signer, provider, {
633
+ to: prep.to,
634
+ data: prep.data,
635
+ value: prep.value === '0' ? 0 : ethers.BigNumber.from(prep.value),
636
+ ...gasOv,
637
+ }, { label: 'deposit' })
638
+ info(`deposit tx: ${dep.hash}`)
639
+
640
+ log('')
641
+ ok(color(`shielded ${amount} ${prep.symbol} on ${C.label}`, c.green))
642
+ info(`new private balance: ${prep.newBalance} ${prep.symbol}`)
643
+ info(`tx: ${explorerTx(chain, dep.hash)}`)
644
+ log('')
645
+ }
646
+
647
+ // === Uniswap V3 constants on Base (extracted from frontend bundle - do not change) ===
648
+ const UNI_USDC = '0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913'
649
+ const UNI_WETH = '0x4200000000000000000000000000000000000006'
650
+ const UNI_ROUTER = '0x2626664c2603336E57B271c5C0b26F421741e481' // SwapRouter02
651
+ const UNI_QUOTER = '0x3d4e44Eb1374240CE5F1B871ab261CD16335B76a'
652
+ const UNI_FEE = 100 // 0.01% pool
653
+ // Gas params are now chain-aware via chainGasOverridesSync(chain) (registry gasCapGwei). The old
654
+ // Base-only baseGasOverrides() + GAS_RESERVE_ETH constant were removed in the multi-chain refactor.
655
+
656
+ // Derive a burner wallet deterministically from the user's BLACKPOOL signature.
657
+ // === Non-custodial burner registry (mirrors frontend app/lib/burner.ts) ===
658
+ // Each swap now uses a FRESH RANDOM wallet whose privkey is encrypted client-side (AES-256-GCM under
659
+ // keccak256(sign-in sig)) and pushed to the backend registry as ciphertext. Recovery from any device
660
+ // re-signs the two messages, fetches the rows, decrypts locally, and sweeps. This replaces the old
661
+ // derive-nonce-0..19 model, which stranded funds at high nonces and — crucially — could NOT recover
662
+ // burners created by the frontend (different address space). CLI is now at parity with the web app.
663
+ const crypto = require('crypto')
664
+ // Signed by a key DERIVED from the sign-in signature (never by the wallet itself) — identical bytes to
665
+ // src/registryAuth.js on the backend and frontend/app/lib/burner.ts.
666
+ const REGISTRY_AUTH_DOMAIN = 'blackpool/registry-auth/v1'
667
+ const REGISTRY_AUTH_MESSAGE =
668
+ 'Blackpool Registry\n\n' +
669
+ 'Authorize access to your encrypted recovery data.\n' +
670
+ 'This does not move funds and does not expose your account keys.'
671
+
672
+ // AES-256 key from the sign-in signature: keccak256(getBytes(sig)) -> 32 bytes. Identical to frontend.
673
+ function aesKeyFromSignIn(signInSig) {
674
+ return Buffer.from(ethers.utils.keccak256(ethers.utils.arrayify(signInSig)).slice(2), 'hex')
675
+ }
676
+ // Encrypt to the frontend's WebCrypto layout: [iv(12)] + [ciphertext] + [authTag(16)]. WebCrypto's
677
+ // subtle.encrypt appends the 16-byte GCM tag to the ciphertext, so we mirror that with getAuthTag().
678
+ function encryptBurnerKey(privKeyHex, signInSig) {
679
+ const key = aesKeyFromSignIn(signInSig)
680
+ const iv = crypto.randomBytes(12)
681
+ const cipher = crypto.createCipheriv('aes-256-gcm', key, iv)
682
+ const ct = Buffer.concat([cipher.update(Buffer.from(privKeyHex, 'utf8')), cipher.final()])
683
+ const tag = cipher.getAuthTag()
684
+ return '0x' + Buffer.concat([iv, ct, tag]).toString('hex')
685
+ }
686
+ // Decrypt the frontend's layout (tag is the trailing 16 bytes).
687
+ function decryptBurnerKey(encHex, signInSig) {
688
+ const key = aesKeyFromSignIn(signInSig)
689
+ const buf = Buffer.from(encHex.replace(/^0x/, ''), 'hex')
690
+ const iv = buf.slice(0, 12)
691
+ const tag = buf.slice(buf.length - 16)
692
+ const ct = buf.slice(12, buf.length - 16)
693
+ const decipher = crypto.createDecipheriv('aes-256-gcm', key, iv)
694
+ decipher.setAuthTag(tag)
695
+ return Buffer.concat([decipher.update(ct), decipher.final()]).toString('utf8')
696
+ }
697
+
698
+ // Registry auth signature — over the SEPARATE registry message, never the sign-in sig (signing this
699
+ // does NOT reveal the encryption key; the backend only ecrecovers the owner address from it).
700
+ async function registryAuthSig(signInSig) {
701
+ const key = ethers.utils.keccak256(ethers.utils.concat([ethers.utils.arrayify(signInSig), ethers.utils.toUtf8Bytes(REGISTRY_AUTH_DOMAIN)]))
702
+ return new ethers.Wallet(key).signMessage(REGISTRY_AUTH_MESSAGE)
703
+ }
704
+
705
+ // Create a fresh random ephemeral burner, encrypt its key, register it BEFORE funding so recovery is
706
+ // possible even if the very next step stalls. Register failure does NOT abort the swap (recovery also
707
+ // scans legacy on-chain), but we surface it.
708
+ async function createAndRegisterBurner(signInSig, authSig, chain, token) {
709
+ const w = ethers.Wallet.createRandom()
710
+ const encKey = encryptBurnerKey(w.privateKey, signInSig)
711
+ try {
712
+ await apiPost('/api/burner/register', { authSig, burnerAddress: w.address, chain, token, encKey }, { timeoutMs: 30_000 })
713
+ } catch (e) { info(`(registry register failed: ${cleanErr(e)} — swap continues; recovery still scans on-chain)`) }
714
+ return w
715
+ }
716
+
717
+ // Fetch the user's registered burners (ciphertext), decrypt each key locally. Cross-device, no cache.
718
+ async function listRecoverableBurners(signInSig, authSig) {
719
+ const rows = []; let before = null
720
+ for (let i = 0; i < 100; i++) {
721
+ const res = await apiPost('/api/burner/list', before ? { authSig, before } : { authSig }, { timeoutMs: 30_000 })
722
+ rows.push(...(res.burners || [])); if (!res.next) break; before = res.next
723
+ }
724
+ const out = []
725
+ for (const row of rows) {
726
+ try { out.push({ address: row.burnerAddress, chain: row.chain, token: row.token, privateKey: decryptBurnerKey(row.encKey, signInSig), sweptAt: row.sweptAt }) }
727
+ catch { /* row from a different account / undecryptable — skip */ }
728
+ }
729
+ return out
730
+ }
731
+
732
+ // Mark a burner swept in the registry (bookkeeping only; best-effort).
733
+ async function markBurnerSwept(authSig, burnerAddress, chain) {
734
+ try { await apiPost('/api/burner/mark-swept', { authSig, burnerAddress, chain }, { timeoutMs: 20_000 }) } catch { /* non-fatal */ }
735
+ }
736
+ // Poll until token balance >= minimum. Mirrors frontend e7().
737
+ async function waitTokenBalance(provider, tokenAddr, addr, min, retries = 60) {
738
+ const c = new ethers.Contract(tokenAddr, ['function balanceOf(address) view returns (uint256)'], provider)
739
+ for (let i = 0; i < retries; i++) {
740
+ try { const b = await c.balanceOf(addr); if (b.gte(min)) return b } catch {}
741
+ await new Promise(r => setTimeout(r, 2500))
742
+ }
743
+ throw new Error(`token balance never reached on ${addr}`)
744
+ }
745
+ // Poll until ETH balance >= minimum. Mirrors frontend e9().
746
+ async function waitEthBalance(provider, addr, min, label = 'eth') {
747
+ for (let i = 0; i < 80; i++) {
748
+ const b = await provider.getBalance(addr)
749
+ if (b.gte(min)) return b
750
+ await new Promise(r => setTimeout(r, 2500))
751
+ }
752
+ throw new Error(`${label} balance never arrived on ${addr}`)
753
+ }
754
+
755
+ async function apiGet(endpoint, { timeoutMs = 30_000 } = {}) {
756
+ const controller = new AbortController()
757
+ const t = setTimeout(() => controller.abort(), timeoutMs)
758
+ try {
759
+ const r = await fetch(`${API}${endpoint}`, { headers: { 'User-Agent': `blackpool-cli/${require('../package.json').version}` }, signal: controller.signal })
760
+ let json = {}
761
+ try { json = await r.json() } catch {}
762
+ if (!r.ok) throw new Error(json?.error || `HTTP ${r.status}`)
763
+ return json
764
+ } finally { clearTimeout(t) }
765
+ }
766
+
767
+ // 1Click origin assets, keyed by the shielded pool they withdraw from. The API resolves a bridge
768
+ // origin via CHAIN_DEFS.oneClickAsset and rejects anything else with "has no private pool", so this
769
+ // mirrors that map. Robinhood is indexed by 1Click since 2026-09-18 (chain id 'hood'); Arc is
770
+ // absent (chain disabled). Origin chain is derived from the key, not assumed to be Base.
771
+ const BRIDGE_ORIGINS = {
772
+ base_eth: { chain: 'base', pool: 'eth' },
773
+ base_usdc: { chain: 'base', pool: 'usdc' },
774
+ hood_eth: { chain: 'robinhood', pool: 'eth' },
775
+ hood_usdg: { chain: 'robinhood', pool: 'usdg' },
776
+ arb_eth: { chain: 'arbitrum', pool: 'eth' },
777
+ arb_usdc: { chain: 'arbitrum', pool: 'usdc' },
778
+ bsc_bnb: { chain: 'bsc', pool: 'bnb' },
779
+ bsc_usdc: { chain: 'bsc', pool: 'usdc' },
780
+ bsc_usdt: { chain: 'bsc', pool: 'usdt' },
781
+ }
782
+
783
+ // blackpool bridge <destAssetKey> <recipient> <amount> [--from ...] [--chain ...]
784
+ // Every destination uses the NEAR Intents 1Click flow (/api/swap + poll) — Robinhood included.
785
+ async function cmdBridge(args) {
786
+ const toAsset = args[0]
787
+ const recipient = args[1]
788
+ const amount = args[2]
789
+ if (!toAsset || toAsset.startsWith('--') || !recipient || !amount) {
790
+ fail(`usage: blackpool bridge <dest-asset> <recipient> <amount> [--from <origin>] [--chain ...]\n origins: ${Object.keys(BRIDGE_ORIGINS).join('|')} (default base_usdc)\n run 'blackpool assets' for destinations`)
791
+ }
792
+ const fromAsset = getFlag(args, '--from') || 'base_usdc'
793
+ // Recipient format follows the destination: Solana = base58 pubkey, everything else EVM 0x…
794
+ const destRe = toAsset.startsWith('sol_') ? /^[1-9A-HJ-NP-Za-km-z]{32,44}$/ : /^0x[a-fA-F0-9]{40}$/
795
+ if (!destRe.test(recipient)) fail(`invalid recipient address for ${toAsset}: ${recipient}`)
796
+ const n = Number(amount)
797
+ if (isNaN(n) || n <= 0) fail(`invalid amount: ${amount}`)
798
+ // Validate the origin locally instead of discovering it server-side. Without this, an unsupported
799
+ // --from spent a signature and a ~1 min proof round-trip only to come back with the API's
800
+ // "origin X has no private pool".
801
+ const origin = BRIDGE_ORIGINS[fromAsset]
802
+ if (!origin) {
803
+ const known = Object.keys(BRIDGE_ORIGINS).join(', ')
804
+ fail(`unknown --from "${fromAsset}" (1Click origins: ${known})`)
805
+ }
806
+ // The origin pool must exist on the origin chain and carries its own deposit floor.
807
+ // Note the two floors are different checks: minDeposit is about whether this amount could have been
808
+ // shielded in the first place (a note that already exists is fine to bridge, hence info not fail),
809
+ // while the MIN_TX floor is about the withdraw fee this bridge is *about to* pay, which is why that
810
+ // one fails. The origin chain here comes from BRIDGE_ORIGINS, not --chain.
811
+ const originMin = CHAINS[origin.chain].pools[origin.pool].minDeposit
812
+ if (originMin && n < originMin) {
813
+ info(`note: ${n} is below the ${originMin} ${CHAINS[origin.chain].pools[origin.pool].symbol} deposit floor on ${CHAINS[origin.chain].label} — bridging an existing balance is fine, but you could not have shielded this amount.`)
814
+ }
815
+ requireMinTx(n, origin.chain, origin.pool, 'bridge')
816
+
817
+ const wallet = getWallet()
818
+ const sig = await ensureSignature(wallet)
819
+
820
+ log('')
821
+ log(color(` bridging ${amount} privately`, c.bold))
822
+ log(color(` ${fromAsset} -> ${toAsset}`, c.dim))
823
+ log(color(` recipient ${recipient}`, c.dim))
824
+ log('')
825
+ info('quote + groth16 proof + private withdraw to solver... can take ~1-2 min')
826
+
827
+ const res = await apiPost('/api/swap', {
828
+ signature: sig, fromAsset, toAsset, amount: n, recipient,
829
+ refundTo: wallet.address, // Solana/XRP recipients: solver refunds (origin chain) come back to this wallet
830
+ }, { timeoutMs: 180_000 })
831
+
832
+ ok(`withdrawn privately to solver`)
833
+ info(`≈ ${res.amountOut} arriving, est ~${res.timeEstimate}s`)
834
+ // origin chain comes from the validated BRIDGE_ORIGINS entry. The old version inferred it with a
835
+ // robinhood-or-base ternary, so an arb_*/bsc_* origin printed a Basescan link for a tx that was
836
+ // never on Base.
837
+ info(`withdraw tx: ${explorerTx(origin.chain, res.txHash)}`)
838
+ info(`tracking: ${res.statusUrl}`)
839
+ log('')
840
+
841
+ if (res.depositAddress) {
842
+ info('polling solver status (Ctrl+C to stop; bridge continues regardless)...')
843
+ const start = Date.now()
844
+ while (Date.now() - start < 360_000) {
845
+ await new Promise(r => setTimeout(r, 8000))
846
+ try {
847
+ const st = await apiGet(`/api/intent-status?depositAddress=${res.depositAddress}`)
848
+ if (st.status === 'SUCCESS') { ok(color(`delivered ${st.amountOut || res.amountOut} to recipient`, c.green)); log(''); return }
849
+ if (st.status === 'REFUNDED') { fail(`solver refunded (${st.refundReason || 'route unavailable'}). Funds auto-return to your wallet.`) }
850
+ if (st.status === 'FAILED') { fail('bridge failed at solver. Funds auto-refund.') }
851
+ info(`status: ${st.status}...`)
852
+ } catch (e) { info(`status check: ${e.message}`) }
853
+ }
854
+ info('still processing after 6 min - check the tracking URL above.')
855
+ }
856
+ log('')
857
+ }
858
+
859
+ // blackpool swap <amount> --from eth|usdc [--slippage bps]
860
+ // Same-chain private swap ETH<->USDC via burner + Uniswap V3, reshield to private balance.
861
+ // Mirrors frontend flow: fresh burner -> /api/swap-private -> [burner-gas for USDC] -> approve+swap
862
+ // via Uniswap V3 -> unwrap WETH if ETH output -> /api/deposit/prepare -> burner broadcasts reshield.
863
+ // blackpool recover [--to <addr>]
864
+ // Sweep any USDC/ETH stranded on burner wallets back to the user's public wallet.
865
+ // Mirrors frontend /recover/page.tsx: sweep every registry burner holding more than dust.
866
+ // Sweep one burner's stablecoin + ETH to `dest`. Returns { swept, dust, error } counters.
867
+ async function sweepBurner(privateKey, chain, dest) {
868
+ const C = CHAINS[chain]
869
+ const stableSym = C.pools[C.stablePool].symbol
870
+ const stableDecimals = C.pools[C.stablePool].decimals
871
+ const provider = rpcProvider(chain)
872
+ const gasO = chainGasOverridesSync(chain)
873
+ const DUST_ETH = ethers.utils.parseEther('0.0001')
874
+ const usdc = new ethers.Contract(C.uni.stable, ['function balanceOf(address) view returns (uint256)'], provider)
875
+ const burner = new ethers.Wallet(privateKey, provider)
876
+
877
+ let ethB, uB
878
+ try { [ethB, uB] = await Promise.all([provider.getBalance(burner.address), usdc.balanceOf(burner.address)]) }
879
+ catch (e) { info(` ${burner.address.slice(0, 10)}: balance read failed (${(e.message || '').slice(0, 60)})`); return { swept: false, dust: false, error: true } }
880
+
881
+ if (ethB.eq(0) && uB.eq(0)) return { swept: false, dust: false, error: false }
882
+ if (uB.eq(0) && ethB.lte(DUST_ETH)) return { swept: false, dust: true, error: false } // only dust ETH — not recoverable
883
+
884
+ log(color(` burner ${burner.address}`, c.cyan))
885
+ log(` ${C.pools[C.nativePool].symbol} : ${ethers.utils.formatEther(ethB)}`)
886
+ log(` ${stableSym}: ${ethers.utils.formatUnits(uB, stableDecimals)}`)
887
+ let swept = false, error = false
888
+
889
+ // stablecoin first (needs ETH for gas). Chain-realistic gas so the burner's small top-up isn't drained.
890
+ if (uB.gt(0)) {
891
+ try {
892
+ if (ethB.lt(ethers.utils.parseEther('0.00001'))) {
893
+ info(` burner has no ETH for gas — send it ~0.00002 ETH then re-run recover`)
894
+ } else {
895
+ info(` sweeping ${ethers.utils.formatUnits(uB, stableDecimals)} ${stableSym} to ${dest}...`)
896
+ const t = new ethers.Contract(C.uni.stable, ['function transfer(address,uint256) returns (bool)'], burner)
897
+ const populated = await t.populateTransaction.transfer(dest, uB)
898
+ const tx = await broadcastAndWait(burner, provider, { ...populated, gasLimit: 80_000, ...gasO }, { label: `${stableSym} sweep` })
899
+ ok(` ${stableSym.toLowerCase()} swept: ${tx.hash.slice(0, 14)}...`)
900
+ swept = true
901
+ }
902
+ } catch (e) { info(` ${stableSym} sweep failed: ${(e.message || '').slice(0, 80)}`); error = true }
903
+ }
904
+
905
+ // remaining ETH, 2x headroom over the actual max fee.
906
+ try {
907
+ const ethNow = await provider.getBalance(burner.address)
908
+ if (ethNow.gt(0)) {
909
+ const fee = await provider.getFeeData()
910
+ const perGas = fee.maxFeePerGas || fee.gasPrice || ethers.utils.parseUnits('2', 'gwei')
911
+ // A plain ETH transfer is 21000 on L1/Base, but Arbitrum Orbit L2s (Robinhood) charge
912
+ // a slightly higher intrinsic gas (~21167) and reject a hardcoded 21000 as "intrinsic gas
913
+ // too low". Estimate per-chain and add a small buffer; fall back to 50k if estimation fails.
914
+ let gasUnits
915
+ try { gasUnits = (await provider.estimateGas({ to: dest, value: ethNow.div(2), from: burner.address })).mul(12).div(10) }
916
+ catch { gasUnits = ethers.BigNumber.from(50_000) }
917
+ const gasCost = perGas.mul(gasUnits).mul(2)
918
+ if (ethNow.gt(gasCost)) {
919
+ const sendVal = ethNow.sub(gasCost)
920
+ info(` sweeping ${ethers.utils.formatEther(sendVal)} ETH to ${dest}...`)
921
+ const tx = await broadcastAndWait(burner, provider, { to: dest, value: sendVal, gasLimit: gasUnits }, { label: 'ETH sweep' })
922
+ ok(` eth swept: ${tx.hash.slice(0, 14)}...`)
923
+ swept = true
924
+ } else {
925
+ info(` ETH (${ethers.utils.formatEther(ethNow)}) is dust below gas cost, leaving it`)
926
+ }
927
+ }
928
+ } catch (e) { info(` ETH sweep failed: ${(e.message || '').slice(0, 80)}`); error = true }
929
+ return { swept, dust: false, error }
930
+ }
931
+
932
+ // Sweep registry burners for ONE chain. Shared by single-chain and --chain all.
933
+ async function recoverOneChain(chain, wallet, sig, dest) {
934
+ const C = CHAINS[chain]
935
+ log('')
936
+ log(color(` recovering stranded burner funds on ${C.label}`, c.bold))
937
+ log(color(` sweeping to ${dest}`, c.dim))
938
+ log('')
939
+
940
+ let swept = 0, dust = 0, errors = 0
941
+ const seen = new Set()
942
+
943
+ // 1) Registry burners (random ephemeral, created by frontend OR this CLI). Primary path — random
944
+ // burners are NOT derivable, so without the registry they'd be unrecoverable.
945
+ try {
946
+ const authSig = await registryAuthSig(sig)
947
+ const registryBurners = (await listRecoverableBurners(sig, authSig)).filter(b => b.chain === chain)
948
+ info(`registry: ${registryBurners.length} burner(s) on ${C.label}`)
949
+ for (const b of registryBurners) {
950
+ if (seen.has(b.address.toLowerCase())) continue
951
+ seen.add(b.address.toLowerCase())
952
+ const r = await sweepBurner(b.privateKey, chain, dest)
953
+ if (r.swept) { swept++; await markBurnerSwept(authSig, b.address, chain) }
954
+ if (r.dust) dust++
955
+ if (r.error) errors++
956
+ }
957
+ } catch (e) { info(`registry scan skipped: ${cleanErr(e)}`) }
958
+
959
+ log('')
960
+ if (swept > 0) ok(color(`recovered funds from ${swept} burner(s) on ${C.label}`, c.green))
961
+ else ok(`no stranded funds on ${C.label} — all burners clean`)
962
+ if (dust > 0) info(`(${dust} burner(s) hold only dust ETH below gas cost — not recoverable, safe to ignore)`)
963
+ if (errors > 0) info(`${errors} burner(s) had errors — re-run recover to retry`)
964
+ log('')
965
+ return { swept, dust, errors }
966
+ }
967
+
968
+ async function cmdRecover(args) {
969
+ const to = getFlag(args, '--to')
970
+ const wallet = getWallet()
971
+ const dest = to || wallet.address
972
+ if (!/^0x[a-fA-F0-9]{40}$/.test(dest)) fail(`invalid --to address: ${dest}`)
973
+ const sig = await ensureSignature(wallet)
974
+ const rawChain = (getFlag(args, '--chain') || 'base').toLowerCase()
975
+
976
+ // --chain all: sweep every chain in one go (fully idempotent, safe to run any time)
977
+ if (rawChain === 'all') {
978
+ for (const chainKey of Object.keys(CHAINS)) {
979
+ await recoverOneChain(chainKey, wallet, sig, dest)
980
+ }
981
+ return
982
+ }
983
+
984
+ const chain = resolveChain(args)
985
+ await recoverOneChain(chain, wallet, sig, dest)
986
+ }
987
+
988
+ async function cmdSwap(args) {
989
+ const chain = resolveChain(args)
990
+ const C = CHAINS[chain]
991
+ const amount = args[0]
992
+ const from = getFlag(args, '--from')
993
+ const stablePool = C.stablePool // 'usdc' on base, 'usdg' on robinhood, 'usdt' on bsc
994
+ const nativePool = C.nativePool // 'eth' everywhere except bsc, where it's 'bnb'
995
+ const validFrom = [nativePool, stablePool]
996
+ if (!amount || amount.startsWith('--') || !from) fail(`usage: blackpool swap <amount> --from ${validFrom.join('|')} [--chain ${CHAIN_LIST}] [--slippage 100]`)
997
+ if (!validFrom.includes(from)) fail(`--from must be ${validFrom.join(' or ')} on ${C.label}`)
998
+ const n = Number(amount)
999
+ if (isNaN(n) || n <= 0) fail(`invalid amount: ${amount}`)
1000
+ requireMinTx(n, chain, from, 'swap')
1001
+ const slippageBps = Number(getFlag(args, '--slippage') || 100) // 1% default
1002
+ const isEthOrigin = from === nativePool
1003
+ const toSym = isEthOrigin ? C.pools[stablePool].symbol : C.pools[nativePool].symbol
1004
+ const stableSym = C.pools[stablePool].symbol
1005
+ // per-chain Uniswap V3 + token addresses (registry)
1006
+ const U = { router: C.uni.router, quoter: C.uni.quoter, weth: C.uni.weth, stable: C.uni.stable, fee: C.uni.fee }
1007
+ const stableDecimals = C.pools[stablePool].decimals
1008
+ const gasOv = (limit) => ({ gasLimit: limit, ...(chainGasOverridesSync(chain)) })
1009
+
1010
+ const wallet = getWallet()
1011
+ const sig = await ensureSignature(wallet)
1012
+ const provider = rpcProvider(chain)
1013
+
1014
+ log('')
1015
+ log(color(` swapping ${amount} ${from.toUpperCase()} -> ${toSym} privately on ${C.label}`, c.bold))
1016
+ log(color(` fresh random burner (encrypted key registered for cross-device recovery)`, c.dim))
1017
+ log('')
1018
+
1019
+ // === Step 1: create + register a fresh random burner (parity with frontend) ===
1020
+ info('creating fresh burner + registering encrypted key for recovery...')
1021
+ const authSig = await registryAuthSig(sig)
1022
+ const burnerW = await createAndRegisterBurner(sig, authSig, chain, C.uni.stable)
1023
+ const burner = new ethers.Wallet(burnerW.privateKey, provider)
1024
+ info(`burner: ${burner.address}`)
1025
+ info(`if anything fails, run \`blackpool recover\` — this burner's key is in the registry.`)
1026
+
1027
+ // === Step 2: withdraw private -> burner via /api/swap-private ===
1028
+ info(`withdrawing private ${from.toUpperCase()} to burner via relayer...`)
1029
+ const wp = await apiPost('/api/swap-private', {
1030
+ signature: sig, chain, pool: from,
1031
+ fromAsset: chain === 'base' ? (isEthOrigin ? 'base_eth' : 'base_usdc') : undefined,
1032
+ amount: n, burnerAddress: burner.address,
1033
+ }, { timeoutMs: 180_000 })
1034
+ info(`withdraw tx: ${explorerTx(chain, wp.txHash)}`)
1035
+
1036
+ // Gas reserve sizing (chain-aware). The reshield needs a SEPARATE approve+deposit (~1.68M gas) for a
1037
+ // stablecoin dest, or a single ~1.6M-gas deposit for an ETH dest — priced at the chain's gas cap
1038
+ // (never ethers' 1.5 gwei default). Under-reserving here is exactly what stranded ETH→USDG on
1039
+ // Robinhood, so reserve generously (+50%).
1040
+ const gasCap = ethers.utils.parseUnits(C.gasCapGwei, 'gwei')
1041
+ const reshieldGasReserve = ethers.BigNumber.from(1_680_000).mul(gasCap).mul(15).div(10)
1042
+
1043
+ // === Step 3: wait funds on burner + top-up gas for stablecoin origin ===
1044
+ let amountIn
1045
+ if (isEthOrigin) {
1046
+ info('waiting for ETH on burner...')
1047
+ await waitEthBalance(provider, burner.address, ethers.utils.parseEther('0.0001'), 'burner funding')
1048
+ const bal = await provider.getBalance(burner.address)
1049
+ // Leave enough ETH for the stablecoin reshield's approve+deposit (dest is a stablecoin here).
1050
+ if (bal.lte(reshieldGasReserve)) fail('burner balance too low after fees')
1051
+ amountIn = bal.sub(reshieldGasReserve)
1052
+ } else {
1053
+ info(`waiting for ${stableSym} on burner...`)
1054
+ amountIn = await waitTokenBalance(provider, U.stable, burner.address, ethers.BigNumber.from(1), 200)
1055
+ info('topping up burner gas via relayer...')
1056
+ // txCount 2: swap + unwrap both need gas; the ETH-dest reshield pays its own from the swap output.
1057
+ const gasRes = await apiPost('/api/burner-gas', {
1058
+ signature: sig, burnerAddress: burner.address, chain, txCount: 2,
1059
+ }, { timeoutMs: 60_000 })
1060
+ if (!gasRes.success) fail('gas top-up failed')
1061
+ await waitEthBalance(provider, burner.address, ethers.BigNumber.from(1), 'burner gas')
1062
+ }
1063
+
1064
+ // === Step 4: swap via Uniswap V3 (SwapRouter02) ===
1065
+ info(`swapping ${from.toUpperCase()} -> ${toSym} on Uniswap V3 (fee tier ${U.fee / 10000}%)...`)
1066
+ const tokenIn = isEthOrigin ? U.weth : U.stable
1067
+ const tokenOut = isEthOrigin ? U.stable : U.weth
1068
+ const quoter = new ethers.Contract(U.quoter,
1069
+ ['function quoteExactInputSingle((address,address,uint256,uint24,uint160)) returns (uint256,uint160,uint32,uint256)'], provider)
1070
+ const quote = await quoter.callStatic.quoteExactInputSingle([tokenIn, tokenOut, amountIn, U.fee, 0])
1071
+ const amountOut = quote[0]
1072
+ // slippage: 100 bps = 1%
1073
+ const minOut = amountOut.sub(amountOut.mul(slippageBps).div(10000))
1074
+ const decOut = isEthOrigin ? stableDecimals : 18
1075
+ info(`quote: ${ethers.utils.formatUnits(amountOut, decOut)} ${toSym} (min ${ethers.utils.formatUnits(minOut, decOut)} at ${slippageBps} bps slippage)`)
1076
+
1077
+ // approve stablecoin if origin. Manual gasLimit to avoid public-RPC estimateGas flakiness.
1078
+ if (!isEthOrigin) {
1079
+ const stable = new ethers.Contract(U.stable, [
1080
+ 'function allowance(address,address) view returns (uint256)',
1081
+ 'function approve(address,uint256) returns (bool)',
1082
+ ], burner)
1083
+ if ((await stable.allowance(burner.address, U.router)).lt(amountIn)) {
1084
+ info(`approving ${stableSym} to Uniswap router...`)
1085
+ // approve() = ~50k gas. Chain-realistic gas params so the burner's small ETH top-up isn't
1086
+ // drained by ethers' default 1.5 gwei fee estimate. Also skips estimateGas.
1087
+ const apPop = await stable.populateTransaction.approve(U.router, amountIn)
1088
+ await broadcastAndWait(burner, provider, { ...apPop, ...gasOv(80_000) }, { label: 'swap approve' })
1089
+ // Poll allowance propagation (mirror same fix as deposit).
1090
+ for (let i = 0; i < 20; i++) {
1091
+ try { if ((await stable.allowance(burner.address, U.router)).gte(amountIn)) break } catch {}
1092
+ await new Promise(r => setTimeout(r, 2500))
1093
+ }
1094
+ }
1095
+ }
1096
+ const router = new ethers.Contract(U.router, [
1097
+ 'function exactInputSingle((address tokenIn,address tokenOut,uint24 fee,address recipient,uint256 amountIn,uint256 amountOutMinimum,uint160 sqrtPriceLimitX96)) payable returns (uint256)',
1098
+ ], burner)
1099
+ const params = { tokenIn, tokenOut, fee: U.fee, recipient: burner.address, amountIn, amountOutMinimum: minOut, sqrtPriceLimitX96: 0 }
1100
+ // Uniswap V3 exactInputSingle ~180k gas. Manual gas so burner's ETH top-up is enough.
1101
+ const swapOverrides = gasOv(250_000)
1102
+ const swapPop = isEthOrigin
1103
+ ? await router.populateTransaction.exactInputSingle(params, { ...swapOverrides, value: amountIn })
1104
+ : await router.populateTransaction.exactInputSingle(params, swapOverrides)
1105
+ const swapTx = await broadcastAndWait(burner, provider, swapPop, { label: 'Uniswap swap' })
1106
+ info(`swap tx: ${explorerTx(chain, swapTx.hash)}`)
1107
+ ok('swap confirmed on Uniswap')
1108
+
1109
+ // === Step 5: unwrap WETH -> ETH if ETH is the destination ===
1110
+ if (!isEthOrigin) {
1111
+ info('unwrapping WETH -> ETH on burner...')
1112
+ const wethBal = await waitTokenBalance(provider, U.weth, burner.address, ethers.BigNumber.from(1), 60)
1113
+ const weth = new ethers.Contract(U.weth, ['function withdraw(uint256)'], burner)
1114
+ const uwPop = await weth.populateTransaction.withdraw(wethBal)
1115
+ await broadcastAndWait(burner, provider, { ...uwPop, ...gasOv(60_000) }, { label: 'WETH unwrap' })
1116
+ info('unwrap confirmed')
1117
+ }
1118
+
1119
+ // === Step 6: figure out reshield amount + call /api/deposit/prepare ===
1120
+ const destPool = isEthOrigin ? stablePool : 'eth'
1121
+ const destDec = isEthOrigin ? stableDecimals : 18
1122
+ let reshieldAmount
1123
+ if (isEthOrigin) {
1124
+ // Stablecoin destination: reshield the full stable balance (gas paid in the burner's reserved ETH).
1125
+ reshieldAmount = await waitTokenBalance(provider, U.stable, burner.address, ethers.BigNumber.from(1), 60)
1126
+ // Make sure the reserved ETH survived the swap (it should — swap input excluded it).
1127
+ if ((await provider.getBalance(burner.address)).lt(reshieldGasReserve.div(2))) {
1128
+ info('topping up burner gas before reshield...')
1129
+ const g = await apiPost('/api/burner-gas', { signature: sig, burnerAddress: burner.address, chain, txCount: 2 }, { timeoutMs: 60_000 })
1130
+ if (g.success) await waitEthBalance(provider, burner.address, ethers.BigNumber.from(1), 'reshield gas')
1131
+ }
1132
+ } else {
1133
+ // ETH destination: the deposit tx is payable (value = amount) AND pays its own gas from the same
1134
+ // balance. Reserve the deposit gas at the chain cap.
1135
+ const full = await waitEthBalance(provider, burner.address, ethers.utils.parseEther('0.0000001'), 'swap output')
1136
+ const gasReserve = gasCap.mul(1_600_000)
1137
+ if (full.lte(gasReserve)) fail(`swap output ${ethers.utils.formatEther(full)} ETH too small to reshield after gas`)
1138
+ reshieldAmount = full.sub(gasReserve)
1139
+ }
1140
+ const reshieldFmt = ethers.utils.formatUnits(reshieldAmount, destDec)
1141
+ info(`preparing reshield of ${reshieldFmt} ${toSym} back to your private balance...`)
1142
+ // Reshield prepare: pass the USER's address (mirrors frontend `address: el`), NOT the burner.
1143
+ // The tx is still broadcast FROM the burner (which holds the swapped funds), but the deposit's
1144
+ // extData.recipient references the user's account - matching how the frontend reshields.
1145
+ const prep = await apiPost('/api/deposit/prepare', {
1146
+ signature: sig, address: wallet.address, amount: reshieldFmt, pool: destPool, chain,
1147
+ }, { timeoutMs: 180_000 })
1148
+
1149
+ // ERC pool: burner approve first
1150
+ if (prep.approveTx) {
1151
+ info(`burner approving ${prep.symbol} to BLACKPOOL pool...`)
1152
+ await broadcastAndWait(burner, provider, {
1153
+ to: prep.approveTx.to, data: prep.approveTx.data,
1154
+ value: prep.approveTx.value === '0' ? 0 : ethers.BigNumber.from(prep.approveTx.value),
1155
+ ...gasOv(80_000),
1156
+ }, { label: 'reshield approve' })
1157
+ // allowance-propagation poll (same fix as deposit)
1158
+ const token = new ethers.Contract(prep.approveTx.to, ['function allowance(address,address) view returns (uint256)'], provider)
1159
+ for (let i = 0; i < 20; i++) {
1160
+ try { if ((await token.allowance(burner.address, prep.to)).gte(reshieldAmount)) break } catch {}
1161
+ await new Promise(r => setTimeout(r, 2500))
1162
+ }
1163
+ }
1164
+ // Pre-flight: simulate the reshield via eth_call before broadcasting. If it would revert,
1165
+ // we learn why WITHOUT spending gas or stranding funds - then recover cleanly.
1166
+ const reshieldVal = prep.value === '0' ? ethers.BigNumber.from(0) : ethers.BigNumber.from(prep.value)
1167
+ info('simulating reshield before broadcast...')
1168
+ try {
1169
+ await provider.call({ to: prep.to, data: prep.data, value: reshieldVal, from: burner.address })
1170
+ } catch (simErr) {
1171
+ const reason = simErr.reason || simErr.error?.message || simErr.message || String(simErr)
1172
+ fail(`reshield would revert: ${reason.slice(0, 160)}\n Funds are safe on burner ${burner.address}. Run \`blackpool recover\` to sweep them back.`)
1173
+ }
1174
+
1175
+ info('burner broadcasting reshield tx (funds re-enter YOUR private balance)...')
1176
+ // reshield transact() = Groth16 proof verify + Merkle tree updates on-chain. Measured ~1.35M gas.
1177
+ // Two constraints must both fit: (1) gasLimit >= actual usage (else revert like 591k cap),
1178
+ // (2) gasLimit * maxFeePerGas <= burner ETH balance (else "insufficient funds for gas").
1179
+ // Solution: estimate real gas, add 20% buffer, and cap maxFeePerGas to what the burner can afford.
1180
+ const burnerEth = await provider.getBalance(burner.address)
1181
+ let estGas
1182
+ try { estGas = await provider.estimateGas({ to: prep.to, data: prep.data, value: reshieldVal, from: burner.address }) }
1183
+ catch { estGas = ethers.BigNumber.from(1_600_000) } // fallback
1184
+ const gasLimit = estGas.mul(12).div(10) // +20% headroom
1185
+ // Cap at the chain's gas ceiling OR at what fits in burner ETH, whichever is lower.
1186
+ let maxFee = gasCap
1187
+ const maxAffordable = burnerEth.div(gasLimit)
1188
+ if (maxFee.gt(maxAffordable)) maxFee = maxAffordable.mul(9).div(10) // 90% of ceiling
1189
+ const rtx = await broadcastAndWait(burner, provider, {
1190
+ to: prep.to, data: prep.data, value: reshieldVal,
1191
+ gasLimit,
1192
+ maxFeePerGas: maxFee,
1193
+ maxPriorityFeePerGas: maxFee.div(10),
1194
+ }, { label: 'reshield' })
1195
+
1196
+ log('')
1197
+ ok(color(`swap complete: ${amount} ${from.toUpperCase()} -> ${reshieldFmt} ${toSym} (private) on ${C.label}`, c.green))
1198
+ info(`new private balance: ${prep.newBalance} ${prep.symbol}`)
1199
+ info(`reshield tx: ${explorerTx(chain, rtx.hash)}`)
1200
+ log('')
1201
+ }
1202
+
1203
+ // blackpool bridges — list this wallet's NEAR Intents bridges (server-side intent registry, any device).
1204
+ async function cmdBridges() {
1205
+ const wallet = getWallet()
1206
+ const sig = await getSig(wallet)
1207
+ const authSig = await registryAuthSig(sig)
1208
+ const res = await apiPost('/api/intents', { authSig }, { timeoutMs: 60_000 })
1209
+ const list = res.intents || []
1210
+ if (!list.length) { log(color(' no bridges recorded for this wallet', c.dim)); return }
1211
+ log('')
1212
+ for (const it of list) {
1213
+ const when = new Date(it.createdAt).toISOString().replace('T', ' ').slice(0, 16) + ' UTC'
1214
+ const st = String(it.status || 'UNKNOWN')
1215
+ const col = st === 'SUCCESS' ? c.green : (st === 'REFUNDED' || st === 'FAILED') ? c.red : c.yellow
1216
+ log(` ${color(st.padEnd(16), col)} ${when} ${it.amountIn} ${it.fromAsset} -> ${it.toAsset}`)
1217
+ log(color(` to ${it.recipient}`, c.dim))
1218
+ if (st === 'SUCCESS') log(color(` delivered ${it.amountOut || it.amountOutQuote || ''}${it.destTx ? ` dest tx ${it.destTx}` : ''}`, c.dim))
1219
+ else if (st === 'REFUNDED' || st === 'FAILED') log(color(` ${it.statusDetail || 'solver could not complete'} — refund to ${it.refundTo} on ${it.originChain}`, c.dim))
1220
+ else log(color(` in progress — refund address if it fails: ${it.refundTo} on ${it.originChain}`, c.dim))
1221
+ log(color(` ${it.statusUrl}`, c.dim))
1222
+ }
1223
+ log('')
1224
+ }
1225
+
1226
+ async function cmdAssets() {
1227
+ info('fetching supported assets...')
1228
+ const res = await apiGet('/api/assets')
1229
+ log('')
1230
+ log(color(' bridge destination assets:', c.bold))
1231
+ const byChain = {}
1232
+ for (const [k, v] of Object.entries(res.assets || {})) {
1233
+ byChain[v.chain] = byChain[v.chain] || []
1234
+ byChain[v.chain].push(`${k} (${v.symbol})`)
1235
+ }
1236
+ for (const [chain, list] of Object.entries(byChain)) {
1237
+ log(` ${color(chain, c.cyan)}: ${color(list.join(', '), c.dim)}`)
1238
+ }
1239
+ log('')
1240
+ // Destinations and origins are different sets: any listed asset can be a destination, but an
1241
+ // origin must be a pool BLACKPOOL shields AND that 1Click indexes.
1242
+ log(color(` valid --from origins: ${Object.keys(BRIDGE_ORIGINS).join(', ')}`, c.bold))
1243
+ log(color(' every destination above is reachable from every origin via NEAR Intents (Robinhood = hood_*)', c.dim))
1244
+ log('')
1245
+ }
1246
+
1247
+ async function cmdSend(args) {
1248
+ const chain = resolveChain(args)
1249
+ const C = CHAINS[chain]
1250
+ const recipient = args[0]
1251
+ const amount = args[1]
1252
+ const validPools = poolsForChain(chain)
1253
+ const pool = getFlag(args, '--pool') || C.stablePool
1254
+ if (!recipient || !amount) fail(`usage: blackpool send <recipient> <amount> [--pool ${validPools.join('|')}] [--chain ${CHAIN_LIST}]`)
1255
+ if (!/^0x[a-fA-F0-9]{40}$/.test(recipient)) fail(`invalid recipient address: ${recipient}`)
1256
+ const n = Number(amount)
1257
+ if (isNaN(n) || n <= 0) fail(`invalid amount: ${amount}`)
1258
+ if (!validPools.includes(pool)) fail(`unknown pool "${pool}" on ${C.label} (supported: ${validPools.join(', ')})`)
1259
+ requireMinTx(n, chain, pool, 'send')
1260
+
1261
+ const wallet = getWallet()
1262
+ const sig = await ensureSignature(wallet)
1263
+
1264
+ log('')
1265
+ log(color(` paying ${amount} ${pool.toUpperCase()} privately on ${C.label}`, c.bold))
1266
+ log(color(` to ${recipient}`, c.dim))
1267
+ log('')
1268
+ info('building groth16 proof + broadcasting via relayer...')
1269
+ info('this can take ~30-90 seconds - you pay no gas.')
1270
+
1271
+ const res = await apiPost('/api/withdraw', {
1272
+ signature: sig, amount: n, recipient, pool, chain
1273
+ }, { timeoutMs: 180_000 })
1274
+
1275
+ log('')
1276
+ ok(color(`sent ${res.recipientReceives} ${res.symbol}`, c.green) + ` to ${color(recipient, c.cyan)}`)
1277
+ info(`fee: ${res.fee} ${res.symbol}`)
1278
+ info(`tx: ${explorerTx(chain, res.txHash)}`)
1279
+ log('')
1280
+ }
1281
+
1282
+ function getFlag(args, name) {
1283
+ const i = args.indexOf(name)
1284
+ if (i === -1) return null
1285
+ return args[i + 1]
1286
+ }
1287
+
1288
+ // === help ===
1289
+ function help() {
1290
+ log(`
1291
+ ${color('blackpool', c.bold)} ${color('v' + require('../package.json').version, c.dim)} - private payments for onchain agents
1292
+
1293
+ ${color('Usage:', c.bold)}
1294
+ blackpool <command> [args] [flags]
1295
+
1296
+ ${color('Commands:', c.bold)}
1297
+ login Store key (hidden prompt) + sign BLACKPOOL message.
1298
+ logout Remove stored key and cached signature.
1299
+ balance [--pool] [--chain] Show private + public balances (or one pool).
1300
+ --chain all shows every chain in one run.
1301
+ deposit <amount> [--pool] [--chain] Shield public funds into private pool.
1302
+ send <to> <amount> [--pool] [--chain] Send privately to an address (no gas from you).
1303
+ swap <amount> --from <pool> [--chain] Private same-chain swap via Uniswap V3.
1304
+ bridge <asset> <to> <amount> Bridge cross-chain privately via NEAR Intents
1305
+ (--from ${Object.keys(BRIDGE_ORIGINS).join('|')}).
1306
+ Run 'blackpool assets' for the destination list.
1307
+ assets List supported bridge destination assets.
1308
+ bridges List your cross-chain bridges + status (where a refund went).
1309
+ recover [--to <addr>] [--chain] Sweep any stranded funds from burner wallets.
1310
+ --chain all sweeps every chain in one run (idempotent).
1311
+ version Print CLI version.
1312
+ latest Check npm for a newer version.
1313
+ help Show this help.
1314
+
1315
+ ${color('Chains:', c.bold)}
1316
+ ${Object.entries(CHAINS).map(([k, v]) => ` --chain ${k.padEnd(10)} ${k === 'base' ? '(default) ' : ''}pools: ${Object.keys(v.pools).join(', ')}`).join('\n')}
1317
+
1318
+ ${color('Environment (optional, for agents/CI):', c.bold)}
1319
+ BLACKPOOL_KEY Agent private key (0x-hex). If set, skips interactive login.
1320
+ BLACKPOOL_API API base URL (default: ${API}).
1321
+
1322
+ ${color('Config:', c.bold)}
1323
+ ~/.blackpool/wallet.json Stored private key (chmod 600).
1324
+ ~/.blackpool/config.json Cached signatures per wallet (chmod 600).
1325
+
1326
+ ${color('Docs:', c.bold)}
1327
+ https://docs.blackpool.cash/agents
1328
+ `)
1329
+ }
1330
+
1331
+ // Check npm registry for latest published version.
1332
+ async function cmdLatest() {
1333
+ const local = require('../package.json').version
1334
+ info(`fetching latest @blackpool/cli from npm...`)
1335
+ try {
1336
+ const r = await fetch('https://registry.npmjs.org/@blackpool/cli/latest', { headers: { 'Accept': 'application/json' } })
1337
+ if (!r.ok) fail(`npm registry error (HTTP ${r.status})`)
1338
+ const remote = (await r.json()).version
1339
+ log('')
1340
+ log(` ${color('installed', c.dim)} ${color('v' + local, c.cyan)}`)
1341
+ log(` ${color('latest ', c.dim)} ${color('v' + remote, c.lime)}`)
1342
+ log('')
1343
+ if (local === remote) {
1344
+ ok('you are on the latest version')
1345
+ } else {
1346
+ info(color(`update available: npm i -g @blackpool/cli@${remote}`, c.yellow))
1347
+ }
1348
+ log('')
1349
+ } catch (e) { fail(`could not reach npm registry: ${e.message}`) }
1350
+ }
1351
+
1352
+ // === entrypoint ===
1353
+ async function main() {
1354
+ const [cmd, ...args] = process.argv.slice(2)
1355
+ try {
1356
+ switch (cmd) {
1357
+ case 'login': await cmdLogin(); break
1358
+ case 'logout': await cmdLogout(); break
1359
+ case 'balance': await cmdBalance(args); break
1360
+ case 'deposit': await cmdDeposit(args); break
1361
+ case 'send': await cmdSend(args); break
1362
+ case 'swap': await cmdSwap(args); break
1363
+ case 'bridge': await cmdBridge(args); break
1364
+ case 'assets': await cmdAssets(); break
1365
+ case 'bridges': await cmdBridges(); break
1366
+ case 'recover': await cmdRecover(args); break
1367
+ case 'version':
1368
+ case '-v':
1369
+ case '--version': {
1370
+ const pkg = require('../package.json')
1371
+ log(pkg.version)
1372
+ break
1373
+ }
1374
+ case 'latest':
1375
+ case '--latest': await cmdLatest(); break
1376
+ case 'help':
1377
+ case '-h':
1378
+ case '--help':
1379
+ case undefined: help(); break
1380
+ default: log(`unknown command: ${cmd}\n`); help(); process.exit(2)
1381
+ }
1382
+ } catch (e) {
1383
+ fail(cleanErr(e))
1384
+ }
1385
+ }
1386
+ main()