@zbase-protocol/core 0.1.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.
@@ -0,0 +1,422 @@
1
+ /**
2
+ * @zbase-protocol/core — WebSocket UTXO note scanner (Phase 1B)
3
+ *
4
+ * Subscribes to a UTXOPool's `Spent` and `Transferred` events over a WS RPC,
5
+ * trial-decrypts every transferred ciphertext with the user's viewing key,
6
+ * and yields decrypted notes as an async iterable. Wallets and facilitators
7
+ * use this to track balance + spendable notes without polling.
8
+ *
9
+ * Naming: this file is `noteScanner.ts` (NOT `scanner.ts`) because
10
+ * `scanner.ts` is already in use for the chain-agnostic x402-exposure scorer.
11
+ * The two scanners do unrelated things; consolidating later is a separate
12
+ * refactor and would break public API.
13
+ *
14
+ * Transport: we depend ONLY on the WHATWG `WebSocket` global (available in
15
+ * browser, Node ≥22 native, Cloudflare Workers, Bun). No `ethers` or `viem`
16
+ * import is needed for the WS layer — both libraries would force a heavier
17
+ * dependency surface and we already speak JSON-RPC. ABI encoding/decoding of
18
+ * event logs is done by hand for the two specific events we care about. If
19
+ * this proves brittle, the next iteration can swap to viem's `decodeEventLog`
20
+ * (already a root dependency of the consuming Next.js app).
21
+ *
22
+ * Reconnect policy: exponential backoff capped at 30 s. The async iterable
23
+ * yields a `{type:"connected"}` marker on every successful (re)connect so
24
+ * callers can persist the last-seen block and replay if they care about
25
+ * historical correctness across disconnects.
26
+ */
27
+ import { keccak_256 } from "@noble/hashes/sha3";
28
+ import { decryptNoteWithAAD, } from "./notes.js";
29
+ // -----------------------------------------------------------------------------
30
+ // Topic hashes (keccak256 of canonical event signatures, hex-prefixed)
31
+ // -----------------------------------------------------------------------------
32
+ function toHex(bytes) {
33
+ return "0x" + Array.from(bytes, (b) => b.toString(16).padStart(2, "0")).join("");
34
+ }
35
+ function keccakTopic(signature) {
36
+ return toHex(keccak_256(new TextEncoder().encode(signature)));
37
+ }
38
+ /**
39
+ * Spent(uint256,uint256,uint256,uint256,uint256)
40
+ * Two indexed nullifier hashes + non-indexed (amount, commitment0, commitment1).
41
+ */
42
+ export const SPENT_TOPIC = keccakTopic("Spent(uint256,uint256,uint256,uint256,uint256)");
43
+ /**
44
+ * Transferred(uint256,uint256,uint256,uint256,(bytes32[4],bytes32,bytes32,bytes32,bytes32),(bytes32[4],bytes32,bytes32,bytes32,bytes32))
45
+ *
46
+ * The two `CommitmentCiphertext` structs are ABI-encoded as tuples. This is
47
+ * the canonical event-signature form Solidity uses for keccak256(topic[0]).
48
+ */
49
+ export const TRANSFERRED_TOPIC = keccakTopic("Transferred(uint256,uint256,uint256,uint256,(bytes32[4],bytes32,bytes32,bytes32,bytes32),(bytes32[4],bytes32,bytes32,bytes32,bytes32))");
50
+ const DEFAULT_RECONNECT_BACKOFF_MS = [
51
+ 1_000, 2_000, 4_000, 8_000, 16_000, 30_000,
52
+ ];
53
+ // -----------------------------------------------------------------------------
54
+ // Log decoding (Phase 1B: hand-decode, swap to viem if maintenance burden grows)
55
+ // -----------------------------------------------------------------------------
56
+ function hexToBytes(hex) {
57
+ const s = hex.startsWith("0x") ? hex.slice(2) : hex;
58
+ if (s.length % 2 !== 0) {
59
+ throw new Error(`hexToBytes: odd-length hex ${hex}`);
60
+ }
61
+ const out = new Uint8Array(s.length / 2);
62
+ for (let i = 0; i < out.length; i++) {
63
+ out[i] = parseInt(s.slice(i * 2, i * 2 + 2), 16);
64
+ }
65
+ return out;
66
+ }
67
+ function hexToBigint(hex) {
68
+ return BigInt(hex);
69
+ }
70
+ function readWord(bytes, wordIndex) {
71
+ const start = wordIndex * 32;
72
+ if (start + 32 > bytes.length) {
73
+ throw new Error(`readWord: out-of-range word ${wordIndex} (bytes length ${bytes.length})`);
74
+ }
75
+ return bytes.slice(start, start + 32);
76
+ }
77
+ function readWordAsBigint(bytes, wordIndex) {
78
+ const w = readWord(bytes, wordIndex);
79
+ let v = 0n;
80
+ for (const b of w)
81
+ v = (v << 8n) | BigInt(b);
82
+ return v;
83
+ }
84
+ /**
85
+ * Decode the data field of a `Spent` log:
86
+ * words: [withdrawnAmount, outputCommitment0, outputCommitment1]
87
+ * The two indexed nullifier hashes live in `topics[1..3]`, not in data.
88
+ */
89
+ export function decodeSpentLog(log) {
90
+ if (log.topics.length < 3) {
91
+ throw new Error("decodeSpentLog: expected 3 topics (signature + 2 indexed)");
92
+ }
93
+ const data = hexToBytes(log.data);
94
+ return {
95
+ nullifierHash0: hexToBigint(log.topics[1]),
96
+ nullifierHash1: hexToBigint(log.topics[2]),
97
+ withdrawnAmount: readWordAsBigint(data, 0),
98
+ outputCommitment0: readWordAsBigint(data, 1),
99
+ outputCommitment1: readWordAsBigint(data, 2),
100
+ blockNumber: hexToBigint(log.blockNumber),
101
+ txHash: log.transactionHash,
102
+ logIndex: Number(hexToBigint(log.logIndex)),
103
+ };
104
+ }
105
+ export function decodeTransferredLog(log) {
106
+ if (log.topics.length < 3) {
107
+ throw new Error("decodeTransferredLog: expected 3 topics (signature + 2 indexed)");
108
+ }
109
+ const data = hexToBytes(log.data);
110
+ const outputCommitment0 = readWordAsBigint(data, 0);
111
+ const outputCommitment1 = readWordAsBigint(data, 1);
112
+ // ciphertext0 occupies words 2..9, ciphertext1 occupies 10..17.
113
+ const ct0Blob = new Uint8Array(128);
114
+ for (let i = 0; i < 4; i++)
115
+ ct0Blob.set(readWord(data, 2 + i), i * 32);
116
+ const ct0Aad = readWord(data, 9);
117
+ const ct1Blob = new Uint8Array(128);
118
+ for (let i = 0; i < 4; i++)
119
+ ct1Blob.set(readWord(data, 10 + i), i * 32);
120
+ const ct1Aad = readWord(data, 17);
121
+ return {
122
+ nullifierHash0: hexToBigint(log.topics[1]),
123
+ nullifierHash1: hexToBigint(log.topics[2]),
124
+ outputCommitment0,
125
+ outputCommitment1,
126
+ ciphertext0Blob: ct0Blob,
127
+ ciphertext0Aad: ct0Aad,
128
+ ciphertext1Blob: ct1Blob,
129
+ ciphertext1Aad: ct1Aad,
130
+ blockNumber: hexToBigint(log.blockNumber),
131
+ txHash: log.transactionHash,
132
+ logIndex: Number(hexToBigint(log.logIndex)),
133
+ };
134
+ }
135
+ /**
136
+ * Trial-decrypt both ciphertexts of a Transferred log against the viewing
137
+ * key. Returns the matching event or `null` if neither ciphertext belongs to
138
+ * this wallet.
139
+ *
140
+ * The encrypted blob fed to `decryptNoteWithAAD` is the on-chain wire format
141
+ * that `encryptNoteForTransfer` produces — see notes.ts `EncryptedNote`
142
+ * layout. The contract stores it spread across four bytes32 words; we
143
+ * reassemble it back into the linear byte buffer here.
144
+ *
145
+ * Phase 1B caveat: the v0 `encryptNote` envelope (ephPub|viewTag|nonce|ct)
146
+ * does not yet fit neatly inside bytes32[4]. A follow-up will define a
147
+ * compact wire layout; the v0 wire is the linear blob and that's what we
148
+ * decode here. The hand-decoder above is a placeholder that will need to
149
+ * track that schema once it's frozen.
150
+ */
151
+ export function tryDecryptTransferred(decoded, viewingPrivateKey) {
152
+ // Try output 0
153
+ const note0 = decryptNoteWithAAD(decoded.ciphertext0Blob, viewingPrivateKey, decoded.outputCommitment0);
154
+ if (note0) {
155
+ return {
156
+ type: "transferred-in",
157
+ outputIndex: 0,
158
+ commitment: decoded.outputCommitment0,
159
+ note: note0,
160
+ siblingNullifierHash: decoded.nullifierHash1,
161
+ blockNumber: decoded.blockNumber,
162
+ txHash: decoded.txHash,
163
+ logIndex: decoded.logIndex,
164
+ };
165
+ }
166
+ // Try output 1
167
+ const note1 = decryptNoteWithAAD(decoded.ciphertext1Blob, viewingPrivateKey, decoded.outputCommitment1);
168
+ if (note1) {
169
+ return {
170
+ type: "transferred-in",
171
+ outputIndex: 1,
172
+ commitment: decoded.outputCommitment1,
173
+ note: note1,
174
+ siblingNullifierHash: decoded.nullifierHash0,
175
+ blockNumber: decoded.blockNumber,
176
+ txHash: decoded.txHash,
177
+ logIndex: decoded.logIndex,
178
+ };
179
+ }
180
+ return {
181
+ type: "transferred-other",
182
+ outputCommitment0: decoded.outputCommitment0,
183
+ outputCommitment1: decoded.outputCommitment1,
184
+ blockNumber: decoded.blockNumber,
185
+ txHash: decoded.txHash,
186
+ logIndex: decoded.logIndex,
187
+ };
188
+ }
189
+ /**
190
+ * Create an async iterable scanner over a UTXOPool's events.
191
+ *
192
+ * Usage:
193
+ * ```ts
194
+ * for await (const ev of createScanner({ wsUrl, utxoPoolAddress, viewingKey })) {
195
+ * if (ev.type === "transferred-in") {
196
+ * wallet.addNote(ev.commitment, ev.note);
197
+ * } else if (ev.type === "spent") {
198
+ * wallet.markSpent(ev.nullifierHash0, ev.nullifierHash1);
199
+ * }
200
+ * }
201
+ * ```
202
+ *
203
+ * Cancellation: pass `signal: AbortSignal` (or break out of the for-await).
204
+ * On abort, the underlying WebSocket is closed cleanly.
205
+ */
206
+ export async function* createScanner(options) {
207
+ const backoff = options.reconnectBackoffMs && options.reconnectBackoffMs.length > 0
208
+ ? options.reconnectBackoffMs
209
+ : DEFAULT_RECONNECT_BACKOFF_MS;
210
+ const WsCtor = options.webSocketCtor ?? globalThis.WebSocket;
211
+ if (!WsCtor) {
212
+ throw new Error("createScanner: WebSocket constructor not available; pass options.webSocketCtor explicitly");
213
+ }
214
+ let reconnectAttempt = 0;
215
+ let lastSeenBlock = options.fromBlock ?? 0n;
216
+ outer: while (!options.signal?.aborted) {
217
+ let ws;
218
+ try {
219
+ ws = new WsCtor(options.wsUrl);
220
+ }
221
+ catch (err) {
222
+ yield { type: "error", message: "websocket-open-failed", cause: err };
223
+ const wait = backoff[Math.min(reconnectAttempt, backoff.length - 1)];
224
+ reconnectAttempt++;
225
+ await sleep(wait, options.signal);
226
+ continue;
227
+ }
228
+ const queue = new EventQueue();
229
+ let subscriptionId = null;
230
+ let nextRpcId = 1;
231
+ const onOpen = () => {
232
+ const subscribe = {
233
+ jsonrpc: "2.0",
234
+ id: nextRpcId++,
235
+ method: "eth_subscribe",
236
+ params: [
237
+ "logs",
238
+ {
239
+ address: options.utxoPoolAddress,
240
+ topics: [[SPENT_TOPIC, TRANSFERRED_TOPIC]],
241
+ },
242
+ ],
243
+ };
244
+ ws.send(JSON.stringify(subscribe));
245
+ };
246
+ const onMessage = (raw) => {
247
+ let parsed;
248
+ try {
249
+ parsed = JSON.parse(String(raw.data));
250
+ }
251
+ catch (err) {
252
+ queue.push({
253
+ type: "error",
254
+ message: "rpc-parse-failed",
255
+ cause: err,
256
+ });
257
+ return;
258
+ }
259
+ if (parsed.error) {
260
+ queue.push({
261
+ type: "error",
262
+ message: `rpc-error: ${parsed.error.message}`,
263
+ });
264
+ return;
265
+ }
266
+ if (parsed.result && typeof parsed.result === "string" && subscriptionId === null) {
267
+ subscriptionId = parsed.result;
268
+ queue.push({ type: "connected", reconnectAttempt, startBlock: lastSeenBlock });
269
+ return;
270
+ }
271
+ if (parsed.method === "eth_subscription" && parsed.params) {
272
+ const log = parsed.params.result;
273
+ if (log.removed)
274
+ return;
275
+ try {
276
+ const topic0 = log.topics[0]?.toLowerCase();
277
+ if (topic0 === SPENT_TOPIC.toLowerCase()) {
278
+ const ev = decodeSpentLog(log);
279
+ lastSeenBlock = ev.blockNumber;
280
+ queue.push({ type: "spent", ...ev });
281
+ }
282
+ else if (topic0 === TRANSFERRED_TOPIC.toLowerCase()) {
283
+ const decoded = decodeTransferredLog(log);
284
+ lastSeenBlock = decoded.blockNumber;
285
+ queue.push(tryDecryptTransferred(decoded, options.viewingKey.privateKey));
286
+ }
287
+ }
288
+ catch (err) {
289
+ queue.push({
290
+ type: "error",
291
+ message: "log-decode-failed",
292
+ cause: err,
293
+ });
294
+ }
295
+ }
296
+ };
297
+ const onClose = () => {
298
+ queue.close();
299
+ };
300
+ const onError = (err) => {
301
+ queue.push({ type: "error", message: "websocket-error", cause: err });
302
+ queue.close();
303
+ };
304
+ ws.addEventListener("open", onOpen);
305
+ ws.addEventListener("message", onMessage);
306
+ ws.addEventListener("close", onClose);
307
+ ws.addEventListener("error", onError);
308
+ const abortListener = () => {
309
+ try {
310
+ ws.close();
311
+ }
312
+ catch {
313
+ /* already closed */
314
+ }
315
+ queue.close();
316
+ };
317
+ options.signal?.addEventListener("abort", abortListener, { once: true });
318
+ try {
319
+ for await (const ev of queue) {
320
+ yield ev;
321
+ }
322
+ }
323
+ finally {
324
+ options.signal?.removeEventListener("abort", abortListener);
325
+ try {
326
+ ws.close();
327
+ }
328
+ catch {
329
+ /* ignore */
330
+ }
331
+ }
332
+ if (options.signal?.aborted)
333
+ break outer;
334
+ const wait = backoff[Math.min(reconnectAttempt, backoff.length - 1)];
335
+ reconnectAttempt++;
336
+ await sleep(wait, options.signal);
337
+ }
338
+ }
339
+ // -----------------------------------------------------------------------------
340
+ // Pure helpers used by both the live scanner and the unit tests
341
+ // -----------------------------------------------------------------------------
342
+ /**
343
+ * In-memory equivalent of the live scanner: feed it an array of already-decoded
344
+ * Transferred logs and the user's viewing key, get back exactly the events
345
+ * the live scanner would have yielded. Used by tests and by historical replay
346
+ * callers that have already fetched logs via `eth_getLogs`.
347
+ */
348
+ export function scanTransferredLogs(logs, viewingPrivateKey) {
349
+ return logs.map((log) => tryDecryptTransferred(log, viewingPrivateKey));
350
+ }
351
+ // -----------------------------------------------------------------------------
352
+ // Internal: bounded async queue used to bridge WS callbacks → async iterator
353
+ // -----------------------------------------------------------------------------
354
+ class EventQueue {
355
+ buffer = [];
356
+ waiters = [];
357
+ closed = false;
358
+ push(event) {
359
+ if (this.closed)
360
+ return;
361
+ const waiter = this.waiters.shift();
362
+ if (waiter) {
363
+ waiter({ value: event, done: false });
364
+ }
365
+ else {
366
+ this.buffer.push(event);
367
+ }
368
+ }
369
+ close() {
370
+ if (this.closed)
371
+ return;
372
+ this.closed = true;
373
+ while (this.waiters.length > 0) {
374
+ const waiter = this.waiters.shift();
375
+ waiter({ value: undefined, done: true });
376
+ }
377
+ }
378
+ [Symbol.asyncIterator]() {
379
+ const self = this;
380
+ return {
381
+ next() {
382
+ const buffered = self.buffer.shift();
383
+ if (buffered !== undefined) {
384
+ return Promise.resolve({ value: buffered, done: false });
385
+ }
386
+ if (self.closed) {
387
+ return Promise.resolve({
388
+ value: undefined,
389
+ done: true,
390
+ });
391
+ }
392
+ return new Promise((resolve) => {
393
+ self.waiters.push(resolve);
394
+ });
395
+ },
396
+ return() {
397
+ self.close();
398
+ return Promise.resolve({
399
+ value: undefined,
400
+ done: true,
401
+ });
402
+ },
403
+ };
404
+ }
405
+ }
406
+ function sleep(ms, signal) {
407
+ return new Promise((resolve) => {
408
+ if (signal?.aborted) {
409
+ resolve();
410
+ return;
411
+ }
412
+ const handle = setTimeout(() => {
413
+ signal?.removeEventListener("abort", onAbort);
414
+ resolve();
415
+ }, ms);
416
+ const onAbort = () => {
417
+ clearTimeout(handle);
418
+ resolve();
419
+ };
420
+ signal?.addEventListener("abort", onAbort, { once: true });
421
+ });
422
+ }