@blackpool/cli 0.4.1 → 0.4.3

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://…",
@@ -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. |
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).
@@ -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,53 @@ 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, afterBlock) {
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
+ let bal = await settledBalance(provider, burner.address, { atBlock: afterBlock, label: 'burner balance' })
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
+ let keep = gasLimit.mul(fee).add(l1Cushion)
863
+ for (let attempt = 0; attempt < 3; attempt++) {
864
+ if (bal.lte(keep.mul(2))) return // nothing worth moving
865
+ const value = bal.sub(keep)
866
+ try {
867
+ await broadcastAndWait(burner, provider, { to, value, gasLimit, ...fees }, { label: 'return unused gas' })
868
+ info(`returned ${ethers.utils.formatEther(value)} unused gas to the relayer (burner is empty)`)
869
+ return
870
+ } catch (e) {
871
+ if (attempt === 2 || !/insufficient funds/i.test(String(e && (e.reason || e.message) || ''))) throw e
872
+ await new Promise(r => setTimeout(r, 3000))
873
+ bal = await provider.getBalance(burner.address) // the node may have been a block behind
874
+ keep = keep.mul(2)
875
+ }
876
+ }
877
+ } catch (e) {
878
+ info(`could not return the unused gas (${cleanErr(e)}); \`blackpool recover\` can sweep it later`)
879
+ }
880
+ }
881
+
882
+ // Balance as of `atBlock` (a load-balanced RPC can answer "latest" from a node a block behind). A node without that
883
+ // block errors, so retry; after a few tries fall back to "latest". With `min`, wait until the balance reaches it.
884
+ async function settledBalance(provider, addr, { atBlock, min, label = 'balance' } = {}) {
885
+ for (let i = 0; i < 60; i++) {
886
+ try {
887
+ const b = await provider.getBalance(addr, atBlock != null && i < 12 ? atBlock : 'latest')
888
+ if (!min || b.gte(min)) return b
889
+ } catch {}
890
+ await new Promise(r => setTimeout(r, 2500))
891
+ }
892
+ throw new Error(`${label} never arrived on ${addr}`)
893
+ }
894
+
831
895
  async function waitEthBalance(provider, addr, min, label = 'eth') {
832
896
  for (let i = 0; i < 80; i++) {
833
897
  const b = await provider.getBalance(addr)
@@ -868,6 +932,11 @@ const BRIDGE_ORIGINS = {
868
932
  // blackpool bridge <destAssetKey> <recipient> <amount> [--from ...] [--chain ...]
869
933
  // Every destination uses the NEAR Intents 1Click flow (/api/swap + poll) — Robinhood included.
870
934
  async function cmdBridge(args) {
935
+ if (/^(max|all)$/i.test(String(args[2] || ''))) {
936
+ const o = BRIDGE_ORIGINS[getFlag(args, '--from') || 'base_usdc']
937
+ if (!o) fail(`unknown --from "${getFlag(args, '--from')}" (1Click origins: ${Object.keys(BRIDGE_ORIGINS).join(', ')})`)
938
+ args = [...args]; args[2] = await resolveMaxAmount(o.chain, o.pool, 'bridge')
939
+ }
871
940
  const toAsset = args[0]
872
941
  const recipient = args[1]
873
942
  const amount = args[2]
@@ -910,7 +979,7 @@ async function cmdBridge(args) {
910
979
  info('quote + groth16 proof + private withdraw to solver... can take ~1-2 min')
911
980
 
912
981
  const res = await apiPost('/api/swap', {
913
- signature: sig, fromAsset, toAsset, amount: n, recipient,
982
+ signature: sig, fromAsset, toAsset, amount: String(amount), recipient,
914
983
  refundTo: wallet.address, // Solana/XRP recipients: solver refunds (origin chain) come back to this wallet
915
984
  }, { timeoutMs: 180_000 })
916
985
 
@@ -1074,6 +1143,11 @@ async function cmdRecover(args) {
1074
1143
  }
1075
1144
 
1076
1145
  async function cmdSwap(args) {
1146
+ if (/^(max|all)$/i.test(String(args[0] || ''))) {
1147
+ const fromPool = getFlag(args, '--from')
1148
+ if (!fromPool) fail('usage: blackpool swap max --from <pool> [--chain C]', 2)
1149
+ args = [...args]; args[0] = await resolveMaxAmount(resolveChain(args), fromPool, 'swap')
1150
+ }
1077
1151
  const chain = resolveChain(args)
1078
1152
  const C = CHAINS[chain]
1079
1153
  const amount = args[0]
@@ -1093,7 +1167,8 @@ async function cmdSwap(args) {
1093
1167
  // per-chain Uniswap V3 + token addresses (registry)
1094
1168
  const U = { router: C.uni.router, quoter: C.uni.quoter, weth: C.uni.weth, stable: C.uni.stable, fee: C.uni.fee }
1095
1169
  const stableDecimals = C.pools[stablePool].decimals
1096
- const gasOv = (limit) => ({ gasLimit: limit, ...(chainGasOverridesSync(chain)) })
1170
+ let liveFees = null // set before the first burner tx (see reserve sizing below)
1171
+ const gasOv = (limit) => ({ gasLimit: limit, ...(liveFees || chainGasOverridesSync(chain)) })
1097
1172
 
1098
1173
  const wallet = getWallet()
1099
1174
  const sig = await ensureSignature(wallet)
@@ -1117,7 +1192,7 @@ async function cmdSwap(args) {
1117
1192
  const wp = await apiPost('/api/swap-private', {
1118
1193
  signature: sig, chain, pool: from,
1119
1194
  fromAsset: chain === 'base' ? (isEthOrigin ? 'base_eth' : 'base_usdc') : undefined,
1120
- amount: n, burnerAddress: burner.address,
1195
+ amount: String(amount), burnerAddress: burner.address,
1121
1196
  }, { timeoutMs: 180_000 })
1122
1197
  info(`withdraw tx: ${explorerTx(chain, wp.txHash)}`)
1123
1198
 
@@ -1125,8 +1200,12 @@ async function cmdSwap(args) {
1125
1200
  // stablecoin dest, or a single ~1.6M-gas deposit for an ETH dest — priced at the chain's gas cap
1126
1201
  // (never ethers' 1.5 gwei default). Under-reserving here is exactly what stranded ETH→USDG on
1127
1202
  // 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)
1203
+ // Priced at the LIVE fee (3 x baseFee + tip, never above the chain cap) rather than the cap itself: on Arbitrum the
1204
+ // cap is ~50x the real price, which left most of a small swap sitting on the burner as unused gas.
1205
+ liveFees = await burnerFees(provider, chain)
1206
+ const feePerGas = liveFees.maxFeePerGas || liveFees.gasPrice
1207
+ const reshieldGasNeed = ethers.BigNumber.from(1_680_000).mul(feePerGas) // approve + ZK deposit, advanced by the relayer
1208
+ const swapGasReserve = ethers.BigNumber.from(320_000).mul(feePerGas).mul(15).div(10) // the swap tx itself
1130
1209
 
1131
1210
  // === Step 3: wait funds on burner + top-up gas for stablecoin origin ===
1132
1211
  let amountIn
@@ -1134,9 +1213,9 @@ async function cmdSwap(args) {
1134
1213
  info('waiting for ETH on burner...')
1135
1214
  await waitEthBalance(provider, burner.address, ethers.utils.parseEther('0.0001'), 'burner funding')
1136
1215
  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)
1216
+ // Swap everything except the swap tx's own gas; the relayer advances the reshield gas afterwards.
1217
+ if (bal.lte(swapGasReserve)) fail('burner balance too low after fees')
1218
+ amountIn = bal.sub(swapGasReserve)
1140
1219
  } else {
1141
1220
  info(`waiting for ${stableSym} on burner...`)
1142
1221
  amountIn = await waitTokenBalance(provider, U.stable, burner.address, ethers.BigNumber.from(1), 200)
@@ -1195,33 +1274,39 @@ async function cmdSwap(args) {
1195
1274
  ok('swap confirmed on Uniswap')
1196
1275
 
1197
1276
  // === Step 5: unwrap WETH -> ETH if ETH is the destination ===
1277
+ let unwrapped = null
1198
1278
  if (!isEthOrigin) {
1199
1279
  info('unwrapping WETH -> ETH on burner...')
1200
1280
  const wethBal = await waitTokenBalance(provider, U.weth, burner.address, ethers.BigNumber.from(1), 60)
1201
1281
  const weth = new ethers.Contract(U.weth, ['function withdraw(uint256)'], burner)
1202
1282
  const uwPop = await weth.populateTransaction.withdraw(wethBal)
1203
- await broadcastAndWait(burner, provider, { ...uwPop, ...gasOv(60_000) }, { label: 'WETH unwrap' })
1283
+ const uw = await broadcastAndWait(burner, provider, { ...uwPop, ...gasOv(60_000) }, { label: 'WETH unwrap' })
1284
+ unwrapped = { amount: wethBal, block: uw.receipt.blockNumber }
1204
1285
  info('unwrap confirmed')
1205
1286
  }
1206
1287
 
1207
1288
  // === Step 6: figure out reshield amount + call /api/deposit/prepare ===
1208
- const destPool = isEthOrigin ? stablePool : 'eth'
1289
+ const destPool = isEthOrigin ? stablePool : nativePool
1209
1290
  const destDec = isEthOrigin ? stableDecimals : 18
1210
1291
  let reshieldAmount
1211
1292
  if (isEthOrigin) {
1212
1293
  // Stablecoin destination: reshield the full stable balance (gas paid in the burner's reserved ETH).
1213
1294
  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...')
1295
+ // The relayer advances the approve + deposit gas (the burner now holds the stablecoin, which is what the
1296
+ // top-up endpoint checks). Whatever is not used goes back to the relayer after the reshield.
1297
+ if ((await provider.getBalance(burner.address)).lt(reshieldGasNeed)) {
1298
+ info('relayer advancing reshield gas...')
1217
1299
  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')
1300
+ if (!g.success) fail('reshield gas top-up failed; the swapped funds are safe on the burner, run `blackpool recover`')
1301
+ await waitEthBalance(provider, burner.address, reshieldGasNeed, 'reshield gas')
1219
1302
  }
1220
1303
  } else {
1221
1304
  // 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.
1223
- const full = await waitEthBalance(provider, burner.address, ethers.utils.parseEther('0.0000001'), 'swap output')
1224
- const gasReserve = gasCap.mul(1_600_000)
1305
+ // balance. Reserve the deposit gas at the live fee.
1306
+ // Read at the unwrap's block and require at least the unwrapped amount: a node a block behind still shows only
1307
+ // the gas top-up, and a reshield built from that would carry almost nothing.
1308
+ const full = await settledBalance(provider, burner.address, { atBlock: unwrapped.block, min: unwrapped.amount, label: 'swap output' })
1309
+ const gasReserve = feePerGas.mul(1_600_000).mul(12).div(10) // live fee, +20%
1225
1310
  if (full.lte(gasReserve)) fail(`swap output ${ethers.utils.formatEther(full)} ETH too small to reshield after gas`)
1226
1311
  reshieldAmount = full.sub(gasReserve)
1227
1312
  }
@@ -1270,17 +1355,20 @@ async function cmdSwap(args) {
1270
1355
  try { estGas = await provider.estimateGas({ to: prep.to, data: prep.data, value: reshieldVal, from: burner.address }) }
1271
1356
  catch { estGas = ethers.BigNumber.from(1_600_000) } // fallback
1272
1357
  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
1358
+ // The live fee OR what fits in burner ETH, whichever is lower.
1359
+ let maxFee = feePerGas
1275
1360
  const maxAffordable = burnerEth.div(gasLimit)
1276
1361
  if (maxFee.gt(maxAffordable)) maxFee = maxAffordable.mul(9).div(10) // 90% of ceiling
1362
+ // BNB Chain enforces a 0.05 gwei minimum on the effective price; an EIP-1559 tip of maxFee/10 falls below it.
1363
+ const feeFields = C.legacyGas ? { gasPrice: maxFee } : { maxFeePerGas: maxFee, maxPriorityFeePerGas: maxFee.div(10) }
1277
1364
  const rtx = await broadcastAndWait(burner, provider, {
1278
1365
  to: prep.to, data: prep.data, value: reshieldVal,
1279
1366
  gasLimit,
1280
- maxFeePerGas: maxFee,
1281
- maxPriorityFeePerGas: maxFee.div(10),
1367
+ ...feeFields,
1282
1368
  }, { label: 'reshield' })
1283
1369
 
1370
+ await returnUnusedGas(burner, provider, chain, rtx.receipt.blockNumber)
1371
+
1284
1372
  log('')
1285
1373
  ok(color(`swap complete: ${amount} ${from.toUpperCase()} -> ${reshieldFmt} ${toSym} (private) on ${C.label}`, c.green))
1286
1374
  info(`new private balance: ${prep.newBalance} ${prep.symbol}`)
@@ -1336,6 +1424,7 @@ async function cmdAssets() {
1336
1424
  }
1337
1425
 
1338
1426
  async function cmdSend(args) {
1427
+ if (/^(max|all)$/i.test(String(args[1] || ''))) return cmdSendMax(args)
1339
1428
  const chain = resolveChain(args)
1340
1429
  const C = CHAINS[chain]
1341
1430
  const recipient = args[0]
@@ -1360,7 +1449,7 @@ async function cmdSend(args) {
1360
1449
  info('this can take ~30-90 seconds - you pay no gas.')
1361
1450
 
1362
1451
  const res = await apiPost('/api/withdraw', {
1363
- signature: sig, amount: n, recipient, pool, chain
1452
+ signature: sig, amount: String(amount), recipient, pool, chain
1364
1453
  }, { timeoutMs: 180_000 })
1365
1454
 
1366
1455
  log('')
@@ -1371,6 +1460,80 @@ async function cmdSend(args) {
1371
1460
  result({ chain, pool, recipient, amount: n, received: res.recipientReceives, fee: res.fee, symbol: res.symbol, txHash: res.txHash, explorer: explorerTx(chain, res.txHash) })
1372
1461
  }
1373
1462
 
1463
+ // "max" for swap/bridge: the most one operation can spend (its two largest notes), as an exact decimal string.
1464
+ async function resolveMaxAmount(chain, pool, flow) {
1465
+ if (!CHAINS[chain] || !CHAINS[chain].pools[pool]) fail(`unknown pool "${pool}" on ${CHAINS[chain] ? CHAINS[chain].label : chain}`)
1466
+ const P = CHAINS[chain].pools[pool]
1467
+ const sig = await ensureSignature(getWallet())
1468
+ const bal = await apiPost('/api/balance', { signature: sig, pool, chain }, { timeoutMs: 120_000 })
1469
+ 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))
1470
+ if (!notes.length) fail(`no private ${P.symbol} on ${CHAINS[chain].label}`)
1471
+ const spend = notes.slice(0, 2).reduce((s, n) => s.add(n), ethers.BigNumber.from(0))
1472
+ const total = notes.reduce((s, n) => s.add(n), ethers.BigNumber.from(0))
1473
+ const spendStr = ethers.utils.formatUnits(spend, P.decimals)
1474
+ 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}`)
1475
+ 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)` : ''))
1476
+ return spendStr
1477
+ }
1478
+
1479
+ // send <recipient> max: empty one pool, every pool of a chain, or every pool everywhere into one address.
1480
+ async function cmdSendMax(args) {
1481
+ const recipient = args[0]
1482
+ if (!/^0x[a-fA-F0-9]{40}$/.test(String(recipient || ''))) fail(`invalid recipient address: ${recipient}`, 2)
1483
+ const rawChain = (getFlag(args, '--chain') || 'base').toLowerCase()
1484
+ const chains = rawChain === 'all' ? Object.keys(CHAINS) : [resolveChain(args)]
1485
+ const poolFlag = (getFlag(args, '--pool') || 'all').toLowerCase()
1486
+ if (rawChain === 'all' && poolFlag !== 'all') fail('with --chain all, use --pool all (pool names differ per chain)', 2)
1487
+ if (poolFlag !== 'all' && !poolsForChain(chains[0]).includes(poolFlag)) fail(`unknown pool "${poolFlag}" on ${CHAINS[chains[0]].label} (supported: ${poolsForChain(chains[0]).join(', ')})`)
1488
+
1489
+ const wallet = getWallet()
1490
+ const sig = await ensureSignature(wallet)
1491
+ const sends = [], dust = [], errors = []
1492
+ log('')
1493
+ log(color(` sending the whole private balance to ${recipient}`, c.bold))
1494
+ for (const chain of chains) {
1495
+ const pools = poolFlag === 'all' ? poolsForChain(chain) : [poolFlag]
1496
+ for (const pool of pools) {
1497
+ const P = CHAINS[chain].pools[pool]
1498
+ const tag = `${CHAINS[chain].label} ${P.symbol}`
1499
+ let behind = 0
1500
+ for (let round = 0; round < 12; round++) {
1501
+ let bal
1502
+ try { bal = await apiPost('/api/balance', { signature: sig, pool, chain }, { timeoutMs: 120_000 }) }
1503
+ catch (e) { errors.push({ chain, pool, error: cleanErr(e) }); info(`${tag}: could not read the balance (${cleanErr(e)})`); break }
1504
+ 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))
1505
+ if (!notes.length) { if (round === 0) info(`${tag}: empty`); break }
1506
+ // one send spends at most the two largest notes
1507
+ const spend = notes.slice(0, 2).reduce((s, n) => s.add(n), ethers.BigNumber.from(0))
1508
+ const spendStr = ethers.utils.formatUnits(spend, P.decimals)
1509
+ const total = ethers.utils.formatUnits(notes.reduce((s, n) => s.add(n), ethers.BigNumber.from(0)), P.decimals)
1510
+ 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 }
1511
+ info(`${tag}: sending ${spendStr}${notes.length > 2 ? ` (part ${round + 1}, ${notes.length} notes)` : ''}...`)
1512
+ try {
1513
+ const res = await apiPost('/api/withdraw', { signature: sig, amount: spendStr, recipient, pool, chain }, { timeoutMs: 180_000 })
1514
+ sends.push({ chain, pool, symbol: res.symbol, amount: spendStr, received: res.recipientReceives, fee: res.fee, txHash: res.txHash, explorer: explorerTx(chain, res.txHash) })
1515
+ ok(`${tag}: sent ${spendStr}, recipient gets ${res.recipientReceives} ${res.symbol} ${color(explorerTx(chain, res.txHash), c.dim)}`)
1516
+ } catch (e) {
1517
+ const m = cleanErr(e)
1518
+ // a send or deposit a moment ago is not in the balance view yet: wait and read it again
1519
+ if (/already spent|unknown root|stale/i.test(m) && behind++ < 4) { info(`${tag}: balance view is catching up, retrying...`); await new Promise(r => setTimeout(r, 10_000)); continue }
1520
+ 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)`) }
1521
+ else { errors.push({ chain, pool, error: m }); info(`${tag}: not sent (${m})`) }
1522
+ break
1523
+ }
1524
+ }
1525
+ }
1526
+ }
1527
+ const received = {}
1528
+ for (const s of sends) received[s.symbol] = (Number(received[s.symbol] || 0) + Number(s.received)).toString()
1529
+ log('')
1530
+ if (sends.length) ok(color(`${sends.length} send(s) done`, c.green) + (dust.length ? `, ${dust.length} pool(s) left with dust` : ''))
1531
+ else info('nothing to send')
1532
+ log('')
1533
+ result({ recipient, amount: 'max', sends, received, dust, errors })
1534
+ if (errors.length && !sends.length) fail(errors.map((e) => `${e.chain}/${e.pool}: ${e.error}`).join('; '))
1535
+ }
1536
+
1374
1537
  function getFlag(args, name) {
1375
1538
  const i = args.indexOf(name)
1376
1539
  if (i === -1) return null
@@ -1393,6 +1556,8 @@ ${color('Commands:', c.bold)}
1393
1556
  --chain all shows every chain in one run.
1394
1557
  deposit <amount> [--pool] [--chain] Shield public funds into private pool.
1395
1558
  send <to> <amount> [--pool] [--chain] Send privately to an address (no gas from you).
1559
+ amount "max": the whole private balance (no --pool = every pool; --chain all).
1560
+ swap/bridge also take "max" (the most one operation can move).
1396
1561
  swap <amount> --from <pool> [--chain] Private same-chain swap via Uniswap V3.
1397
1562
  bridge <asset> <to> <amount> Bridge cross-chain privately via NEAR Intents (--no-wait: return once submitted)
1398
1563
  (--from ${Object.keys(BRIDGE_ORIGINS).join('|')}).
@@ -1485,7 +1650,10 @@ async function main() {
1485
1650
  case undefined: help(); break
1486
1651
  default: if (!JSON_MODE) { log(`unknown command: ${cmd}\n`); help() } fail(`unknown command: ${cmd} (run blackpool help)`, 2)
1487
1652
  }
1488
- if (JSON_MODE) process.stdout.write(JSON.stringify({ ok: true, command: CMD, ...RESULT }) + '\n')
1653
+ // Exit explicitly: the prover leaves worker threads alive, which would keep the process running after a
1654
+ // deposit, send, swap or bridge has already finished.
1655
+ if (JSON_MODE) process.stdout.write(JSON.stringify({ ok: true, command: CMD, ...RESULT }) + '\n', () => process.exit(0))
1656
+ else process.stdout.write('', () => process.exit(0)) // flush piped output first
1489
1657
  } catch (e) {
1490
1658
  fail(cleanErr(e))
1491
1659
  }
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.1",
3
+ "version": "0.4.3",
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": {