@blackpool/cli 0.4.0 → 0.4.2
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/AGENTS.md +12 -2
- package/README.md +7 -5
- package/bin/blackpool.js +164 -26
- package/bin/mcp.js +3 -3
- package/lib/localApi.js +11 -1
- package/package.json +8 -1
package/AGENTS.md
CHANGED
|
@@ -134,8 +134,15 @@ Private payment to any EVM address on the same chain. `amount` is what leaves yo
|
|
|
134
134
|
```
|
|
135
135
|
To make the recipient receive exactly `X`, send `(X + flat) / (1 − 0.0035)` rounded up (see §5).
|
|
136
136
|
|
|
137
|
+
`amount` can be `max`: the whole private balance of `--pool`, of every pool on the chain when `--pool` is omitted, or of every pool on every chain with `--chain all`. A send spends at most two notes, so a balance spread over more notes goes out in several sends; a balance at or below twice its fee is left as dust.
|
|
138
|
+
```json
|
|
139
|
+
{"ok":true,"command":"send","recipient":"0x…","amount":"max","sends":[{"chain":"bsc","pool":"usdt","amount":"12.253307487806713664",
|
|
140
|
+
"received":"11.7104…","fee":"0.5428…","txHash":"0x…"}],"received":{"USDT":"11.7104…"},"dust":[],"errors":[]}
|
|
141
|
+
```
|
|
142
|
+
|
|
137
143
|
### `swap <amount> --from P [--chain C] [--slippage BPS]`
|
|
138
144
|
Private same-chain swap between the chain's native pool and stablecoin pool; the output is re-shielded into your private balance. `--slippage` in basis points, default `100` (1%). No gas needed. ~1–3 min.
|
|
145
|
+
`amount` can be `max`: the most one swap can move (its two largest notes). If the balance is spread over more notes, the output says what is left; run `swap max` again for the rest.
|
|
139
146
|
```json
|
|
140
147
|
{"ok":true,"command":"swap","chain":"base","from":"eth","amount":0.01,"received":"22.41",
|
|
141
148
|
"symbol":"USDC","txHash":"0x…","explorer":"https://basescan.org/tx/0x…","privateBalance":"36.375"}
|
|
@@ -144,6 +151,7 @@ If it fails half-way, funds sit on a registered burner: run `recover`.
|
|
|
144
151
|
|
|
145
152
|
### `bridge <dest-asset> <recipient> <amount> --from ORIGIN [--no-wait]`
|
|
146
153
|
Private cross-chain transfer through NEAR Intents. `ORIGIN` is the private pool paying (default `base_usdc`); `dest-asset` and recipient format are listed in §6. The withdrawal is private; the solver delivers on the destination chain. Without `--no-wait` the CLI polls up to 6 minutes.
|
|
154
|
+
`amount` can be `max`: the most one bridge can move from the `--from` pool (its two largest notes); the route minimum still applies.
|
|
147
155
|
```json
|
|
148
156
|
{"ok":true,"command":"bridge","from":"base_usdc","to":"sol_usdc","recipient":"7Gx4…kQ2m","amount":10,
|
|
149
157
|
"amountOutQuote":"8.91","withdrawTx":"0x…","depositAddress":"0x…","statusUrl":"https://…",
|
|
@@ -184,7 +192,7 @@ Sweep every registered burner that still holds funds to the agent wallet (or `--
|
|
|
184
192
|
| `base` (default) | `eth`, `usdc` | 0.001 ETH · 3 USDC | 0.001 ETH · 2 USDC | 0.00015 ETH · 1 USDC | Uniswap V3 |
|
|
185
193
|
| `robinhood` | `eth`, `usdg` | 0.001 ETH · 3 USDG | 0.001 ETH · 2 USDG | 0.00015 ETH · 0.5 USDG | Uniswap V3 |
|
|
186
194
|
| `arbitrum` | `eth`, `usdc` | 0.001 ETH · 3 USDC | 0.001 ETH · 2 USDC | 0.00015 ETH · 0.5 USDC | Uniswap V3 |
|
|
187
|
-
| `bsc` | `bnb`, `usdc`, `usdt` | 0.
|
|
195
|
+
| `bsc` | `bnb`, `usdc`, `usdt` | 0.01 BNB · 3 USDC/USDT | 0.001 BNB · 2 USDC/USDT | 0.001 BNB · 0.5 USDC/USDT | PancakeSwap V3 |
|
|
188
196
|
|
|
189
197
|
- **Fee** on every private spend = flat fee + 0.35% of the amount, deducted from the amount. Example: `send 10` USDC on Base → fee 1 + 0.035 = 1.035, recipient gets 8.965.
|
|
190
198
|
- A spend must be more than twice its own fee; the CLI enforces the minimums above before doing anything.
|
|
@@ -419,6 +427,8 @@ Use the hosted server to plan (fees, routes, quotes, status) and the local serve
|
|
|
419
427
|
| `insufficient` / `no unspent` | Private balance too low | `balance`, then `deposit` more. |
|
|
420
428
|
| `Withdrawal must be more than twice the fee` | Amount too close to the fee | Larger amount. |
|
|
421
429
|
| `Amount is too low for this route. Minimum is …` | Solver minimum for that bridge | Bridge at least that much. |
|
|
430
|
+
| `This route needs at least $… right now` | Temporary USD floor the solver network sets on a route (code `ROUTE_MINIMUM`; currently every route to or from BNB Chain) | Bridge at least that much or pick another destination. Nothing left the private balance. |
|
|
431
|
+
| `No route for this pair right now` | No solver quotes the pair (code `NO_ROUTE`) | Pick another token or chain. |
|
|
422
432
|
| `rate limited, slow down` | API limit (40 relay ops, 600 reads per minute per IP) | The CLI already waited and retried 4 times; pause a minute. |
|
|
423
433
|
| `simulation failed: …` | The relayer refused a transaction that would revert | Do not retry blindly; check `balance`; report the message. |
|
|
424
434
|
| `reshield would revert` / `swap output … too small` | Swap stopped after funds reached the burner | `recover`, then `deposit` again. |
|
|
@@ -443,7 +453,7 @@ Use the hosted server to plan (fees, routes, quotes, status) and the local serve
|
|
|
443
453
|
- `BLACKPOOL_KEY` never leaves the machine: it signs the sign-in message, deposit approvals and deposit transactions locally.
|
|
444
454
|
- The **sign-in signature** is cached in `$BLACKPOOL_HOME/config.json` (mode 600) and never sent anywhere: keys, note scanning and proofs are computed locally (CLI 0.4.0+), and the relayer receives finished proofs only. Anyone holding that signature can spend the agent's private balance. Treat the config directory like a key: never print it, never put it in prompts, logs or tool output.
|
|
445
455
|
- Only sign the Blackpool sign-in message on blackpool.cash or with this CLI.
|
|
446
|
-
- The contracts and circuits have not been externally audited; pools are upgradeable by
|
|
456
|
+
- The contracts and circuits have not been externally audited; pools are upgradeable and pausable by a 2-of-3 Safe (0x401027251176A8AFec701fE37412edeC45ec6FdB), which cannot move notes. Size balances accordingly. Details: https://blackpool.cash/docs#security
|
|
447
457
|
- Report vulnerabilities privately to hello@blackpool.cash (subject `security`).
|
|
448
458
|
|
|
449
459
|
---
|
package/README.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# @blackpool/cli
|
|
2
2
|
|
|
3
|
-
Private payments for onchain agents. One env var or one hidden prompt, then shield / send / swap / bridge with zero-knowledge privacy on **Base**, **Robinhood Chain**, **Arbitrum**, and **BNB Smart Chain**.
|
|
3
|
+
Private payments for onchain agents. One env var or one hidden prompt, then shield / send / swap / bridge with zero-knowledge privacy on **Base**, **Robinhood Chain**, **Arbitrum**, and **BNB Smart Chain**. Proofs are built on your machine, the same way the web app does it; the full agent guide is [AGENTS.md](https://blackpool.cash/AGENTS.md).
|
|
4
4
|
|
|
5
5
|
Every command takes an optional `--chain base|robinhood|arbitrum|bsc` flag (default `base`). On Base and Arbitrum the stablecoin is USDC; on Robinhood it's USDG; on BSC the stablecoins are USDC and USDT (Binance-Peg, 18 decimals) and the native asset is BNB.
|
|
6
6
|
|
|
@@ -57,20 +57,22 @@ blackpool deposit 0.05 --pool bnb --chain bsc # shield BNB (BSC native poo
|
|
|
57
57
|
blackpool send 0xRECIPIENT 2.5 # send 2.5 USDC privately (no gas from you)
|
|
58
58
|
blackpool send 0xRECIPIENT 0.01 --pool eth # send private ETH
|
|
59
59
|
blackpool send 0xRECIPIENT 2.5 --chain robinhood # send 2.5 USDG privately on Robinhood
|
|
60
|
+
blackpool send 0xRECIPIENT max --pool usdt --chain bsc # send the whole private USDT balance
|
|
61
|
+
blackpool send 0xRECIPIENT max --chain all # empty every private pool on every chain into one address
|
|
60
62
|
blackpool swap 0.001 --from eth # private same-chain swap ETH -> USDC (Uniswap V3)
|
|
61
63
|
blackpool swap 3 --from usdc # private same-chain swap USDC -> ETH
|
|
64
|
+
blackpool swap max --from eth --chain arbitrum # swap as much private ETH as one swap can move
|
|
62
65
|
blackpool bridge arb_usdc 0xRECIPIENT 2.5 # bridge cross-chain privately to Arbitrum (1Click)
|
|
63
66
|
blackpool bridge bsc_usdt 0xRECIPIENT 5 --from bsc_usdc # 1Click origin can be any shielded pool
|
|
64
|
-
blackpool bridge
|
|
65
|
-
blackpool bridge rbsc_bnb 0xRECIPIENT 0.01 --chain robinhood # router: Robinhood -> BSC
|
|
67
|
+
blackpool bridge base_eth 0xRECIPIENT max --from hood_eth # bridge as much as one bridge can move
|
|
66
68
|
blackpool assets # list all supported bridge destinations
|
|
67
|
-
blackpool recover # sweep any stranded funds from
|
|
69
|
+
blackpool recover # sweep any stranded funds from one-time burners
|
|
68
70
|
blackpool recover --to 0xADDR # sweep to a different destination
|
|
69
71
|
```
|
|
70
72
|
|
|
71
73
|
## How it works
|
|
72
74
|
|
|
73
|
-
Each command follows the pattern
|
|
75
|
+
Each command follows the pattern [AGENTS.md](https://blackpool.cash/AGENTS.md) documents.
|
|
74
76
|
|
|
75
77
|
**Login / balance / send / bridge** - you sign once; the CLI scans your notes and builds the Groth16 proof locally (the 20 MB proving key is downloaded once to `~/.blackpool/zk/`), then the relayer broadcasts it. You pay no gas. The relayer is compensated via the protocol fee (flat + 0.35% per withdraw / swap / bridge).
|
|
76
78
|
|
package/bin/blackpool.js
CHANGED
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
* Usage:
|
|
6
6
|
* blackpool login -- sign BLACKPOOL message, cache signature for this wallet
|
|
7
7
|
* blackpool balance [--pool p] -- print private balance
|
|
8
|
-
* blackpool send <to> <amount> -- send privately to an address
|
|
8
|
+
* blackpool send <to> <amount> -- send privately to an address (amount "max" = the whole private balance)
|
|
9
9
|
*
|
|
10
10
|
* Auth: BLACKPOOL_KEY env var (agent private key, hex). No mnemonic support in MVP.
|
|
11
11
|
* Config: ~/.blackpool/config.json stores cached signatures (per address).
|
|
@@ -333,7 +333,7 @@ const CHAINS = {
|
|
|
333
333
|
nativePool: 'eth',
|
|
334
334
|
// pool key -> { symbol, decimals, native, minDeposit }. minDeposit mirrors the frontend
|
|
335
335
|
// POOL_META floor and must stay in step with it: every stablecoin pool is a flat 3 units (~$3)
|
|
336
|
-
// for a uniform entry point across chains, native pools are 0.001 ETH / 0.
|
|
336
|
+
// for a uniform entry point across chains, native pools are 0.001 ETH / 0.01 BNB. These are an
|
|
337
337
|
// operator choice, not a fee-ratio rule -- see the note in frontend POOL_META for what the
|
|
338
338
|
// withdraw fee costs at each floor, including the base:usdc fee*2 edge at a 2 USDC withdraw.
|
|
339
339
|
pools: { eth: { symbol: 'ETH', decimals: 18, native: true, minDeposit: 0.001 }, usdc: { symbol: 'USDC', decimals: 6, native: false, minDeposit: 3 } },
|
|
@@ -372,7 +372,7 @@ const CHAINS = {
|
|
|
372
372
|
stablePool: 'usdt',
|
|
373
373
|
nativePool: 'bnb',
|
|
374
374
|
pools: {
|
|
375
|
-
bnb: { symbol: 'BNB', decimals: 18, native: true, minDeposit: 0.
|
|
375
|
+
bnb: { symbol: 'BNB', decimals: 18, native: true, minDeposit: 0.01 },
|
|
376
376
|
usdc: { symbol: 'USDC', decimals: 18, native: false, minDeposit: 3 },
|
|
377
377
|
usdt: { symbol: 'USDT', decimals: 18, native: false, minDeposit: 3 },
|
|
378
378
|
},
|
|
@@ -454,6 +454,10 @@ async function broadcastAndWait(signer, provider, txRequest, { label = 'transact
|
|
|
454
454
|
if (i < 5) await new Promise(r => setTimeout(r, 700 * (i + 1)))
|
|
455
455
|
}
|
|
456
456
|
}
|
|
457
|
+
if (!sent) {
|
|
458
|
+
// The node may have accepted the tx and still answered with an error: look for it before giving up.
|
|
459
|
+
try { if (await withRpcRetry(() => provider.getTransaction(txHash), { label: `${label} (lookup)` })) sent = true } catch {}
|
|
460
|
+
}
|
|
457
461
|
if (!sent) throw new Error(`${label}: could not broadcast after repeated connection drops`)
|
|
458
462
|
|
|
459
463
|
// Poll for the receipt ourselves (retrying connection errors) instead of tx.wait(), which throws
|
|
@@ -486,13 +490,26 @@ async function chainGasOverrides(provider, chain) {
|
|
|
486
490
|
return { gasPrice: gp }
|
|
487
491
|
}
|
|
488
492
|
const cap = ethers.utils.parseUnits(CHAINS[chain].gasCapGwei, 'gwei')
|
|
493
|
+
const minPrio = ethers.utils.parseUnits('0.001', 'gwei')
|
|
494
|
+
// 2 x baseFee + tip, like wallets do. Not getFeeData(): ethers v5 adds a 1.5 gwei tip, which then hits the cap
|
|
495
|
+
// (0.5 gwei on Arbitrum, ~50x the real price) and makes the node reject value + gas x maxFee as unaffordable.
|
|
489
496
|
let netMax
|
|
490
|
-
try { const
|
|
497
|
+
try { const b = await provider.getBlock('latest'); if (b && b.baseFeePerGas) netMax = b.baseFeePerGas.mul(2).add(minPrio) } catch {}
|
|
498
|
+
if (!netMax) { try { const fd = await provider.getFeeData(); netMax = fd.maxFeePerGas } catch {} }
|
|
491
499
|
const maxFeePerGas = netMax && netMax.lt(cap) ? netMax : cap
|
|
492
|
-
const minPrio = ethers.utils.parseUnits('0.001', 'gwei')
|
|
493
500
|
const maxPriorityFeePerGas = maxFeePerGas.lt(minPrio) ? maxFeePerGas : minPrio
|
|
494
501
|
return { maxFeePerGas, maxPriorityFeePerGas }
|
|
495
502
|
}
|
|
503
|
+
// Burner txs inside one swap: 2 x baseFee + tip (headroom for the minute or two the swap takes), capped.
|
|
504
|
+
async function burnerFees(provider, chain) {
|
|
505
|
+
if (CHAINS[chain].legacyGas) return chainGasOverrides(provider, chain)
|
|
506
|
+
const cap = ethers.utils.parseUnits(CHAINS[chain].gasCapGwei, 'gwei')
|
|
507
|
+
const tip = ethers.utils.parseUnits('0.001', 'gwei')
|
|
508
|
+
let want
|
|
509
|
+
try { const b = await provider.getBlock('latest'); if (b && b.baseFeePerGas) want = b.baseFeePerGas.mul(2).add(tip) } catch {}
|
|
510
|
+
const maxFeePerGas = want && want.lt(cap) ? want : cap
|
|
511
|
+
return { maxFeePerGas, maxPriorityFeePerGas: maxFeePerGas.lt(tip) ? maxFeePerGas : tip }
|
|
512
|
+
}
|
|
496
513
|
// Sync version (fixed chain cap, no network fetch) — for burner txs where we set an explicit gasLimit
|
|
497
514
|
// and just need a chain-realistic price. Replaces the old Base-only baseGasOverrides().
|
|
498
515
|
function chainGasOverridesSync(chain) {
|
|
@@ -680,7 +697,7 @@ async function cmdDeposit(args) {
|
|
|
680
697
|
info('preparing deposit (the proof is generated on this machine)...')
|
|
681
698
|
|
|
682
699
|
const prep = await apiPost('/api/deposit/prepare', {
|
|
683
|
-
signature: sig, address: wallet.address, amount:
|
|
700
|
+
signature: sig, address: wallet.address, amount: String(amount), pool, chain,
|
|
684
701
|
}, { timeoutMs: 180_000 })
|
|
685
702
|
|
|
686
703
|
// ERC pool with insufficient allowance -> approve first
|
|
@@ -828,6 +845,30 @@ async function waitTokenBalance(provider, tokenAddr, addr, min, retries = 60) {
|
|
|
828
845
|
throw new Error(`token balance never reached on ${addr}`)
|
|
829
846
|
}
|
|
830
847
|
// Poll until ETH balance >= minimum. Mirrors frontend e9().
|
|
848
|
+
// After a swap's reshield: send the burner's unused gas back to the relayer that advanced it, so the burner ends
|
|
849
|
+
// empty. Keeps only the cost of this transfer (+30%, plus an L1 data-fee cushion on Base). Never fails the swap.
|
|
850
|
+
async function returnUnusedGas(burner, provider, chain) {
|
|
851
|
+
try {
|
|
852
|
+
const cfg = await apiGet('/api/zk/config')
|
|
853
|
+
const to = cfg && cfg.relayer
|
|
854
|
+
if (!to || !/^0x[a-fA-F0-9]{40}$/.test(to)) return
|
|
855
|
+
const fees = await burnerFees(provider, chain)
|
|
856
|
+
const fee = fees.maxFeePerGas || fees.gasPrice
|
|
857
|
+
const bal = await provider.getBalance(burner.address)
|
|
858
|
+
let gas = ethers.BigNumber.from(21_000)
|
|
859
|
+
try { gas = await provider.estimateGas({ from: burner.address, to, value: 1 }) } catch {}
|
|
860
|
+
const gasLimit = gas.mul(13).div(10)
|
|
861
|
+
const l1Cushion = chain === 'base' ? ethers.utils.parseUnits('2000', 'gwei') : ethers.BigNumber.from(0) // OP-stack L1 data fee
|
|
862
|
+
const keep = gasLimit.mul(fee).add(l1Cushion)
|
|
863
|
+
if (bal.lte(keep.mul(2))) return // nothing worth moving
|
|
864
|
+
const value = bal.sub(keep)
|
|
865
|
+
await broadcastAndWait(burner, provider, { to, value, gasLimit, ...fees }, { label: 'return unused gas' })
|
|
866
|
+
info(`returned ${ethers.utils.formatEther(value)} unused gas to the relayer (burner is empty)`)
|
|
867
|
+
} catch (e) {
|
|
868
|
+
info(`could not return the unused gas (${cleanErr(e)}); \`blackpool recover\` can sweep it later`)
|
|
869
|
+
}
|
|
870
|
+
}
|
|
871
|
+
|
|
831
872
|
async function waitEthBalance(provider, addr, min, label = 'eth') {
|
|
832
873
|
for (let i = 0; i < 80; i++) {
|
|
833
874
|
const b = await provider.getBalance(addr)
|
|
@@ -868,6 +909,11 @@ const BRIDGE_ORIGINS = {
|
|
|
868
909
|
// blackpool bridge <destAssetKey> <recipient> <amount> [--from ...] [--chain ...]
|
|
869
910
|
// Every destination uses the NEAR Intents 1Click flow (/api/swap + poll) — Robinhood included.
|
|
870
911
|
async function cmdBridge(args) {
|
|
912
|
+
if (/^(max|all)$/i.test(String(args[2] || ''))) {
|
|
913
|
+
const o = BRIDGE_ORIGINS[getFlag(args, '--from') || 'base_usdc']
|
|
914
|
+
if (!o) fail(`unknown --from "${getFlag(args, '--from')}" (1Click origins: ${Object.keys(BRIDGE_ORIGINS).join(', ')})`)
|
|
915
|
+
args = [...args]; args[2] = await resolveMaxAmount(o.chain, o.pool, 'bridge')
|
|
916
|
+
}
|
|
871
917
|
const toAsset = args[0]
|
|
872
918
|
const recipient = args[1]
|
|
873
919
|
const amount = args[2]
|
|
@@ -910,7 +956,7 @@ async function cmdBridge(args) {
|
|
|
910
956
|
info('quote + groth16 proof + private withdraw to solver... can take ~1-2 min')
|
|
911
957
|
|
|
912
958
|
const res = await apiPost('/api/swap', {
|
|
913
|
-
signature: sig, fromAsset, toAsset, amount:
|
|
959
|
+
signature: sig, fromAsset, toAsset, amount: String(amount), recipient,
|
|
914
960
|
refundTo: wallet.address, // Solana/XRP recipients: solver refunds (origin chain) come back to this wallet
|
|
915
961
|
}, { timeoutMs: 180_000 })
|
|
916
962
|
|
|
@@ -1074,6 +1120,11 @@ async function cmdRecover(args) {
|
|
|
1074
1120
|
}
|
|
1075
1121
|
|
|
1076
1122
|
async function cmdSwap(args) {
|
|
1123
|
+
if (/^(max|all)$/i.test(String(args[0] || ''))) {
|
|
1124
|
+
const fromPool = getFlag(args, '--from')
|
|
1125
|
+
if (!fromPool) fail('usage: blackpool swap max --from <pool> [--chain C]', 2)
|
|
1126
|
+
args = [...args]; args[0] = await resolveMaxAmount(resolveChain(args), fromPool, 'swap')
|
|
1127
|
+
}
|
|
1077
1128
|
const chain = resolveChain(args)
|
|
1078
1129
|
const C = CHAINS[chain]
|
|
1079
1130
|
const amount = args[0]
|
|
@@ -1093,7 +1144,8 @@ async function cmdSwap(args) {
|
|
|
1093
1144
|
// per-chain Uniswap V3 + token addresses (registry)
|
|
1094
1145
|
const U = { router: C.uni.router, quoter: C.uni.quoter, weth: C.uni.weth, stable: C.uni.stable, fee: C.uni.fee }
|
|
1095
1146
|
const stableDecimals = C.pools[stablePool].decimals
|
|
1096
|
-
|
|
1147
|
+
let liveFees = null // set before the first burner tx (see reserve sizing below)
|
|
1148
|
+
const gasOv = (limit) => ({ gasLimit: limit, ...(liveFees || chainGasOverridesSync(chain)) })
|
|
1097
1149
|
|
|
1098
1150
|
const wallet = getWallet()
|
|
1099
1151
|
const sig = await ensureSignature(wallet)
|
|
@@ -1117,7 +1169,7 @@ async function cmdSwap(args) {
|
|
|
1117
1169
|
const wp = await apiPost('/api/swap-private', {
|
|
1118
1170
|
signature: sig, chain, pool: from,
|
|
1119
1171
|
fromAsset: chain === 'base' ? (isEthOrigin ? 'base_eth' : 'base_usdc') : undefined,
|
|
1120
|
-
amount:
|
|
1172
|
+
amount: String(amount), burnerAddress: burner.address,
|
|
1121
1173
|
}, { timeoutMs: 180_000 })
|
|
1122
1174
|
info(`withdraw tx: ${explorerTx(chain, wp.txHash)}`)
|
|
1123
1175
|
|
|
@@ -1125,8 +1177,12 @@ async function cmdSwap(args) {
|
|
|
1125
1177
|
// stablecoin dest, or a single ~1.6M-gas deposit for an ETH dest — priced at the chain's gas cap
|
|
1126
1178
|
// (never ethers' 1.5 gwei default). Under-reserving here is exactly what stranded ETH→USDG on
|
|
1127
1179
|
// Robinhood, so reserve generously (+50%).
|
|
1128
|
-
|
|
1129
|
-
|
|
1180
|
+
// Priced at the LIVE fee (3 x baseFee + tip, never above the chain cap) rather than the cap itself: on Arbitrum the
|
|
1181
|
+
// cap is ~50x the real price, which left most of a small swap sitting on the burner as unused gas.
|
|
1182
|
+
liveFees = await burnerFees(provider, chain)
|
|
1183
|
+
const feePerGas = liveFees.maxFeePerGas || liveFees.gasPrice
|
|
1184
|
+
const reshieldGasNeed = ethers.BigNumber.from(1_680_000).mul(feePerGas) // approve + ZK deposit, advanced by the relayer
|
|
1185
|
+
const swapGasReserve = ethers.BigNumber.from(320_000).mul(feePerGas).mul(15).div(10) // the swap tx itself
|
|
1130
1186
|
|
|
1131
1187
|
// === Step 3: wait funds on burner + top-up gas for stablecoin origin ===
|
|
1132
1188
|
let amountIn
|
|
@@ -1134,9 +1190,9 @@ async function cmdSwap(args) {
|
|
|
1134
1190
|
info('waiting for ETH on burner...')
|
|
1135
1191
|
await waitEthBalance(provider, burner.address, ethers.utils.parseEther('0.0001'), 'burner funding')
|
|
1136
1192
|
const bal = await provider.getBalance(burner.address)
|
|
1137
|
-
//
|
|
1138
|
-
if (bal.lte(
|
|
1139
|
-
amountIn = bal.sub(
|
|
1193
|
+
// Swap everything except the swap tx's own gas; the relayer advances the reshield gas afterwards.
|
|
1194
|
+
if (bal.lte(swapGasReserve)) fail('burner balance too low after fees')
|
|
1195
|
+
amountIn = bal.sub(swapGasReserve)
|
|
1140
1196
|
} else {
|
|
1141
1197
|
info(`waiting for ${stableSym} on burner...`)
|
|
1142
1198
|
amountIn = await waitTokenBalance(provider, U.stable, burner.address, ethers.BigNumber.from(1), 200)
|
|
@@ -1211,17 +1267,19 @@ async function cmdSwap(args) {
|
|
|
1211
1267
|
if (isEthOrigin) {
|
|
1212
1268
|
// Stablecoin destination: reshield the full stable balance (gas paid in the burner's reserved ETH).
|
|
1213
1269
|
reshieldAmount = await waitTokenBalance(provider, U.stable, burner.address, ethers.BigNumber.from(1), 60)
|
|
1214
|
-
//
|
|
1215
|
-
|
|
1216
|
-
|
|
1270
|
+
// The relayer advances the approve + deposit gas (the burner now holds the stablecoin, which is what the
|
|
1271
|
+
// top-up endpoint checks). Whatever is not used goes back to the relayer after the reshield.
|
|
1272
|
+
if ((await provider.getBalance(burner.address)).lt(reshieldGasNeed)) {
|
|
1273
|
+
info('relayer advancing reshield gas...')
|
|
1217
1274
|
const g = await apiPost('/api/burner-gas', { signature: sig, burnerAddress: burner.address, chain, txCount: 2 }, { timeoutMs: 60_000 })
|
|
1218
|
-
if (g.success)
|
|
1275
|
+
if (!g.success) fail('reshield gas top-up failed; the swapped funds are safe on the burner, run `blackpool recover`')
|
|
1276
|
+
await waitEthBalance(provider, burner.address, reshieldGasNeed, 'reshield gas')
|
|
1219
1277
|
}
|
|
1220
1278
|
} else {
|
|
1221
1279
|
// ETH destination: the deposit tx is payable (value = amount) AND pays its own gas from the same
|
|
1222
|
-
// balance. Reserve the deposit gas at the
|
|
1280
|
+
// balance. Reserve the deposit gas at the live fee.
|
|
1223
1281
|
const full = await waitEthBalance(provider, burner.address, ethers.utils.parseEther('0.0000001'), 'swap output')
|
|
1224
|
-
const gasReserve =
|
|
1282
|
+
const gasReserve = feePerGas.mul(1_600_000).mul(12).div(10) // live fee, +20%
|
|
1225
1283
|
if (full.lte(gasReserve)) fail(`swap output ${ethers.utils.formatEther(full)} ETH too small to reshield after gas`)
|
|
1226
1284
|
reshieldAmount = full.sub(gasReserve)
|
|
1227
1285
|
}
|
|
@@ -1270,17 +1328,20 @@ async function cmdSwap(args) {
|
|
|
1270
1328
|
try { estGas = await provider.estimateGas({ to: prep.to, data: prep.data, value: reshieldVal, from: burner.address }) }
|
|
1271
1329
|
catch { estGas = ethers.BigNumber.from(1_600_000) } // fallback
|
|
1272
1330
|
const gasLimit = estGas.mul(12).div(10) // +20% headroom
|
|
1273
|
-
//
|
|
1274
|
-
let maxFee =
|
|
1331
|
+
// The live fee OR what fits in burner ETH, whichever is lower.
|
|
1332
|
+
let maxFee = feePerGas
|
|
1275
1333
|
const maxAffordable = burnerEth.div(gasLimit)
|
|
1276
1334
|
if (maxFee.gt(maxAffordable)) maxFee = maxAffordable.mul(9).div(10) // 90% of ceiling
|
|
1335
|
+
// BNB Chain enforces a 0.05 gwei minimum on the effective price; an EIP-1559 tip of maxFee/10 falls below it.
|
|
1336
|
+
const feeFields = C.legacyGas ? { gasPrice: maxFee } : { maxFeePerGas: maxFee, maxPriorityFeePerGas: maxFee.div(10) }
|
|
1277
1337
|
const rtx = await broadcastAndWait(burner, provider, {
|
|
1278
1338
|
to: prep.to, data: prep.data, value: reshieldVal,
|
|
1279
1339
|
gasLimit,
|
|
1280
|
-
|
|
1281
|
-
maxPriorityFeePerGas: maxFee.div(10),
|
|
1340
|
+
...feeFields,
|
|
1282
1341
|
}, { label: 'reshield' })
|
|
1283
1342
|
|
|
1343
|
+
await returnUnusedGas(burner, provider, chain)
|
|
1344
|
+
|
|
1284
1345
|
log('')
|
|
1285
1346
|
ok(color(`swap complete: ${amount} ${from.toUpperCase()} -> ${reshieldFmt} ${toSym} (private) on ${C.label}`, c.green))
|
|
1286
1347
|
info(`new private balance: ${prep.newBalance} ${prep.symbol}`)
|
|
@@ -1336,6 +1397,7 @@ async function cmdAssets() {
|
|
|
1336
1397
|
}
|
|
1337
1398
|
|
|
1338
1399
|
async function cmdSend(args) {
|
|
1400
|
+
if (/^(max|all)$/i.test(String(args[1] || ''))) return cmdSendMax(args)
|
|
1339
1401
|
const chain = resolveChain(args)
|
|
1340
1402
|
const C = CHAINS[chain]
|
|
1341
1403
|
const recipient = args[0]
|
|
@@ -1360,7 +1422,7 @@ async function cmdSend(args) {
|
|
|
1360
1422
|
info('this can take ~30-90 seconds - you pay no gas.')
|
|
1361
1423
|
|
|
1362
1424
|
const res = await apiPost('/api/withdraw', {
|
|
1363
|
-
signature: sig, amount:
|
|
1425
|
+
signature: sig, amount: String(amount), recipient, pool, chain
|
|
1364
1426
|
}, { timeoutMs: 180_000 })
|
|
1365
1427
|
|
|
1366
1428
|
log('')
|
|
@@ -1371,6 +1433,77 @@ async function cmdSend(args) {
|
|
|
1371
1433
|
result({ chain, pool, recipient, amount: n, received: res.recipientReceives, fee: res.fee, symbol: res.symbol, txHash: res.txHash, explorer: explorerTx(chain, res.txHash) })
|
|
1372
1434
|
}
|
|
1373
1435
|
|
|
1436
|
+
// "max" for swap/bridge: the most one operation can spend (its two largest notes), as an exact decimal string.
|
|
1437
|
+
async function resolveMaxAmount(chain, pool, flow) {
|
|
1438
|
+
if (!CHAINS[chain] || !CHAINS[chain].pools[pool]) fail(`unknown pool "${pool}" on ${CHAINS[chain] ? CHAINS[chain].label : chain}`)
|
|
1439
|
+
const P = CHAINS[chain].pools[pool]
|
|
1440
|
+
const sig = await ensureSignature(getWallet())
|
|
1441
|
+
const bal = await apiPost('/api/balance', { signature: sig, pool, chain }, { timeoutMs: 120_000 })
|
|
1442
|
+
const notes = (bal.utxos || []).map((u) => ethers.utils.parseUnits(String(u), P.decimals)).sort((a, b) => (b.gt(a) ? 1 : b.lt(a) ? -1 : 0))
|
|
1443
|
+
if (!notes.length) fail(`no private ${P.symbol} on ${CHAINS[chain].label}`)
|
|
1444
|
+
const spend = notes.slice(0, 2).reduce((s, n) => s.add(n), ethers.BigNumber.from(0))
|
|
1445
|
+
const total = notes.reduce((s, n) => s.add(n), ethers.BigNumber.from(0))
|
|
1446
|
+
const spendStr = ethers.utils.formatUnits(spend, P.decimals)
|
|
1447
|
+
if (Number(spendStr) < minTxFor(chain, pool).min) fail(`private ${P.symbol} on ${CHAINS[chain].label} is ${ethers.utils.formatUnits(total, P.decimals)}, below the minimum ${flow}`)
|
|
1448
|
+
info(`max: ${spendStr} ${P.symbol}` + (notes.length > 2 ? ` (of ${ethers.utils.formatUnits(total, P.decimals)} across ${notes.length} notes; run ${flow} max again for the rest)` : ''))
|
|
1449
|
+
return spendStr
|
|
1450
|
+
}
|
|
1451
|
+
|
|
1452
|
+
// send <recipient> max: empty one pool, every pool of a chain, or every pool everywhere into one address.
|
|
1453
|
+
async function cmdSendMax(args) {
|
|
1454
|
+
const recipient = args[0]
|
|
1455
|
+
if (!/^0x[a-fA-F0-9]{40}$/.test(String(recipient || ''))) fail(`invalid recipient address: ${recipient}`, 2)
|
|
1456
|
+
const rawChain = (getFlag(args, '--chain') || 'base').toLowerCase()
|
|
1457
|
+
const chains = rawChain === 'all' ? Object.keys(CHAINS) : [resolveChain(args)]
|
|
1458
|
+
const poolFlag = (getFlag(args, '--pool') || 'all').toLowerCase()
|
|
1459
|
+
if (rawChain === 'all' && poolFlag !== 'all') fail('with --chain all, use --pool all (pool names differ per chain)', 2)
|
|
1460
|
+
if (poolFlag !== 'all' && !poolsForChain(chains[0]).includes(poolFlag)) fail(`unknown pool "${poolFlag}" on ${CHAINS[chains[0]].label} (supported: ${poolsForChain(chains[0]).join(', ')})`)
|
|
1461
|
+
|
|
1462
|
+
const wallet = getWallet()
|
|
1463
|
+
const sig = await ensureSignature(wallet)
|
|
1464
|
+
const sends = [], dust = [], errors = []
|
|
1465
|
+
log('')
|
|
1466
|
+
log(color(` sending the whole private balance to ${recipient}`, c.bold))
|
|
1467
|
+
for (const chain of chains) {
|
|
1468
|
+
const pools = poolFlag === 'all' ? poolsForChain(chain) : [poolFlag]
|
|
1469
|
+
for (const pool of pools) {
|
|
1470
|
+
const P = CHAINS[chain].pools[pool]
|
|
1471
|
+
const tag = `${CHAINS[chain].label} ${P.symbol}`
|
|
1472
|
+
for (let round = 0; round < 12; round++) {
|
|
1473
|
+
let bal
|
|
1474
|
+
try { bal = await apiPost('/api/balance', { signature: sig, pool, chain }, { timeoutMs: 120_000 }) }
|
|
1475
|
+
catch (e) { errors.push({ chain, pool, error: cleanErr(e) }); info(`${tag}: could not read the balance (${cleanErr(e)})`); break }
|
|
1476
|
+
const notes = (bal.utxos || []).map((u) => ethers.utils.parseUnits(String(u), P.decimals)).sort((a, b) => (b.gt(a) ? 1 : b.lt(a) ? -1 : 0))
|
|
1477
|
+
if (!notes.length) { if (round === 0) info(`${tag}: empty`); break }
|
|
1478
|
+
// one send spends at most the two largest notes
|
|
1479
|
+
const spend = notes.slice(0, 2).reduce((s, n) => s.add(n), ethers.BigNumber.from(0))
|
|
1480
|
+
const spendStr = ethers.utils.formatUnits(spend, P.decimals)
|
|
1481
|
+
const total = ethers.utils.formatUnits(notes.reduce((s, n) => s.add(n), ethers.BigNumber.from(0)), P.decimals)
|
|
1482
|
+
if (Number(spendStr) < minTxFor(chain, pool).min) { dust.push({ chain, pool, symbol: P.symbol, balance: total }); info(`${tag}: ${total} left as dust (under the minimum send)`); break }
|
|
1483
|
+
info(`${tag}: sending ${spendStr}${notes.length > 2 ? ` (part ${round + 1}, ${notes.length} notes)` : ''}...`)
|
|
1484
|
+
try {
|
|
1485
|
+
const res = await apiPost('/api/withdraw', { signature: sig, amount: spendStr, recipient, pool, chain }, { timeoutMs: 180_000 })
|
|
1486
|
+
sends.push({ chain, pool, symbol: res.symbol, amount: spendStr, received: res.recipientReceives, fee: res.fee, txHash: res.txHash, explorer: explorerTx(chain, res.txHash) })
|
|
1487
|
+
ok(`${tag}: sent ${spendStr}, recipient gets ${res.recipientReceives} ${res.symbol} ${color(explorerTx(chain, res.txHash), c.dim)}`)
|
|
1488
|
+
} catch (e) {
|
|
1489
|
+
const m = cleanErr(e)
|
|
1490
|
+
if (/twice the fee/i.test(m)) { dust.push({ chain, pool, symbol: P.symbol, balance: total }); info(`${tag}: ${total} left as dust (at or below twice the fee)`) }
|
|
1491
|
+
else { errors.push({ chain, pool, error: m }); info(`${tag}: not sent (${m})`) }
|
|
1492
|
+
break
|
|
1493
|
+
}
|
|
1494
|
+
}
|
|
1495
|
+
}
|
|
1496
|
+
}
|
|
1497
|
+
const received = {}
|
|
1498
|
+
for (const s of sends) received[s.symbol] = (Number(received[s.symbol] || 0) + Number(s.received)).toString()
|
|
1499
|
+
log('')
|
|
1500
|
+
if (sends.length) ok(color(`${sends.length} send(s) done`, c.green) + (dust.length ? `, ${dust.length} pool(s) left with dust` : ''))
|
|
1501
|
+
else info('nothing to send')
|
|
1502
|
+
log('')
|
|
1503
|
+
result({ recipient, amount: 'max', sends, received, dust, errors })
|
|
1504
|
+
if (errors.length && !sends.length) fail(errors.map((e) => `${e.chain}/${e.pool}: ${e.error}`).join('; '))
|
|
1505
|
+
}
|
|
1506
|
+
|
|
1374
1507
|
function getFlag(args, name) {
|
|
1375
1508
|
const i = args.indexOf(name)
|
|
1376
1509
|
if (i === -1) return null
|
|
@@ -1393,6 +1526,8 @@ ${color('Commands:', c.bold)}
|
|
|
1393
1526
|
--chain all shows every chain in one run.
|
|
1394
1527
|
deposit <amount> [--pool] [--chain] Shield public funds into private pool.
|
|
1395
1528
|
send <to> <amount> [--pool] [--chain] Send privately to an address (no gas from you).
|
|
1529
|
+
amount "max": the whole private balance (no --pool = every pool; --chain all).
|
|
1530
|
+
swap/bridge also take "max" (the most one operation can move).
|
|
1396
1531
|
swap <amount> --from <pool> [--chain] Private same-chain swap via Uniswap V3.
|
|
1397
1532
|
bridge <asset> <to> <amount> Bridge cross-chain privately via NEAR Intents (--no-wait: return once submitted)
|
|
1398
1533
|
(--from ${Object.keys(BRIDGE_ORIGINS).join('|')}).
|
|
@@ -1485,7 +1620,10 @@ async function main() {
|
|
|
1485
1620
|
case undefined: help(); break
|
|
1486
1621
|
default: if (!JSON_MODE) { log(`unknown command: ${cmd}\n`); help() } fail(`unknown command: ${cmd} (run blackpool help)`, 2)
|
|
1487
1622
|
}
|
|
1488
|
-
|
|
1623
|
+
// Exit explicitly: the prover leaves worker threads alive, which would keep the process running after a
|
|
1624
|
+
// deposit, send, swap or bridge has already finished.
|
|
1625
|
+
if (JSON_MODE) process.stdout.write(JSON.stringify({ ok: true, command: CMD, ...RESULT }) + '\n', () => process.exit(0))
|
|
1626
|
+
else process.stdout.write('', () => process.exit(0)) // flush piped output first
|
|
1489
1627
|
} catch (e) {
|
|
1490
1628
|
fail(cleanErr(e))
|
|
1491
1629
|
}
|
package/bin/mcp.js
CHANGED
|
@@ -20,7 +20,7 @@ const SUPPORTED = ['2025-06-18', '2025-03-26', '2024-11-05']
|
|
|
20
20
|
|
|
21
21
|
const CHAIN = { type: 'string', enum: ['base', 'robinhood', 'arbitrum', 'bsc'], description: 'Chain. Default base.' }
|
|
22
22
|
const POOL = { type: 'string', description: 'Pool on that chain: eth|usdc (base, arbitrum), eth|usdg (robinhood), bnb|usdc|usdt (bsc). Default: the chain\'s stablecoin pool.' }
|
|
23
|
-
const AMOUNT = { type: 'string', pattern: '^[0-9]+(\\.[0-9]+)
|
|
23
|
+
const AMOUNT = { type: 'string', pattern: '^([0-9]+(\\.[0-9]+)?|max)$', description: 'Decimal amount in token units, e.g. "10" or "0.25", or "max" (send: the whole pool balance; swap/bridge: the most one operation can move). Never wei.' }
|
|
24
24
|
|
|
25
25
|
const TOOLS = [
|
|
26
26
|
{
|
|
@@ -43,8 +43,8 @@ const TOOLS = [
|
|
|
43
43
|
},
|
|
44
44
|
{
|
|
45
45
|
name: 'blackpool_send', title: 'Private send', destructive: true,
|
|
46
|
-
description: 'Pay any EVM address privately from the private balance. The relayer pays gas; the recipient receives amount minus the fee (flat + 0.35%). Takes 30-90 s. Never retry after a timeout without checking blackpool_balance.',
|
|
47
|
-
inputSchema: { type: 'object', properties: { recipient: { type: 'string', pattern: '^0x[0-9a-fA-F]{40}$' }, amount:
|
|
46
|
+
description: 'Pay any EVM address privately from the private balance. The relayer pays gas; the recipient receives amount minus the fee (flat + 0.35%). amount "max" sends the whole balance of the pool (or of every pool when pool is omitted), splitting across several sends when needed and leaving dust untouched. Takes 30-90 s per send. Never retry after a timeout without checking blackpool_balance.',
|
|
47
|
+
inputSchema: { type: 'object', properties: { recipient: { type: 'string', pattern: '^0x[0-9a-fA-F]{40}$' }, amount: { type: 'string', pattern: '^([0-9]+(\\.[0-9]+)?|max)$', description: 'Decimal amount in token units, e.g. "10" or "0.25", or "max" for the whole private balance. Never wei.' }, pool: POOL, chain: CHAIN }, required: ['recipient', 'amount'], additionalProperties: false },
|
|
48
48
|
args: (a) => ['send', a.recipient, String(a.amount), ...opt('--pool', a.pool), ...opt('--chain', a.chain)],
|
|
49
49
|
},
|
|
50
50
|
{
|
package/lib/localApi.js
CHANGED
|
@@ -48,9 +48,19 @@ function createLocalApi({ request, loadCircuit, snarkjs, singleThread = false, o
|
|
|
48
48
|
const units = (P, v) => BigInt(U.parseUnits(String(v), P.decimals).toString())
|
|
49
49
|
const fmt = (P, v) => U.formatUnits(v.toString(), P.decimals)
|
|
50
50
|
|
|
51
|
+
// A snapshot behind the chain (the API reports the pool's on-chain nextIndex) would show old notes right after a
|
|
52
|
+
// transaction; wait for it to catch up (normally a few seconds) before using it.
|
|
53
|
+
async function poolSnapshot(P, timeoutMs = 45_000) {
|
|
54
|
+
const until = Date.now() + timeoutMs
|
|
55
|
+
for (;;) {
|
|
56
|
+
const d = await request('GET', `/api/zk/pool?chain=${P.chain}&pool=${P.pool}`)
|
|
57
|
+
if (d.chainNextIndex == null || d.nextIndex >= d.chainNextIndex || Date.now() > until) return d
|
|
58
|
+
await new Promise((r) => setTimeout(r, 2500))
|
|
59
|
+
}
|
|
60
|
+
}
|
|
51
61
|
async function account(keys, P) {
|
|
52
62
|
onProgress({ stage: 'scan', pool: P.key })
|
|
53
|
-
const data = await
|
|
63
|
+
const data = await poolSnapshot(P)
|
|
54
64
|
const indexed = new Set(data.nullifiers.map((n) => n.toLowerCase()))
|
|
55
65
|
const pend = mem.pending.get(P.key) || new Set()
|
|
56
66
|
for (const n of pend) if (indexed.has(n)) pend.delete(n)
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@blackpool/cli",
|
|
3
|
-
"version": "0.4.
|
|
3
|
+
"version": "0.4.2",
|
|
4
4
|
"description": "Private payments for onchain agents. CLI for BLACKPOOL - shield, send, swap, bridge with zero-knowledge privacy on Base, Robinhood Chain, Arbitrum, and BNB Smart Chain.",
|
|
5
5
|
"mcpName": "cash.blackpool/blackpool",
|
|
6
6
|
"bin": {
|
|
@@ -52,5 +52,12 @@
|
|
|
52
52
|
"license": "MIT",
|
|
53
53
|
"publishConfig": {
|
|
54
54
|
"access": "public"
|
|
55
|
+
},
|
|
56
|
+
"overrides": {
|
|
57
|
+
"ws": "^8.21.0",
|
|
58
|
+
"underscore": "^1.13.8",
|
|
59
|
+
"bfj": "^9.1.4",
|
|
60
|
+
"ejs": "^7.0.2",
|
|
61
|
+
"minimatch": "^11.0.1"
|
|
55
62
|
}
|
|
56
63
|
}
|