@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.
- package/README.md +128 -2
- package/bin/blackpool.js +1386 -0
- package/package.json +47 -4
package/bin/blackpool.js
ADDED
|
@@ -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()
|