@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 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.02 BNB · 3 USDC/USDT | 0.001 BNB · 2 USDC/USDT | 0.001 BNB · 0.5 USDC/USDT | PancakeSwap V3 |
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 an admin key during launch. Size balances accordingly. Details: https://blackpool.cash/docs#security
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**. Wraps the same API `docs.blackpool.cash/agents` uses - no ZK client needed on your side.
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 rarb_eth 0xRECIPIENT 0.01 --chain robinhood # router: Robinhood -> Arbitrum
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 burner slots
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 `docs.blackpool.cash/agents` documents.
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.02 BNB. These are an
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.02 },
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 fd = await provider.getFeeData(); netMax = fd.maxFeePerGas } catch {}
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: n, pool, chain,
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: n, recipient,
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
- const gasOv = (limit) => ({ gasLimit: limit, ...(chainGasOverridesSync(chain)) })
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: n, burnerAddress: burner.address,
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
- const gasCap = ethers.utils.parseUnits(C.gasCapGwei, 'gwei')
1129
- const reshieldGasReserve = ethers.BigNumber.from(1_680_000).mul(gasCap).mul(15).div(10)
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
- // Leave enough ETH for the stablecoin reshield's approve+deposit (dest is a stablecoin here).
1138
- if (bal.lte(reshieldGasReserve)) fail('burner balance too low after fees')
1139
- amountIn = bal.sub(reshieldGasReserve)
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
- // Make sure the reserved ETH survived the swap (it should — swap input excluded it).
1215
- if ((await provider.getBalance(burner.address)).lt(reshieldGasReserve.div(2))) {
1216
- info('topping up burner gas before reshield...')
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) await waitEthBalance(provider, burner.address, ethers.BigNumber.from(1), 'reshield gas')
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 chain cap.
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 = gasCap.mul(1_600_000)
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
- // Cap at the chain's gas ceiling OR at what fits in burner ETH, whichever is lower.
1274
- let maxFee = gasCap
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
- maxFeePerGas: maxFee,
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: n, recipient, pool, chain
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
- if (JSON_MODE) process.stdout.write(JSON.stringify({ ok: true, command: CMD, ...RESULT }) + '\n')
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]+)?$', description: 'Decimal amount in token units, e.g. "10" or "0.25". Never wei.' }
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: AMOUNT, pool: POOL, chain: CHAIN }, required: ['recipient', 'amount'], additionalProperties: false },
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 request('GET', `/api/zk/pool?chain=${P.chain}&pool=${P.pool}`)
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.0",
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
  }