@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 +10 -0
- package/README.md +7 -5
- package/bin/blackpool.js +195 -27
- package/bin/mcp.js +3 -3
- package/lib/localApi.js +11 -1
- package/package.json +1 -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://…",
|
|
@@ -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**.
|
|
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).
|
|
@@ -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,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:
|
|
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
|
-
|
|
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:
|
|
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
|
-
|
|
1129
|
-
|
|
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
|
-
//
|
|
1138
|
-
if (bal.lte(
|
|
1139
|
-
amountIn = bal.sub(
|
|
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 :
|
|
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
|
-
//
|
|
1215
|
-
|
|
1216
|
-
|
|
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)
|
|
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
|
|
1223
|
-
|
|
1224
|
-
|
|
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
|
-
//
|
|
1274
|
-
let maxFee =
|
|
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
|
-
|
|
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:
|
|
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
|
-
|
|
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]+)
|
|
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.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": {
|