@blackpool/cli 0.3.0 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AGENTS.md +4 -4
- package/README.md +3 -2
- package/bin/blackpool.js +42 -1
- package/lib/circuit.js +54 -0
- package/lib/localApi.js +197 -0
- package/lib/zk.js +356 -0
- package/package.json +6 -2
package/AGENTS.md
CHANGED
|
@@ -119,7 +119,7 @@ Without `--pool` (one entry per chain; `--chain all` returns four):
|
|
|
119
119
|
A value of `"?"` with an `error` field means that read failed; retry.
|
|
120
120
|
|
|
121
121
|
### `deposit <amount> [--pool P] [--chain C]`
|
|
122
|
-
Shield public funds from the agent wallet. ERC-20 pools approve first (one extra transaction). Needs native gas. ~30
|
|
122
|
+
Shield public funds from the agent wallet. ERC-20 pools approve first (one extra transaction). Needs native gas. ~10–30 s (the first run also downloads the 20 MB proving key).
|
|
123
123
|
```json
|
|
124
124
|
{"ok":true,"command":"deposit","chain":"base","pool":"usdc","amount":25,"symbol":"USDC",
|
|
125
125
|
"txHash":"0x…","explorer":"https://basescan.org/tx/0x…","privateBalance":"25.0"}
|
|
@@ -127,7 +127,7 @@ Shield public funds from the agent wallet. ERC-20 pools approve first (one extra
|
|
|
127
127
|
`--pool` defaults to the chain's stablecoin pool.
|
|
128
128
|
|
|
129
129
|
### `send <recipient> <amount> [--pool P] [--chain C]`
|
|
130
|
-
Private payment to any EVM address on the same chain. `amount` is what leaves your private balance; the recipient gets `amount − fee`. No gas needed. ~30
|
|
130
|
+
Private payment to any EVM address on the same chain. `amount` is what leaves your private balance; the recipient gets `amount − fee`. No gas needed. ~10–30 s.
|
|
131
131
|
```json
|
|
132
132
|
{"ok":true,"command":"send","chain":"base","pool":"usdc","recipient":"0x…","amount":10,
|
|
133
133
|
"received":"8.965","fee":"1.035","symbol":"USDC","txHash":"0x…","explorer":"https://basescan.org/tx/0x…"}
|
|
@@ -441,7 +441,7 @@ Use the hosted server to plan (fees, routes, quotes, status) and the local serve
|
|
|
441
441
|
|
|
442
442
|
- **Dedicated agent wallet.** Fund it with only what the agent may spend. Never use a personal or treasury key.
|
|
443
443
|
- `BLACKPOOL_KEY` never leaves the machine: it signs the sign-in message, deposit approvals and deposit transactions locally.
|
|
444
|
-
- The **sign-in signature** is cached in `$BLACKPOOL_HOME/config.json` (mode 600) and sent
|
|
444
|
+
- 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
445
|
- Only sign the Blackpool sign-in message on blackpool.cash or with this CLI.
|
|
446
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
|
|
447
447
|
- Report vulnerabilities privately to hello@blackpool.cash (subject `security`).
|
|
@@ -450,7 +450,7 @@ Use the hosted server to plan (fees, routes, quotes, status) and the local serve
|
|
|
450
450
|
|
|
451
451
|
## 14. Direct API (advanced)
|
|
452
452
|
|
|
453
|
-
The CLI is the supported interface
|
|
453
|
+
The CLI is the supported interface: it derives keys and generates Groth16 proofs locally. A client in another language must do the same (see `cli/lib/zk.js`): read public pool data from `GET /api/zk/pool`, derive keys from the EIP-191 signature of the exact message below, prove with the published circuit (`https://blackpool.cash/zk/manifest.json`), then post the proof to `POST /api/relay`, or broadcast a deposit from its own wallet. The API never accepts the signature itself. Endpoint list: https://blackpool.cash/docs#api
|
|
454
454
|
|
|
455
455
|
```text
|
|
456
456
|
Welcome to Blackpool
|
package/README.md
CHANGED
|
@@ -24,7 +24,7 @@ blackpool login
|
|
|
24
24
|
# ✓ wallet stored at ~/.blackpool/wallet.json (chmod 600)
|
|
25
25
|
```
|
|
26
26
|
|
|
27
|
-
The key is prompted with **hidden input** (no echo, never enters shell history), then stored locally at `~/.blackpool/wallet.json` with `chmod 600` (owner-only). It is never transmitted
|
|
27
|
+
The key is prompted with **hidden input** (no echo, never enters shell history), then stored locally at `~/.blackpool/wallet.json` with `chmod 600` (owner-only). It is never transmitted, and neither is the sign-in signature: keys and proofs are computed on your machine.
|
|
28
28
|
|
|
29
29
|
### Env var (agents / CI) - no prompt
|
|
30
30
|
|
|
@@ -72,7 +72,7 @@ blackpool recover --to 0xADDR # sweep to a different destination
|
|
|
72
72
|
|
|
73
73
|
Each command follows the pattern `docs.blackpool.cash/agents` documents.
|
|
74
74
|
|
|
75
|
-
**Login / balance / send / bridge** - you sign once
|
|
75
|
+
**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
76
|
|
|
77
77
|
**Deposit** - your public wallet signs and broadcasts the shield tx (this is the only step where you pay gas, because the pool must see funds coming from a public address). For ERC-20 pools (USDC), an `approve` runs first; the CLI polls the on-chain allowance before broadcasting the deposit (same fix as the frontend for load-balanced RPC lag).
|
|
78
78
|
|
|
@@ -103,6 +103,7 @@ If any step fails between (2) and (7), funds remain on the burner; its key is in
|
|
|
103
103
|
|
|
104
104
|
- `~/.blackpool/wallet.json` - stored private key (chmod 600, only if you used `blackpool login` interactively).
|
|
105
105
|
- `~/.blackpool/config.json` - cached BLACKPOOL signatures per wallet (chmod 600).
|
|
106
|
+
- `~/.blackpool/zk/` - public proving key files, verified against the published SHA-256.
|
|
106
107
|
|
|
107
108
|
## Verified live on Base mainnet
|
|
108
109
|
|
package/bin/blackpool.js
CHANGED
|
@@ -175,7 +175,48 @@ async function ensureSignature(wallet, { forceNew = false } = {}) {
|
|
|
175
175
|
}
|
|
176
176
|
|
|
177
177
|
// === HTTP ===
|
|
178
|
+
// === local proving ===
|
|
179
|
+
// Balance scans, deposits, payments and swaps are computed on this machine (lib/localApi.js): keys come from
|
|
180
|
+
// the cached sign-in signature, proofs from the circuit files in <BLACKPOOL_HOME>/zk/. The API only receives
|
|
181
|
+
// finished proofs (POST /api/relay) and serves public pool data, so the signature never leaves this machine.
|
|
182
|
+
let LOCAL = null
|
|
183
|
+
function localApi() {
|
|
184
|
+
if (LOCAL) return LOCAL
|
|
185
|
+
const { createLocalApi } = require('../lib/localApi')
|
|
186
|
+
const { createCircuitLoader } = require('../lib/circuit')
|
|
187
|
+
const UA = `blackpool-cli/${require('../package.json').version}`
|
|
188
|
+
const request = async (method, endpoint, body) => {
|
|
189
|
+
for (let attempt = 0; ; attempt++) {
|
|
190
|
+
const r = await fetch(`${API}${endpoint}`, {
|
|
191
|
+
method, headers: { 'Content-Type': 'application/json', 'User-Agent': UA },
|
|
192
|
+
body: method === 'GET' ? undefined : JSON.stringify(body || {}), signal: AbortSignal.timeout(120_000),
|
|
193
|
+
})
|
|
194
|
+
let json = {}
|
|
195
|
+
try { json = await r.json() } catch {}
|
|
196
|
+
if (r.status === 429 && attempt < 4) {
|
|
197
|
+
const wait = Math.min(Math.max(Number(json?.retryAfter) || 5, 1), 30)
|
|
198
|
+
info(`rate limited by the API, retrying in ${wait}s...`)
|
|
199
|
+
await new Promise((res) => setTimeout(res, wait * 1000)); continue
|
|
200
|
+
}
|
|
201
|
+
if (!r.ok) throw Object.assign(new Error(json?.error || `HTTP ${r.status}`), { status: r.status })
|
|
202
|
+
return json
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
let said = false
|
|
206
|
+
LOCAL = createLocalApi({
|
|
207
|
+
request,
|
|
208
|
+
loadCircuit: createCircuitLoader({ dir: path.join(CONFIG_DIR, 'zk'), userAgent: UA }),
|
|
209
|
+
onProgress: (p) => {
|
|
210
|
+
if (p.stage === 'download') info(`downloading proving key (${p.file}, ${Math.round((p.bytes || 0) / 1e6)} MB, first run only)...`)
|
|
211
|
+
if (p.stage === 'prove' && !said) { info('generating zero-knowledge proof locally...'); said = true }
|
|
212
|
+
},
|
|
213
|
+
})
|
|
214
|
+
return LOCAL
|
|
215
|
+
}
|
|
216
|
+
|
|
178
217
|
async function apiPost(endpoint, body, opts = {}) {
|
|
218
|
+
if (localApi().handles(endpoint)) return localApi().handle(endpoint, body)
|
|
219
|
+
if (body && 'signature' in body) { body = { ...body }; delete body.signature } // never sent to the API
|
|
179
220
|
for (let attempt = 0; ; attempt++) {
|
|
180
221
|
try { return await apiPostOnce(endpoint, body, opts) }
|
|
181
222
|
catch (e) {
|
|
@@ -636,7 +677,7 @@ async function cmdDeposit(args) {
|
|
|
636
677
|
log(color(` shielding ${amount} ${pool.toUpperCase()} on ${C.label}`, c.bold))
|
|
637
678
|
log(color(` from public wallet ${wallet.address}`, c.dim))
|
|
638
679
|
log('')
|
|
639
|
-
info('preparing deposit
|
|
680
|
+
info('preparing deposit (the proof is generated on this machine)...')
|
|
640
681
|
|
|
641
682
|
const prep = await apiPost('/api/deposit/prepare', {
|
|
642
683
|
signature: sig, address: wallet.address, amount: n, pool, chain,
|
package/lib/circuit.js
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
'use strict'
|
|
2
|
+
// Proving-key loader for Node (CLI and its MCP server). Downloads the Groth16 circuit files once from the
|
|
3
|
+
// published manifest, verifies their SHA-256 and keeps them in <BLACKPOOL_HOME>/zk/. These files are public
|
|
4
|
+
// (the same ones the pool verifier was generated from); nothing secret is written.
|
|
5
|
+
const fs = require('fs')
|
|
6
|
+
const path = require('path')
|
|
7
|
+
const crypto = require('crypto')
|
|
8
|
+
|
|
9
|
+
const MANIFEST_URL = process.env.BLACKPOOL_ZK_MANIFEST || 'https://blackpool.cash/zk/manifest.json'
|
|
10
|
+
|
|
11
|
+
function sha256(buf) { return crypto.createHash('sha256').update(buf).digest('hex') }
|
|
12
|
+
|
|
13
|
+
function createCircuitLoader({ dir, userAgent = 'blackpool-cli' }) {
|
|
14
|
+
let cached = null
|
|
15
|
+
return async function loadCircuit(onProgress = () => {}) {
|
|
16
|
+
if (cached) return cached
|
|
17
|
+
fs.mkdirSync(dir, { recursive: true })
|
|
18
|
+
const local = (name) => path.join(dir, name)
|
|
19
|
+
let manifest
|
|
20
|
+
try {
|
|
21
|
+
const r = await fetch(MANIFEST_URL, { headers: { 'User-Agent': userAgent }, signal: AbortSignal.timeout(30_000) })
|
|
22
|
+
if (!r.ok) throw new Error(`HTTP ${r.status}`)
|
|
23
|
+
manifest = await r.json()
|
|
24
|
+
fs.writeFileSync(local('manifest.json'), JSON.stringify(manifest))
|
|
25
|
+
} catch (e) {
|
|
26
|
+
// offline: fall back to the last manifest whose files are already here
|
|
27
|
+
if (!fs.existsSync(local('manifest.json'))) throw new Error(`cannot fetch the proving-key manifest (${e.message})`)
|
|
28
|
+
manifest = JSON.parse(fs.readFileSync(local('manifest.json'), 'utf8'))
|
|
29
|
+
}
|
|
30
|
+
const out = {}
|
|
31
|
+
for (const kind of ['wasm', 'zkey']) {
|
|
32
|
+
const f = manifest[kind]
|
|
33
|
+
const name = path.basename(f.path)
|
|
34
|
+
const file = local(name)
|
|
35
|
+
let buf = fs.existsSync(file) ? fs.readFileSync(file) : null
|
|
36
|
+
if (!buf || sha256(buf) !== f.sha256) {
|
|
37
|
+
onProgress({ stage: 'download', file: kind, bytes: f.bytes })
|
|
38
|
+
const url = new URL(f.path, MANIFEST_URL).toString()
|
|
39
|
+
const r = await fetch(url, { headers: { 'User-Agent': userAgent }, signal: AbortSignal.timeout(300_000) })
|
|
40
|
+
if (!r.ok) throw new Error(`download ${name}: HTTP ${r.status}`)
|
|
41
|
+
buf = Buffer.from(await r.arrayBuffer())
|
|
42
|
+
if (sha256(buf) !== f.sha256) throw new Error(`download ${name}: checksum mismatch`)
|
|
43
|
+
fs.writeFileSync(file + '.tmp', buf)
|
|
44
|
+
fs.renameSync(file + '.tmp', file)
|
|
45
|
+
for (const old of fs.readdirSync(dir)) if (old !== name && old.endsWith('.' + kind)) fs.rmSync(local(old), { force: true })
|
|
46
|
+
}
|
|
47
|
+
out[kind] = new Uint8Array(buf)
|
|
48
|
+
}
|
|
49
|
+
cached = out
|
|
50
|
+
return out
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
module.exports = { createCircuitLoader, MANIFEST_URL }
|
package/lib/localApi.js
ADDED
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
'use strict'
|
|
2
|
+
// Local replacements for the five endpoints that used to receive the sign-in signature.
|
|
3
|
+
//
|
|
4
|
+
// createLocalApi() returns handle(path, body) for:
|
|
5
|
+
// /api/balance /api/withdraw /api/deposit/prepare /api/swap /api/swap-private
|
|
6
|
+
// with the SAME request and response shapes the server used, so callers (web app, CLI, MCP server) only swap the
|
|
7
|
+
// transport. The signature is used here, in memory, to derive keys; it is never sent anywhere. Proofs are made
|
|
8
|
+
// locally and only the finished proof goes to /api/relay. Nothing is persisted by this module.
|
|
9
|
+
const { utils: U, Wallet } = require('ethers') // v5
|
|
10
|
+
const zk = require('./zk')
|
|
11
|
+
|
|
12
|
+
const LOCAL_PATHS = new Set(['/api/balance', '/api/withdraw', '/api/deposit/prepare', '/api/swap', '/api/swap-private'])
|
|
13
|
+
const REGISTRY_AUTH_DOMAIN = 'blackpool/registry-auth/v1'
|
|
14
|
+
const REGISTRY_AUTH_MESSAGE = 'Blackpool Registry\n\nAuthorize access to your encrypted recovery data.\nThis does not move funds and does not expose your account keys.'
|
|
15
|
+
const ERC20 = new U.Interface(['function approve(address,uint256) returns (bool)'])
|
|
16
|
+
|
|
17
|
+
/** Same derivation as the burner/intent registries: owner = address(keccak256(sig || domain)). */
|
|
18
|
+
function registryAuthSig(signInSig) {
|
|
19
|
+
const key = U.keccak256(U.concat([U.arrayify(signInSig), U.toUtf8Bytes(REGISTRY_AUTH_DOMAIN)]))
|
|
20
|
+
return new Wallet(key).signMessage(REGISTRY_AUTH_MESSAGE)
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* request(method, path, body) -> parsed JSON; must throw Error(message) on a non-2xx answer.
|
|
25
|
+
* loadCircuit(onProgress) -> { wasm, zkey } (Uint8Array or file paths), loaded once by the caller.
|
|
26
|
+
*/
|
|
27
|
+
function createLocalApi({ request, loadCircuit, snarkjs, singleThread = false, onProgress = () => {} }) {
|
|
28
|
+
const mem = { config: null, configAt: 0, keys: new Map(), pending: new Map() }
|
|
29
|
+
|
|
30
|
+
async function config() {
|
|
31
|
+
if (!mem.config || Date.now() - mem.configAt > 5 * 60_000) { mem.config = await request('GET', '/api/zk/config'); mem.configAt = Date.now() }
|
|
32
|
+
return mem.config
|
|
33
|
+
}
|
|
34
|
+
async function keysFor(signature) {
|
|
35
|
+
let k = mem.keys.get(signature)
|
|
36
|
+
if (!k) { k = await zk.deriveKeys(signature); mem.keys.set(signature, k) }
|
|
37
|
+
return k
|
|
38
|
+
}
|
|
39
|
+
async function poolOf({ chain, pool, fromAsset }) {
|
|
40
|
+
const cfg = await config()
|
|
41
|
+
let key = chain && pool ? `${String(chain).toLowerCase()}:${String(pool).toLowerCase()}` : null
|
|
42
|
+
if (!key && fromAsset) key = Object.keys(cfg.pools).find((k) => cfg.pools[k].oneClickAsset === fromAsset) || null
|
|
43
|
+
if (!key) key = `${String(chain || 'base').toLowerCase()}:${String(pool || 'eth').toLowerCase()}`
|
|
44
|
+
const P = cfg.pools[key]
|
|
45
|
+
if (!P) throw new Error('unknown pool')
|
|
46
|
+
return { key, ...P, feeRecipient: cfg.feeRecipient }
|
|
47
|
+
}
|
|
48
|
+
const units = (P, v) => BigInt(U.parseUnits(String(v), P.decimals).toString())
|
|
49
|
+
const fmt = (P, v) => U.formatUnits(v.toString(), P.decimals)
|
|
50
|
+
|
|
51
|
+
async function account(keys, P) {
|
|
52
|
+
onProgress({ stage: 'scan', pool: P.key })
|
|
53
|
+
const data = await request('GET', `/api/zk/pool?chain=${P.chain}&pool=${P.pool}`)
|
|
54
|
+
const indexed = new Set(data.nullifiers.map((n) => n.toLowerCase()))
|
|
55
|
+
const pend = mem.pending.get(P.key) || new Set()
|
|
56
|
+
for (const n of pend) if (indexed.has(n)) pend.delete(n)
|
|
57
|
+
return zk.loadAccount({ keys, poolData: { root: data.root, entries: data.entries, nullifiers: [...data.nullifiers, ...pend] } })
|
|
58
|
+
}
|
|
59
|
+
async function proofFor(tx) {
|
|
60
|
+
onProgress({ stage: 'circuit' })
|
|
61
|
+
const circuit = await loadCircuit(onProgress)
|
|
62
|
+
onProgress({ stage: 'prove' })
|
|
63
|
+
const proof = await zk.prove(tx.circuitInput, { ...circuit, snarkjs, singleThread })
|
|
64
|
+
return { ...proof, ...tx.publicArgs }
|
|
65
|
+
}
|
|
66
|
+
async function relay(P, tx, purpose, extra = {}) {
|
|
67
|
+
const args = await proofFor(tx)
|
|
68
|
+
onProgress({ stage: 'relay' })
|
|
69
|
+
const r = await request('POST', '/api/relay', { chain: P.chain, pool: P.pool, args, extData: tx.extData, purpose, ...extra })
|
|
70
|
+
const set = mem.pending.get(P.key) || new Set()
|
|
71
|
+
for (const n of tx.publicArgs.inputNullifiers) set.add(n.toLowerCase())
|
|
72
|
+
mem.pending.set(P.key, set)
|
|
73
|
+
return r
|
|
74
|
+
}
|
|
75
|
+
// A second spend in the same session needs the first one's change note in the tree.
|
|
76
|
+
async function waitIndexed(P, nullifiers, timeoutMs = 90_000) {
|
|
77
|
+
const want = nullifiers.map((n) => n.toLowerCase())
|
|
78
|
+
const until = Date.now() + timeoutMs
|
|
79
|
+
while (Date.now() < until) {
|
|
80
|
+
const d = await request('GET', `/api/zk/pool?chain=${P.chain}&pool=${P.pool}`)
|
|
81
|
+
const have = new Set(d.nullifiers.map((n) => n.toLowerCase()))
|
|
82
|
+
if (want.every((n) => have.has(n))) return
|
|
83
|
+
await new Promise((r) => setTimeout(r, 3000))
|
|
84
|
+
}
|
|
85
|
+
throw new Error('timed out waiting for the indexer; your first payment went through, retry the second')
|
|
86
|
+
}
|
|
87
|
+
function friendly(e, P) {
|
|
88
|
+
if (e.code === 'TOO_SMALL') return new Error(`amount must be more than twice the fee (${fmt(P, e.minimum)} ${P.symbol})`)
|
|
89
|
+
if (e.code === 'FRAGMENTED') return new Error(`max ${fmt(P, e.maxSpendable)} per transaction (funds split across several notes; spend the rest separately)`)
|
|
90
|
+
if (e.code === 'INSUFFICIENT') return new Error(`insufficient balance: have ${fmt(P, e.maxSpendable)}`)
|
|
91
|
+
return e
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
const handlers = {
|
|
95
|
+
async '/api/balance'(b) {
|
|
96
|
+
const P = await poolOf(b)
|
|
97
|
+
const acc = await account(await keysFor(b.signature), P)
|
|
98
|
+
return { balance: fmt(P, acc.balance), symbol: P.symbol, utxoCount: acc.notes.length, utxos: acc.notes.map((n) => fmt(P, n.amount)) }
|
|
99
|
+
},
|
|
100
|
+
|
|
101
|
+
async '/api/withdraw'(b) {
|
|
102
|
+
const P = await poolOf(b)
|
|
103
|
+
const keys = await keysFor(b.signature)
|
|
104
|
+
const list = Array.isArray(b.recipients) && b.recipients.length ? b.recipients.map((r) => ({ address: r.address, amount: r.amount })) : [{ address: b.recipient, amount: b.amount }]
|
|
105
|
+
if (list.length > 2) throw new Error('max 2 recipients (circuit 2-output constraint)')
|
|
106
|
+
for (const r of list) {
|
|
107
|
+
if (!r.address || !/^0x[a-fA-F0-9]{40}$/.test(r.address)) throw new Error(`invalid recipient address: ${r.address || '(empty)'}`)
|
|
108
|
+
if (!r.amount || isNaN(Number(r.amount)) || Number(r.amount) <= 0) throw new Error(`invalid amount for ${r.address}`)
|
|
109
|
+
}
|
|
110
|
+
const results = []
|
|
111
|
+
for (let i = 0; i < list.length; i++) {
|
|
112
|
+
if (i > 0) await waitIndexed(P, results[i - 1]._nullifiers)
|
|
113
|
+
const acc = await account(keys, P)
|
|
114
|
+
if (!acc.notes.length) throw new Error('no unspent UTXOs')
|
|
115
|
+
let tx
|
|
116
|
+
try { tx = await zk.prepareWithdraw({ keys, account: acc, amount: units(P, list[i].amount), recipient: list[i].address, flatFeeWei: P.flatFeeWei, feeRecipient: P.feeRecipient }) }
|
|
117
|
+
catch (e) { throw friendly(e, P) }
|
|
118
|
+
const r = await relay(P, tx, 'withdraw')
|
|
119
|
+
results.push({ ...r, _nullifiers: tx.publicArgs.inputNullifiers })
|
|
120
|
+
}
|
|
121
|
+
const clean = results.map(({ _nullifiers, ...r }) => r)
|
|
122
|
+
return clean.length === 1 ? clean[0] : { success: clean.every((x) => x.success), transfers: clean }
|
|
123
|
+
},
|
|
124
|
+
|
|
125
|
+
async '/api/deposit/prepare'(b) {
|
|
126
|
+
if (!b.address || !b.amount) throw new Error('missing address/amount')
|
|
127
|
+
const P = await poolOf(b)
|
|
128
|
+
const keys = await keysFor(b.signature)
|
|
129
|
+
const acc = await account(keys, P)
|
|
130
|
+
const amount = units(P, b.amount)
|
|
131
|
+
const tx = await zk.prepareDeposit({ keys, account: acc, amount, depositor: b.address, feeRecipient: P.feeRecipient })
|
|
132
|
+
const args = await proofFor(tx)
|
|
133
|
+
const resp = {
|
|
134
|
+
to: P.address,
|
|
135
|
+
data: zk.encodeTransact(args, tx.extData),
|
|
136
|
+
value: P.isNative ? amount.toString() : '0',
|
|
137
|
+
newBalance: fmt(P, tx.newBalance),
|
|
138
|
+
symbol: P.symbol,
|
|
139
|
+
}
|
|
140
|
+
if (!P.isNative) {
|
|
141
|
+
const a = await request('GET', `/api/zk/allowance?chain=${P.chain}&pool=${P.pool}&owner=${b.address}`)
|
|
142
|
+
if (BigInt(a.allowance) < amount) resp.approveTx = { to: P.token, data: ERC20.encodeFunctionData('approve', [P.address, amount.toString()]), value: '0' }
|
|
143
|
+
}
|
|
144
|
+
return resp
|
|
145
|
+
},
|
|
146
|
+
|
|
147
|
+
async '/api/swap'(b) {
|
|
148
|
+
if (!b.signature) throw new Error('missing signature')
|
|
149
|
+
const q = await request('POST', '/api/swap/quote', {
|
|
150
|
+
fromAsset: b.fromAsset, toAsset: b.toAsset, amount: b.amount, recipient: b.recipient, refundTo: b.refundTo, slippage: b.slippage,
|
|
151
|
+
...(b.chain && b.pool ? { chain: b.chain, pool: b.pool } : {}),
|
|
152
|
+
})
|
|
153
|
+
const P = await poolOf({ chain: q.chain, pool: q.pool })
|
|
154
|
+
const keys = await keysFor(b.signature)
|
|
155
|
+
const acc = await account(keys, P)
|
|
156
|
+
if (!acc.notes.length) throw new Error(`your private ${P.symbol} balance on ${P.chain} is empty — switch to the pool that holds your funds, or deposit ${P.symbol} first`)
|
|
157
|
+
let tx
|
|
158
|
+
try { tx = await zk.prepareWithdraw({ keys, account: acc, amount: BigInt(q.amountWei), recipient: q.depositAddress, flatFeeWei: P.flatFeeWei, feeRecipient: P.feeRecipient }) }
|
|
159
|
+
catch (e) { throw friendly(e, P) }
|
|
160
|
+
const r = await relay(P, tx, 'swap', { authSig: await registryAuthSig(b.signature) })
|
|
161
|
+
return { success: r.success, txHash: r.txHash, depositAddress: r.depositAddress, amountOut: r.amountOut, timeEstimate: r.timeEstimate, statusUrl: r.statusUrl }
|
|
162
|
+
},
|
|
163
|
+
|
|
164
|
+
async '/api/swap-private'(b) {
|
|
165
|
+
if (!b.burnerAddress || !/^0x[a-fA-F0-9]{40}$/.test(b.burnerAddress)) throw new Error('invalid burnerAddress')
|
|
166
|
+
if (!b.amount || isNaN(Number(b.amount)) || Number(b.amount) <= 0) throw new Error('invalid amount')
|
|
167
|
+
const P = await poolOf(b)
|
|
168
|
+
const keys = await keysFor(b.signature)
|
|
169
|
+
const acc = await account(keys, P)
|
|
170
|
+
if (!acc.notes.length) throw new Error('no unspent UTXOs')
|
|
171
|
+
let tx
|
|
172
|
+
try { tx = await zk.prepareWithdraw({ keys, account: acc, amount: units(P, b.amount), recipient: b.burnerAddress, flatFeeWei: P.flatFeeWei, feeRecipient: P.feeRecipient }) }
|
|
173
|
+
catch (e) { throw friendly(e, P) }
|
|
174
|
+
const r = await relay(P, tx, 'swap-private')
|
|
175
|
+
if (!r.success) throw new Error('withdraw to burner failed')
|
|
176
|
+
return {
|
|
177
|
+
success: true, step: 'withdrawn_to_burner', txHash: r.txHash, burnerAddress: b.burnerAddress, originToken: P.symbol,
|
|
178
|
+
originIsNative: !!P.isNative, burnerReceives: fmt(P, tx.receives), burnerReceivesEth: P.isNative ? fmt(P, tx.receives) : undefined,
|
|
179
|
+
gasReserveEth: String(b.gasBuffer || '0.00005'), needsGasTopup: !P.isNative, fee: fmt(P, tx.fee),
|
|
180
|
+
}
|
|
181
|
+
},
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
return {
|
|
185
|
+
handles: (path) => LOCAL_PATHS.has(path),
|
|
186
|
+
async handle(path, body) {
|
|
187
|
+
const h = handlers[path]
|
|
188
|
+
if (!h) throw new Error(`no local handler for ${path}`)
|
|
189
|
+
if (!body || !body.signature) throw new Error('missing signature')
|
|
190
|
+
return h(body)
|
|
191
|
+
},
|
|
192
|
+
/** Forget every key derived in this session. */
|
|
193
|
+
forget() { mem.keys.clear(); mem.pending.clear() },
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
module.exports = { createLocalApi, LOCAL_PATHS, registryAuthSig, REGISTRY_AUTH_MESSAGE }
|
package/lib/zk.js
ADDED
|
@@ -0,0 +1,356 @@
|
|
|
1
|
+
'use strict'
|
|
2
|
+
// Blackpool client-side zero-knowledge core.
|
|
3
|
+
//
|
|
4
|
+
// Runs unchanged in Node (the CLI and its MCP server) and in the browser (bundled into the web app's proving
|
|
5
|
+
// worker). Key derivation, note scanning, Merkle paths, coin selection and Groth16 proving all happen here, on
|
|
6
|
+
// the user's device. The relayer only ever receives a finished proof and its public data — never the sign-in
|
|
7
|
+
// signature, the keys or the notes. Nothing in this module writes to disk or to any browser storage: every
|
|
8
|
+
// secret lives in the caller's memory for as long as the caller keeps it.
|
|
9
|
+
const { utils: U, BigNumber } = require('ethers') // ethers v5 (CLI dependency; bundled for the browser)
|
|
10
|
+
const { poseidon1, poseidon2, poseidon3, poseidon4 } = require('poseidon-lite')
|
|
11
|
+
|
|
12
|
+
const FIELD_SIZE = 21888242871839275222246405745257275088548364400416034343698204186575808495617n
|
|
13
|
+
const TREE_HEIGHT = 26
|
|
14
|
+
// Merkle zeros(0) of every Blackpool pool: keccak256("privacyblackpool") % FIELD_SIZE.
|
|
15
|
+
const ZERO_LEAF = 13957331740549083428539157647087363326246483135047439495076507042642183216086n
|
|
16
|
+
const FEE_RATE_BPS = 35n // must match the relayer
|
|
17
|
+
|
|
18
|
+
const SIGN_IN_MESSAGE =
|
|
19
|
+
'Welcome to Blackpool\n\nSign this message to access your private account.\n\n- Gas free: no transaction is sent\n- Your private account keys are derived from this signature\n- It cannot move funds from your wallet or approve spending\n- Only sign it on blackpool.cash or the Blackpool CLI'
|
|
20
|
+
|
|
21
|
+
const POOL_ABI = [
|
|
22
|
+
'function transact((uint256[2] pA,uint256[2][2] pB,uint256[2] pC,bytes32 root,bytes32[2] inputNullifiers,bytes32[2] outputCommitments,uint256 publicAmount,bytes32 extDataHash) _args,(address recipient,int256 extAmount,address feeRecipient,uint256 fee,bytes encryptedOutput1,bytes encryptedOutput2) _extData) payable',
|
|
23
|
+
'function isKnownRoot(bytes32) view returns (bool)',
|
|
24
|
+
]
|
|
25
|
+
const POOL_IFACE = new U.Interface(POOL_ABI)
|
|
26
|
+
|
|
27
|
+
// ---------- primitives ----------
|
|
28
|
+
|
|
29
|
+
function webcrypto() {
|
|
30
|
+
if (globalThis.crypto && globalThis.crypto.subtle) return globalThis.crypto
|
|
31
|
+
return require('crypto').webcrypto // Node 18
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const big = (x) => (typeof x === 'bigint' ? x : BigInt(BigNumber.from(x).toString()))
|
|
35
|
+
const mod = (x) => ((x % FIELD_SIZE) + FIELD_SIZE) % FIELD_SIZE
|
|
36
|
+
|
|
37
|
+
function poseidon(items) {
|
|
38
|
+
const f = [null, poseidon1, poseidon2, poseidon3, poseidon4][items.length]
|
|
39
|
+
if (!f) throw new Error(`unsupported poseidon arity ${items.length}`)
|
|
40
|
+
return f(items.map((x) => (typeof x === 'bigint' ? x : big(x))))
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** 0x-prefixed, zero-padded hex. Negative numbers become '-0x…' (how ethers v5 reads signed ints). */
|
|
44
|
+
function toHex(x, bytes = 32) {
|
|
45
|
+
const v = big(x)
|
|
46
|
+
const h = (v < 0n ? -v : v).toString(16).padStart(bytes * 2, '0')
|
|
47
|
+
return (v < 0n ? '-0x' : '0x') + h
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function randomField(nbytes = 31) {
|
|
51
|
+
const b = new Uint8Array(nbytes)
|
|
52
|
+
webcrypto().getRandomValues(b)
|
|
53
|
+
return BigInt(U.hexlify(b))
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function randomPrivkey() {
|
|
57
|
+
const b = new Uint8Array(32)
|
|
58
|
+
webcrypto().getRandomValues(b)
|
|
59
|
+
return U.hexlify(b)
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// ---------- keys ----------
|
|
63
|
+
|
|
64
|
+
class Keypair {
|
|
65
|
+
constructor(privkey = randomPrivkey()) {
|
|
66
|
+
this.privkey = privkey
|
|
67
|
+
this.pubkey = poseidon([privkey])
|
|
68
|
+
}
|
|
69
|
+
sign(commitment, index) {
|
|
70
|
+
return poseidon([this.privkey, commitment, index])
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Keys of a private account, derived from the sign-in signature exactly as before:
|
|
76
|
+
* encryptionKey = keccak256(signature), utxoPrivateKey = keccak256(encryptionKey).
|
|
77
|
+
*/
|
|
78
|
+
async function deriveKeys(signature) {
|
|
79
|
+
if (!/^0x[0-9a-fA-F]{130}$/.test(String(signature))) throw new Error('invalid sign-in signature')
|
|
80
|
+
const encKeyHex = U.keccak256(signature)
|
|
81
|
+
const encKey = U.arrayify(encKeyHex)
|
|
82
|
+
const keypair = new Keypair(U.keccak256(encKey))
|
|
83
|
+
const aesKey = await webcrypto().subtle.importKey('raw', encKey, { name: 'AES-GCM' }, false, ['encrypt', 'decrypt'])
|
|
84
|
+
return { keypair, aesKey, pubkey: toHex(keypair.pubkey) }
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// AES-256-GCM in the format the pools have always used: IV(12) || tag(16) || ciphertext.
|
|
88
|
+
async function encrypt(aesKey, text) {
|
|
89
|
+
const iv = new Uint8Array(12)
|
|
90
|
+
webcrypto().getRandomValues(iv)
|
|
91
|
+
const out = new Uint8Array(await webcrypto().subtle.encrypt({ name: 'AES-GCM', iv, tagLength: 128 }, aesKey, new TextEncoder().encode(text)))
|
|
92
|
+
const ct = out.slice(0, out.length - 16)
|
|
93
|
+
const tag = out.slice(out.length - 16)
|
|
94
|
+
return U.hexlify(U.concat([iv, tag, ct]))
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
async function decrypt(aesKey, hex) {
|
|
98
|
+
const buf = U.arrayify(hex)
|
|
99
|
+
if (buf.length < 29) throw new Error('ciphertext too short')
|
|
100
|
+
const iv = buf.slice(0, 12)
|
|
101
|
+
const tag = buf.slice(12, 28)
|
|
102
|
+
const ct = buf.slice(28)
|
|
103
|
+
const plain = await webcrypto().subtle.decrypt({ name: 'AES-GCM', iv, tagLength: 128 }, aesKey, U.concat([ct, tag]))
|
|
104
|
+
return new TextDecoder().decode(plain)
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// ---------- notes ----------
|
|
108
|
+
|
|
109
|
+
class Note {
|
|
110
|
+
constructor({ amount = 0n, keypair = new Keypair(), blinding = randomField(), index = null, mint = 0n } = {}) {
|
|
111
|
+
this.amount = big(amount)
|
|
112
|
+
this.keypair = keypair
|
|
113
|
+
this.blinding = big(blinding)
|
|
114
|
+
this.index = index
|
|
115
|
+
this.mint = big(mint)
|
|
116
|
+
}
|
|
117
|
+
commitment() {
|
|
118
|
+
return poseidon([this.amount, this.keypair.pubkey, this.blinding, this.mint])
|
|
119
|
+
}
|
|
120
|
+
nullifier() {
|
|
121
|
+
if (this.amount > 0n && this.index == null) throw new Error('nullifier needs the note index')
|
|
122
|
+
const c = this.commitment()
|
|
123
|
+
const i = BigInt(this.index || 0)
|
|
124
|
+
return poseidon([c, i, this.keypair.sign(c, i)])
|
|
125
|
+
}
|
|
126
|
+
encrypt(aesKey) {
|
|
127
|
+
return encrypt(aesKey, `${this.amount}|${this.blinding}|${this.index || 0}|${this.mint}`)
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* Find this account's unspent notes in a pool.
|
|
133
|
+
* entries: [{ index, commitment, encryptedOutput }] (public, from the indexer)
|
|
134
|
+
* spent: Set of spent nullifiers as lowercase 0x-hex (public NewNullifier events)
|
|
135
|
+
*/
|
|
136
|
+
async function scanNotes({ keys, entries, spent }) {
|
|
137
|
+
const mine = []
|
|
138
|
+
for (const e of entries) {
|
|
139
|
+
let parts
|
|
140
|
+
try { parts = (await decrypt(keys.aesKey, e.encryptedOutput)).split('|') } catch { continue }
|
|
141
|
+
if (parts.length !== 4) continue
|
|
142
|
+
let note
|
|
143
|
+
try {
|
|
144
|
+
note = new Note({ amount: BigInt(parts[0]), blinding: BigInt(parts[1]), keypair: keys.keypair, index: Number(e.index), mint: BigInt(parts[3]) })
|
|
145
|
+
} catch { continue }
|
|
146
|
+
if (note.amount === 0n) continue
|
|
147
|
+
if (toHex(note.commitment()) !== toHex(e.commitment)) continue
|
|
148
|
+
if (spent && spent.has(toHex(note.nullifier()).toLowerCase())) continue
|
|
149
|
+
mine.push(note)
|
|
150
|
+
}
|
|
151
|
+
return mine
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
// ---------- Merkle tree (height 26, Poseidon, Blackpool zero leaf) ----------
|
|
155
|
+
|
|
156
|
+
const ZEROS = (() => {
|
|
157
|
+
const z = [ZERO_LEAF]
|
|
158
|
+
for (let i = 1; i <= TREE_HEIGHT; i++) z.push(poseidon([z[i - 1], z[i - 1]]))
|
|
159
|
+
return z
|
|
160
|
+
})()
|
|
161
|
+
|
|
162
|
+
/** Builds every non-empty layer once; path() is then O(height). leaves are in index order. */
|
|
163
|
+
function buildTree(leaves) {
|
|
164
|
+
const layers = [leaves.map(big)]
|
|
165
|
+
for (let level = 0; level < TREE_HEIGHT; level++) {
|
|
166
|
+
const cur = layers[level]
|
|
167
|
+
const next = []
|
|
168
|
+
for (let i = 0; i < cur.length; i += 2) next.push(poseidon([cur[i], i + 1 < cur.length ? cur[i + 1] : ZEROS[level]]))
|
|
169
|
+
layers.push(next)
|
|
170
|
+
}
|
|
171
|
+
const root = layers[TREE_HEIGHT].length ? layers[TREE_HEIGHT][0] : ZEROS[TREE_HEIGHT]
|
|
172
|
+
return {
|
|
173
|
+
root,
|
|
174
|
+
size: leaves.length,
|
|
175
|
+
path(index) {
|
|
176
|
+
if (index < 0 || index >= leaves.length) throw new Error(`leaf ${index} not in tree`)
|
|
177
|
+
const pathElements = []
|
|
178
|
+
let idx = index
|
|
179
|
+
for (let level = 0; level < TREE_HEIGHT; level++) {
|
|
180
|
+
const sib = idx ^ 1
|
|
181
|
+
pathElements.push(sib < layers[level].length ? layers[level][sib] : ZEROS[level])
|
|
182
|
+
idx >>= 1
|
|
183
|
+
}
|
|
184
|
+
return pathElements
|
|
185
|
+
},
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
// ---------- fees and coin selection ----------
|
|
190
|
+
|
|
191
|
+
/** The relayer fee for spending `gross` out of the pool (same formula the relayer enforces). */
|
|
192
|
+
function relayFee(gross, flatFeeWei) {
|
|
193
|
+
return big(flatFeeWei) + (big(gross) * FEE_RATE_BPS) / 10000n
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/** The 2-in circuit can spend at most two notes; take the two largest. */
|
|
197
|
+
function pickInputs(notes, amount) {
|
|
198
|
+
const sorted = [...notes].sort((a, b) => (a.amount < b.amount ? 1 : a.amount > b.amount ? -1 : 0))
|
|
199
|
+
const inputs = sorted.slice(0, 2)
|
|
200
|
+
const sum = inputs.reduce((s, n) => s + n.amount, 0n)
|
|
201
|
+
const total = notes.reduce((s, n) => s + n.amount, 0n)
|
|
202
|
+
if (sum < big(amount)) {
|
|
203
|
+
const err = new Error(total >= big(amount)
|
|
204
|
+
? `max ${sum} per transaction (funds split across ${notes.length} notes; spend the rest separately)`
|
|
205
|
+
: `insufficient balance: have ${sum}`)
|
|
206
|
+
err.code = total >= big(amount) ? 'FRAGMENTED' : 'INSUFFICIENT'
|
|
207
|
+
err.maxSpendable = sum
|
|
208
|
+
throw err
|
|
209
|
+
}
|
|
210
|
+
return { inputs, sum }
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
// ---------- transaction building + proving ----------
|
|
214
|
+
|
|
215
|
+
function extDataHash(ext) {
|
|
216
|
+
const enc = U.defaultAbiCoder.encode(
|
|
217
|
+
['tuple(address recipient,int256 extAmount,address feeRecipient,uint256 fee,bytes encryptedOutput1,bytes encryptedOutput2)'],
|
|
218
|
+
[ext],
|
|
219
|
+
)
|
|
220
|
+
return mod(BigInt(U.keccak256(enc)))
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
/**
|
|
224
|
+
* Builds the circuit input and the transaction's public data.
|
|
225
|
+
* tree: buildTree() over the pool's current leaves. nextIndex = tree.size.
|
|
226
|
+
*/
|
|
227
|
+
async function buildTransaction({ keys, tree, inputs, outputs, fee = 0n, recipient, feeRecipient }) {
|
|
228
|
+
if (inputs.length > 2 || outputs.length > 2) throw new Error('at most 2 inputs and 2 outputs')
|
|
229
|
+
inputs = [...inputs]; outputs = [...outputs]
|
|
230
|
+
while (inputs.length < 2) inputs.push(new Note())
|
|
231
|
+
while (outputs.length < 2) outputs.push(new Note())
|
|
232
|
+
fee = big(fee)
|
|
233
|
+
const extAmount = fee + outputs.reduce((s, n) => s + n.amount, 0n) - inputs.reduce((s, n) => s + n.amount, 0n)
|
|
234
|
+
|
|
235
|
+
const inPathIndices = []
|
|
236
|
+
const inPathElements = []
|
|
237
|
+
for (const n of inputs) {
|
|
238
|
+
if (n.amount > 0n) {
|
|
239
|
+
inPathIndices.push(n.index)
|
|
240
|
+
inPathElements.push(tree.path(n.index).map(String))
|
|
241
|
+
} else {
|
|
242
|
+
inPathIndices.push(0)
|
|
243
|
+
inPathElements.push(new Array(TREE_HEIGHT).fill('0'))
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
let next = tree.size
|
|
247
|
+
for (const o of outputs) o.index = next++
|
|
248
|
+
|
|
249
|
+
const ext = {
|
|
250
|
+
recipient: U.getAddress(toHex(big(recipient || 0), 20)),
|
|
251
|
+
extAmount: toHex(extAmount),
|
|
252
|
+
feeRecipient: U.getAddress(toHex(big(feeRecipient || 0), 20)),
|
|
253
|
+
fee: toHex(fee),
|
|
254
|
+
encryptedOutput1: await outputs[0].encrypt(keys.aesKey),
|
|
255
|
+
encryptedOutput2: await outputs[1].encrypt(keys.aesKey),
|
|
256
|
+
}
|
|
257
|
+
const hash = extDataHash(ext)
|
|
258
|
+
const publicAmount = mod(extAmount - fee)
|
|
259
|
+
const nullifiers = inputs.map((n) => n.nullifier())
|
|
260
|
+
const commitments = outputs.map((n) => n.commitment())
|
|
261
|
+
|
|
262
|
+
const circuitInput = {
|
|
263
|
+
root: tree.root.toString(),
|
|
264
|
+
inputNullifier: nullifiers.map(String),
|
|
265
|
+
outputCommitment: commitments.map(String),
|
|
266
|
+
publicAmount: publicAmount.toString(),
|
|
267
|
+
extDataHash: hash.toString(),
|
|
268
|
+
mintAddress: inputs[0].mint.toString(),
|
|
269
|
+
inAmount: inputs.map((n) => n.amount.toString()),
|
|
270
|
+
inPrivateKey: inputs.map((n) => big(n.keypair.privkey).toString()),
|
|
271
|
+
inBlinding: inputs.map((n) => n.blinding.toString()),
|
|
272
|
+
inPathIndices,
|
|
273
|
+
inPathElements,
|
|
274
|
+
outAmount: outputs.map((n) => n.amount.toString()),
|
|
275
|
+
outBlinding: outputs.map((n) => n.blinding.toString()),
|
|
276
|
+
outPubkey: outputs.map((n) => n.keypair.pubkey.toString()),
|
|
277
|
+
}
|
|
278
|
+
const publicArgs = {
|
|
279
|
+
root: toHex(tree.root),
|
|
280
|
+
inputNullifiers: nullifiers.map((x) => toHex(x)),
|
|
281
|
+
outputCommitments: commitments.map((x) => toHex(x)),
|
|
282
|
+
publicAmount: toHex(publicAmount),
|
|
283
|
+
extDataHash: toHex(hash),
|
|
284
|
+
}
|
|
285
|
+
return { circuitInput, publicArgs, extData: ext, extAmount }
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
/**
|
|
289
|
+
* Groth16 proof with snarkjs. wasm/zkey: Uint8Array (or a path in Node). singleThread avoids nested workers.
|
|
290
|
+
*/
|
|
291
|
+
async function prove(circuitInput, { wasm, zkey, snarkjs, singleThread = false }) {
|
|
292
|
+
const sj = snarkjs || require('snarkjs')
|
|
293
|
+
const { proof } = await sj.groth16.fullProve(circuitInput, wasm, zkey, undefined, undefined, { singleThread })
|
|
294
|
+
return {
|
|
295
|
+
pA: [toHex(proof.pi_a[0]), toHex(proof.pi_a[1])],
|
|
296
|
+
pB: [
|
|
297
|
+
[toHex(proof.pi_b[0][1]), toHex(proof.pi_b[0][0])],
|
|
298
|
+
[toHex(proof.pi_b[1][1]), toHex(proof.pi_b[1][0])],
|
|
299
|
+
],
|
|
300
|
+
pC: [toHex(proof.pi_c[0]), toHex(proof.pi_c[1])],
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
/** Calldata for a self-broadcast deposit (the user's wallet sends it; value = extAmount for native pools). */
|
|
305
|
+
function encodeTransact(args, extData) {
|
|
306
|
+
return POOL_IFACE.encodeFunctionData('transact', [args, extData])
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
// ---------- high-level flows (pure: the caller supplies pool data and circuit files) ----------
|
|
310
|
+
|
|
311
|
+
/**
|
|
312
|
+
* poolData: { root, entries, nullifiers } from GET /api/zk/pool. Rebuilds the tree locally and refuses to
|
|
313
|
+
* continue if it does not reproduce the root the pool reported (stale or inconsistent indexer data).
|
|
314
|
+
*/
|
|
315
|
+
async function loadAccount({ keys, poolData }) {
|
|
316
|
+
const entries = [...poolData.entries].sort((a, b) => a.index - b.index)
|
|
317
|
+
entries.forEach((e, i) => { if (Number(e.index) !== i) throw new Error(`indexer data has a gap at leaf ${i}; retry in a few seconds`) })
|
|
318
|
+
const tree = buildTree(entries.map((e) => e.commitment))
|
|
319
|
+
if (poolData.root && toHex(tree.root).toLowerCase() !== String(poolData.root).toLowerCase()) {
|
|
320
|
+
throw new Error('pool data is out of sync (root mismatch); retry in a few seconds')
|
|
321
|
+
}
|
|
322
|
+
const spent = new Set((poolData.nullifiers || []).map((n) => toHex(n).toLowerCase()))
|
|
323
|
+
const notes = await scanNotes({ keys, entries, spent })
|
|
324
|
+
const balance = notes.reduce((s, n) => s + n.amount, 0n)
|
|
325
|
+
return { tree, notes, balance }
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
/** Shield `amount` (merging the oldest existing note so the balance stays in few notes). */
|
|
329
|
+
async function prepareDeposit({ keys, account, amount, depositor, feeRecipient }) {
|
|
330
|
+
const inputs = account.notes.length ? [account.notes.slice().sort((a, b) => a.index - b.index)[0]] : []
|
|
331
|
+
const inSum = inputs.reduce((s, n) => s + n.amount, 0n)
|
|
332
|
+
const outputs = [new Note({ amount: inSum + big(amount), keypair: keys.keypair })]
|
|
333
|
+
const tx = await buildTransaction({ keys, tree: account.tree, inputs, outputs, fee: 0n, recipient: depositor, feeRecipient })
|
|
334
|
+
return { ...tx, newBalance: account.balance + big(amount) }
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
/** Spend `amount` (gross, before the relayer fee) to `recipient`; change stays private. */
|
|
338
|
+
async function prepareWithdraw({ keys, account, amount, recipient, flatFeeWei, feeRecipient }) {
|
|
339
|
+
amount = big(amount)
|
|
340
|
+
const fee = relayFee(amount, flatFeeWei)
|
|
341
|
+
if (fee * 2n >= amount) {
|
|
342
|
+
const err = new Error('amount must be more than twice the fee'); err.code = 'TOO_SMALL'; err.minimum = fee * 2n; throw err
|
|
343
|
+
}
|
|
344
|
+
const { inputs, sum } = pickInputs(account.notes, amount)
|
|
345
|
+
const change = sum - amount
|
|
346
|
+
const outputs = change > 0n ? [new Note({ amount: change, keypair: keys.keypair })] : []
|
|
347
|
+
const tx = await buildTransaction({ keys, tree: account.tree, inputs, outputs, fee, recipient, feeRecipient })
|
|
348
|
+
return { ...tx, fee, receives: amount - fee, change }
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
module.exports = {
|
|
352
|
+
FIELD_SIZE, TREE_HEIGHT, ZERO_LEAF, ZEROS, FEE_RATE_BPS, SIGN_IN_MESSAGE, POOL_ABI,
|
|
353
|
+
poseidon, toHex, Keypair, Note, deriveKeys, encrypt, decrypt, scanNotes, buildTree,
|
|
354
|
+
relayFee, pickInputs, extDataHash, buildTransaction, prove, encodeTransact,
|
|
355
|
+
loadAccount, prepareDeposit, prepareWithdraw,
|
|
356
|
+
}
|
package/package.json
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@blackpool/cli",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.4.0",
|
|
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
|
+
"mcpName": "cash.blackpool/blackpool",
|
|
5
6
|
"bin": {
|
|
6
7
|
"blackpool": "bin/blackpool.js"
|
|
7
8
|
},
|
|
@@ -11,11 +12,14 @@
|
|
|
11
12
|
},
|
|
12
13
|
"files": [
|
|
13
14
|
"bin/",
|
|
15
|
+
"lib/",
|
|
14
16
|
"README.md",
|
|
15
17
|
"AGENTS.md"
|
|
16
18
|
],
|
|
17
19
|
"dependencies": {
|
|
18
|
-
"ethers": "^5.7.2"
|
|
20
|
+
"ethers": "^5.7.2",
|
|
21
|
+
"poseidon-lite": "^0.3.0",
|
|
22
|
+
"snarkjs": "0.7.5"
|
|
19
23
|
},
|
|
20
24
|
"keywords": [
|
|
21
25
|
"mcp",
|