@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.
package/dist/merkle.js ADDED
@@ -0,0 +1,84 @@
1
+ /**
2
+ * @zbase-protocol/core — Chain-agnostic Merkle tree operations
3
+ *
4
+ * Uses Lean Incremental Merkle Tree (lean-imt) with Poseidon hashing.
5
+ * Same tree structure works for both EVM and Solana verifiers.
6
+ */
7
+ import { LeanIMT } from "@zk-kit/lean-imt";
8
+ import { poseidon2 } from "poseidon-lite";
9
+ const TREE_DEPTH = 32;
10
+ /**
11
+ * Build a Merkle tree from leaf values (commitments).
12
+ * The hash function (Poseidon2) is identical across chains.
13
+ */
14
+ export function buildMerkleTree(leaves) {
15
+ const hash = (a, b) => poseidon2([a, b]);
16
+ const tree = new LeanIMT(hash);
17
+ for (const leaf of leaves) {
18
+ tree.insert(leaf);
19
+ }
20
+ return tree;
21
+ }
22
+ /**
23
+ * Generate a Merkle proof for a leaf, shaped for `note_spend.circom`'s
24
+ * `LeanIMTInclusionProof(levels=TREE_DEPTH)`.
25
+ *
26
+ * B3 fix (audit-sweep-2026-06-17): zk-kit's `generateProof` returns a
27
+ * **compacted** proof — levels where the leaf has no right sibling are DROPPED
28
+ * from `siblings`, and `proof.index` is **recomputed** over only the surviving
29
+ * path bits (it is NOT the raw `leafIndex`). The circuit walks `actualDepth`
30
+ * real levels, decomposing `index` into bits and pairing bit `lvl` with
31
+ * `siblings[lvl]` — exactly the compacted shape. The previous implementation
32
+ * fed the RAW `leafIndex` bits alongside the compacted-then-zero-padded
33
+ * siblings; for any leaf whose path crosses a dropped level the index bits and
34
+ * siblings misaligned, producing a wrong root and an unsatisfiable witness.
35
+ * (It only worked for left-spine leaves where `proof.index === leafIndex` and
36
+ * no level is dropped — which is why the original committed fixture passed.)
37
+ *
38
+ * Correct mapping, matching zk-kit's own `verifyProof`:
39
+ * - `index` = proof.index (compacted path, NOT leafIndex)
40
+ * - `actualDepth` = siblings.length (number of real levels)
41
+ * - `siblings` = proof.siblings, zero-padded to TREE_DEPTH
42
+ * - `pathIndices` = bits of proof.index over TREE_DEPTH levels
43
+ *
44
+ * `actualDepth` and `index` are returned alongside so the witness builder can
45
+ * feed the circuit's `actualDepth`/`index` inputs directly.
46
+ */
47
+ export function generateMerkleProof(tree, leafIndex) {
48
+ const proof = tree.generateProof(leafIndex);
49
+ const siblings = proof.siblings.map((s) => Array.isArray(s) ? s[0] : s);
50
+ // F12: a LeanIMT proof has `depth` siblings (variable with tree size), but the
51
+ // fixed-depth circuit expects exactly TREE_DEPTH padded siblings. Validate we
52
+ // don't EXCEED the circuit depth (would silently truncate / produce a wrong
53
+ // root), then zero-pad up to TREE_DEPTH (the LeanIMT "no right sibling"
54
+ // sentinel is 0, matching the circuit's padding convention).
55
+ if (siblings.length > TREE_DEPTH) {
56
+ throw new Error(`merkle: proof depth ${siblings.length} exceeds circuit TREE_DEPTH ${TREE_DEPTH}`);
57
+ }
58
+ const actualDepth = siblings.length;
59
+ const index = proof.index; // compacted path index (NOT leafIndex)
60
+ // pathIndices = bits of the COMPACTED index over TREE_DEPTH levels. Bits at or
61
+ // above actualDepth are 0 (the circuit's `active[lvl]` gate ignores them).
62
+ const pathIndices = [];
63
+ let idx = index;
64
+ for (let i = 0; i < TREE_DEPTH; i++) {
65
+ pathIndices.push(idx & 1);
66
+ idx >>= 1;
67
+ }
68
+ const paddedElements = siblings.slice();
69
+ while (paddedElements.length < TREE_DEPTH)
70
+ paddedElements.push(0n);
71
+ return {
72
+ pathElements: paddedElements,
73
+ pathIndices,
74
+ index,
75
+ actualDepth,
76
+ root: tree.root,
77
+ };
78
+ }
79
+ /**
80
+ * Get the tree depth constant.
81
+ */
82
+ export function getTreeDepth() {
83
+ return TREE_DEPTH;
84
+ }
@@ -0,0 +1,206 @@
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 { type Note, type ViewingKeyPair } from "./notes.js";
28
+ /** Connection lifecycle marker. Emitted on every (re)connect. */
29
+ export interface ScannerConnectedEvent {
30
+ type: "connected";
31
+ reconnectAttempt: number;
32
+ /** Block height to start replaying historical events from, if any. */
33
+ startBlock: bigint;
34
+ }
35
+ /** A `Spent` log was observed. No ciphertext to decrypt — just nullifier hashes. */
36
+ export interface ScannerSpentEvent {
37
+ type: "spent";
38
+ nullifierHash0: bigint;
39
+ nullifierHash1: bigint;
40
+ withdrawnAmount: bigint;
41
+ outputCommitment0: bigint;
42
+ outputCommitment1: bigint;
43
+ blockNumber: bigint;
44
+ txHash: string;
45
+ logIndex: number;
46
+ }
47
+ /** A `Transferred` log was observed AND one ciphertext decrypted for us. */
48
+ export interface ScannerTransferredInEvent {
49
+ type: "transferred-in";
50
+ /** Which of the two outputs decrypted (0 or 1). */
51
+ outputIndex: 0 | 1;
52
+ commitment: bigint;
53
+ note: Note;
54
+ /** The other nullifier hash from the same spend — useful for de-duping. */
55
+ siblingNullifierHash: bigint;
56
+ blockNumber: bigint;
57
+ txHash: string;
58
+ logIndex: number;
59
+ }
60
+ /** A `Transferred` log was observed but neither ciphertext was ours. */
61
+ export interface ScannerTransferredOtherEvent {
62
+ type: "transferred-other";
63
+ outputCommitment0: bigint;
64
+ outputCommitment1: bigint;
65
+ blockNumber: bigint;
66
+ txHash: string;
67
+ logIndex: number;
68
+ }
69
+ /** Non-fatal error (parse failure, malformed log, etc.) — caller may ignore. */
70
+ export interface ScannerErrorEvent {
71
+ type: "error";
72
+ message: string;
73
+ cause?: unknown;
74
+ }
75
+ export type ScannerEvent = ScannerConnectedEvent | ScannerSpentEvent | ScannerTransferredInEvent | ScannerTransferredOtherEvent | ScannerErrorEvent;
76
+ /**
77
+ * Spent(uint256,uint256,uint256,uint256,uint256)
78
+ * Two indexed nullifier hashes + non-indexed (amount, commitment0, commitment1).
79
+ */
80
+ export declare const SPENT_TOPIC: string;
81
+ /**
82
+ * Transferred(uint256,uint256,uint256,uint256,(bytes32[4],bytes32,bytes32,bytes32,bytes32),(bytes32[4],bytes32,bytes32,bytes32,bytes32))
83
+ *
84
+ * The two `CommitmentCiphertext` structs are ABI-encoded as tuples. This is
85
+ * the canonical event-signature form Solidity uses for keccak256(topic[0]).
86
+ */
87
+ export declare const TRANSFERRED_TOPIC: string;
88
+ export interface ScannerOptions {
89
+ /** ws:// or wss:// JSON-RPC endpoint. */
90
+ wsUrl: string;
91
+ /** UTXOPool contract address (0x-prefixed, 20 bytes). */
92
+ utxoPoolAddress: string;
93
+ /** Viewing key to trial-decrypt every Transferred ciphertext against. */
94
+ viewingKey: Pick<ViewingKeyPair, "privateKey">;
95
+ /** Block to start subscription from (default: latest). */
96
+ fromBlock?: bigint;
97
+ /** Custom abort signal to terminate the scanner. */
98
+ signal?: AbortSignal;
99
+ /** Override the reconnect backoff sequence (ms). Default: [1000, 2000, 4000, 8000, 16000, 30000] capped. */
100
+ reconnectBackoffMs?: readonly number[];
101
+ /** Pluggable WebSocket constructor (for tests). Default: globalThis.WebSocket. */
102
+ webSocketCtor?: typeof WebSocket;
103
+ }
104
+ /**
105
+ * Decode the data field of a `Spent` log:
106
+ * words: [withdrawnAmount, outputCommitment0, outputCommitment1]
107
+ * The two indexed nullifier hashes live in `topics[1..3]`, not in data.
108
+ */
109
+ export declare function decodeSpentLog(log: {
110
+ topics: readonly string[];
111
+ data: string;
112
+ blockNumber: string;
113
+ transactionHash: string;
114
+ logIndex: string;
115
+ }): Omit<ScannerSpentEvent, "type">;
116
+ /**
117
+ * Decode a Transferred log into raw fields + the two ciphertext blobs ready
118
+ * for trial decryption.
119
+ *
120
+ * Layout (non-indexed args, packed as one ABI-encoded tuple):
121
+ *
122
+ * word 0 outputCommitment0
123
+ * word 1 outputCommitment1
124
+ * word 2 ciphertext0.ciphertext[0]
125
+ * word 3 ciphertext0.ciphertext[1]
126
+ * word 4 ciphertext0.ciphertext[2]
127
+ * word 5 ciphertext0.ciphertext[3]
128
+ * word 6 ciphertext0.blindedSenderViewingKey
129
+ * word 7 ciphertext0.blindedReceiverViewingKey
130
+ * word 8 ciphertext0.memo
131
+ * word 9 ciphertext0.aad
132
+ * word 10 ciphertext1.ciphertext[0]
133
+ * ...
134
+ * word 17 ciphertext1.aad
135
+ *
136
+ * The 8-word `CommitmentCiphertext` struct is encoded inline (no offset
137
+ * indirection) because every field is a fixed-size value type — Solidity
138
+ * inlines such tuples.
139
+ *
140
+ * The ciphertext blob we feed to `decryptNoteWithAAD` is the concatenation
141
+ * of bytes32[4] (the four word ciphertext slots). See UTXOPool.sol comment
142
+ * on `CommitmentCiphertext` for the per-word convention.
143
+ */
144
+ export interface DecodedTransferredLog {
145
+ nullifierHash0: bigint;
146
+ nullifierHash1: bigint;
147
+ outputCommitment0: bigint;
148
+ outputCommitment1: bigint;
149
+ ciphertext0Blob: Uint8Array;
150
+ ciphertext0Aad: Uint8Array;
151
+ ciphertext1Blob: Uint8Array;
152
+ ciphertext1Aad: Uint8Array;
153
+ blockNumber: bigint;
154
+ txHash: string;
155
+ logIndex: number;
156
+ }
157
+ export declare function decodeTransferredLog(log: {
158
+ topics: readonly string[];
159
+ data: string;
160
+ blockNumber: string;
161
+ transactionHash: string;
162
+ logIndex: string;
163
+ }): DecodedTransferredLog;
164
+ /**
165
+ * Trial-decrypt both ciphertexts of a Transferred log against the viewing
166
+ * key. Returns the matching event or `null` if neither ciphertext belongs to
167
+ * this wallet.
168
+ *
169
+ * The encrypted blob fed to `decryptNoteWithAAD` is the on-chain wire format
170
+ * that `encryptNoteForTransfer` produces — see notes.ts `EncryptedNote`
171
+ * layout. The contract stores it spread across four bytes32 words; we
172
+ * reassemble it back into the linear byte buffer here.
173
+ *
174
+ * Phase 1B caveat: the v0 `encryptNote` envelope (ephPub|viewTag|nonce|ct)
175
+ * does not yet fit neatly inside bytes32[4]. A follow-up will define a
176
+ * compact wire layout; the v0 wire is the linear blob and that's what we
177
+ * decode here. The hand-decoder above is a placeholder that will need to
178
+ * track that schema once it's frozen.
179
+ */
180
+ export declare function tryDecryptTransferred(decoded: DecodedTransferredLog, viewingPrivateKey: Uint8Array): ScannerTransferredInEvent | ScannerTransferredOtherEvent;
181
+ /**
182
+ * Create an async iterable scanner over a UTXOPool's events.
183
+ *
184
+ * Usage:
185
+ * ```ts
186
+ * for await (const ev of createScanner({ wsUrl, utxoPoolAddress, viewingKey })) {
187
+ * if (ev.type === "transferred-in") {
188
+ * wallet.addNote(ev.commitment, ev.note);
189
+ * } else if (ev.type === "spent") {
190
+ * wallet.markSpent(ev.nullifierHash0, ev.nullifierHash1);
191
+ * }
192
+ * }
193
+ * ```
194
+ *
195
+ * Cancellation: pass `signal: AbortSignal` (or break out of the for-await).
196
+ * On abort, the underlying WebSocket is closed cleanly.
197
+ */
198
+ export declare function createScanner(options: ScannerOptions): AsyncIterable<ScannerEvent>;
199
+ /**
200
+ * In-memory equivalent of the live scanner: feed it an array of already-decoded
201
+ * Transferred logs and the user's viewing key, get back exactly the events
202
+ * the live scanner would have yielded. Used by tests and by historical replay
203
+ * callers that have already fetched logs via `eth_getLogs`.
204
+ */
205
+ export declare function scanTransferredLogs(logs: readonly DecodedTransferredLog[], viewingPrivateKey: Uint8Array): Array<ScannerTransferredInEvent | ScannerTransferredOtherEvent>;
206
+ export type { EncryptedNote, Note, ViewingKeyPair } from "./notes.js";