@unlink-xyz/core 0.1.0 → 0.1.2

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.
Files changed (110) hide show
  1. package/.eslintrc.json +4 -0
  2. package/account/zkAccount.test.ts +316 -0
  3. package/account/zkAccount.ts +222 -0
  4. package/clients/broadcaster.ts +67 -0
  5. package/clients/http.ts +94 -0
  6. package/clients/indexer.ts +150 -0
  7. package/config.ts +39 -0
  8. package/core.ts +17 -0
  9. package/dist/account/railgun-imports-prototype.d.ts +12 -0
  10. package/dist/account/railgun-imports-prototype.d.ts.map +1 -0
  11. package/dist/account/railgun-imports-prototype.js +30 -0
  12. package/dist/clients/indexer.d.ts.map +1 -1
  13. package/dist/clients/indexer.js +1 -1
  14. package/dist/state/hydrator.d.ts +16 -0
  15. package/dist/state/hydrator.d.ts.map +1 -0
  16. package/dist/state/hydrator.js +18 -0
  17. package/dist/state/job-store.d.ts +12 -0
  18. package/dist/state/job-store.d.ts.map +1 -0
  19. package/dist/state/job-store.js +118 -0
  20. package/dist/state/jobs.d.ts +50 -0
  21. package/dist/state/jobs.d.ts.map +1 -0
  22. package/dist/state/jobs.js +1 -0
  23. package/dist/state.d.ts +83 -0
  24. package/dist/state.d.ts.map +1 -0
  25. package/dist/state.js +171 -0
  26. package/dist/transactions/deposit.d.ts +0 -2
  27. package/dist/transactions/deposit.d.ts.map +1 -1
  28. package/dist/transactions/deposit.js +5 -9
  29. package/dist/transactions/note-sync.d.ts.map +1 -1
  30. package/dist/transactions/note-sync.js +1 -1
  31. package/dist/transactions/shield.d.ts +5 -0
  32. package/dist/transactions/shield.d.ts.map +1 -0
  33. package/dist/transactions/shield.js +93 -0
  34. package/dist/transactions/transact.d.ts +0 -5
  35. package/dist/transactions/transact.d.ts.map +1 -1
  36. package/dist/transactions/transact.js +2 -2
  37. package/dist/transactions/utils.d.ts +10 -0
  38. package/dist/transactions/utils.d.ts.map +1 -0
  39. package/dist/transactions/utils.js +17 -0
  40. package/dist/tsconfig.tsbuildinfo +1 -1
  41. package/dist/utils/time.d.ts +2 -0
  42. package/dist/utils/time.d.ts.map +1 -0
  43. package/dist/utils/time.js +3 -0
  44. package/dist/utils/witness.d.ts +11 -0
  45. package/dist/utils/witness.d.ts.map +1 -0
  46. package/dist/utils/witness.js +19 -0
  47. package/errors.ts +20 -0
  48. package/index.ts +17 -0
  49. package/key-derivation/babyjubjub.ts +11 -0
  50. package/key-derivation/bech32.test.ts +90 -0
  51. package/key-derivation/bech32.ts +124 -0
  52. package/key-derivation/bip32.ts +56 -0
  53. package/key-derivation/bip39.ts +76 -0
  54. package/key-derivation/bytes.ts +118 -0
  55. package/key-derivation/hash.ts +13 -0
  56. package/key-derivation/index.ts +7 -0
  57. package/key-derivation/wallet-node.ts +155 -0
  58. package/keys.ts +47 -0
  59. package/package.json +4 -5
  60. package/prover/config.ts +104 -0
  61. package/prover/index.ts +1 -0
  62. package/prover/prover.integration.test.ts +162 -0
  63. package/prover/prover.test.ts +309 -0
  64. package/prover/prover.ts +405 -0
  65. package/prover/registry.test.ts +90 -0
  66. package/prover/registry.ts +82 -0
  67. package/schema.ts +17 -0
  68. package/setup-artifacts.sh +57 -0
  69. package/state/index.ts +2 -0
  70. package/state/merkle/hydrator.ts +69 -0
  71. package/state/merkle/index.ts +12 -0
  72. package/state/merkle/merkle-tree.test.ts +50 -0
  73. package/state/merkle/merkle-tree.ts +163 -0
  74. package/state/store/ciphertext-store.ts +28 -0
  75. package/state/store/index.ts +24 -0
  76. package/state/store/job-store.ts +162 -0
  77. package/state/store/jobs.ts +64 -0
  78. package/state/store/leaf-store.ts +39 -0
  79. package/state/store/note-store.ts +177 -0
  80. package/state/store/nullifier-store.ts +39 -0
  81. package/state/store/records.ts +61 -0
  82. package/state/store/root-store.ts +34 -0
  83. package/state/store/store.ts +25 -0
  84. package/state.test.ts +235 -0
  85. package/storage/index.ts +3 -0
  86. package/storage/indexeddb.test.ts +99 -0
  87. package/storage/indexeddb.ts +235 -0
  88. package/storage/memory.test.ts +59 -0
  89. package/storage/memory.ts +93 -0
  90. package/transactions/deposit.test.ts +160 -0
  91. package/transactions/deposit.ts +227 -0
  92. package/transactions/index.ts +20 -0
  93. package/transactions/note-sync.test.ts +155 -0
  94. package/transactions/note-sync.ts +452 -0
  95. package/transactions/reconcile.ts +73 -0
  96. package/transactions/transact.test.ts +451 -0
  97. package/transactions/transact.ts +811 -0
  98. package/transactions/types.ts +141 -0
  99. package/tsconfig.json +14 -0
  100. package/types/global.d.ts +15 -0
  101. package/types.ts +24 -0
  102. package/utils/async.ts +15 -0
  103. package/utils/bigint.ts +34 -0
  104. package/utils/crypto.test.ts +69 -0
  105. package/utils/crypto.ts +58 -0
  106. package/utils/json-codec.ts +38 -0
  107. package/utils/polling.ts +6 -0
  108. package/utils/signature.ts +16 -0
  109. package/utils/validators.test.ts +64 -0
  110. package/utils/validators.ts +86 -0
@@ -0,0 +1,452 @@
1
+ import { poseidon } from "@railgun-community/circomlibjs";
2
+
3
+ import type { ZkAccount } from "../account/zkAccount.js";
4
+ import type { FetchLike } from "../clients/http.js";
5
+ import { createIndexerClient } from "../clients/indexer.js";
6
+ import type { CommitmentRecord } from "../clients/indexer.js";
7
+ import { serviceConfig } from "../config.js";
8
+ import { CoreError } from "../errors.js";
9
+ import { ByteLength, ByteUtils } from "../key-derivation/bytes.js";
10
+ import {
11
+ createMerkleTrees,
12
+ rebuildTreeFromStore,
13
+ type LeafRecord,
14
+ type LocalMerkleTrees,
15
+ type NoteRecord,
16
+ type RootRecord,
17
+ } from "../state/index.js";
18
+ import { formatUint256, parseHexToBigInt } from "../utils/bigint.js";
19
+ import { decryptNote } from "../utils/crypto.js";
20
+ import { ensureChainId } from "../utils/validators.js";
21
+ import type { Ciphertext } from "./types.js";
22
+
23
+ const DEFAULT_BATCH_SIZE = 256;
24
+
25
+ export type NoteSyncStateStore = {
26
+ putLeaf(record: LeafRecord): Promise<void>;
27
+ getLeaf(chainId: number, index: number): Promise<LeafRecord | null>;
28
+ clearLeaves(chainId: number): Promise<void>;
29
+ putRoot(record: RootRecord): Promise<void>;
30
+ putNote(record: NoteRecord): Promise<void>;
31
+ getNote(chainId: number, index: number): Promise<NoteRecord | null>;
32
+ markNoteSpent(
33
+ chainId: number,
34
+ index: number,
35
+ spentAt?: number,
36
+ ): Promise<NoteRecord>;
37
+ putCiphertext(
38
+ chainId: number,
39
+ index: number,
40
+ payload: Uint8Array,
41
+ ): Promise<void>;
42
+ countNullifiers(chainId: number): Promise<number>;
43
+ putNullifier(record: {
44
+ chainId: number;
45
+ nullifier: string;
46
+ noteIndex?: number;
47
+ }): Promise<void>;
48
+ };
49
+
50
+ export type NoteSyncOptions = {
51
+ merkleTrees?: LocalMerkleTrees;
52
+ indexerClient?: ReturnType<typeof createIndexerClient>;
53
+ fetch?: FetchLike;
54
+ limit?: number;
55
+ };
56
+
57
+ type ChainSyncStatus = {
58
+ inFlight: boolean;
59
+ lastSuccess: number | null;
60
+ lastError?: string;
61
+ };
62
+
63
+ function buildCiphertext(bytes: [string, string, string]): Ciphertext {
64
+ return {
65
+ data: [
66
+ parseHexToBigInt(bytes[0]),
67
+ parseHexToBigInt(bytes[1]),
68
+ parseHexToBigInt(bytes[2]),
69
+ ],
70
+ };
71
+ }
72
+
73
+ function encodeCiphertext(ciphertext: Ciphertext): Uint8Array {
74
+ const payload = new Uint8Array(ByteLength.UINT_256 * ciphertext.data.length);
75
+ ciphertext.data.forEach((value, index) => {
76
+ const start = index * ByteLength.UINT_256;
77
+ payload.set(ByteUtils.hexStringToBytes(formatUint256(value)), start);
78
+ });
79
+ return payload;
80
+ }
81
+
82
+ export function createNoteSyncService(
83
+ stateStore: NoteSyncStateStore,
84
+ options: NoteSyncOptions = {},
85
+ ) {
86
+ const trees = options.merkleTrees ?? createMerkleTrees();
87
+ const limit = options.limit ?? DEFAULT_BATCH_SIZE;
88
+ const status = new Map<number, ChainSyncStatus>();
89
+
90
+ // Initialize indexer client
91
+ let indexerClient = options.indexerClient;
92
+ if (!indexerClient) {
93
+ const fetchImpl =
94
+ options.fetch ?? (typeof fetch === "function" ? fetch : undefined);
95
+ if (!fetchImpl) {
96
+ throw new Error(
97
+ "fetch dependency is required to sync indexer commitments",
98
+ );
99
+ }
100
+ indexerClient = createIndexerClient(serviceConfig.indexerBaseUrl, {
101
+ fetch: fetchImpl,
102
+ });
103
+ }
104
+
105
+ // Count-based optimization: avoid per-nullifier lookups when counts are unchanged
106
+ async function shouldQueryNullifiers(chainId: number) {
107
+ const [onChainCount, localCount] = await Promise.all([
108
+ indexerClient!.getNullifierCount(chainId),
109
+ stateStore.countNullifiers(chainId),
110
+ ]);
111
+ return onChainCount !== localCount;
112
+ }
113
+
114
+ function createNoteRecord(params: {
115
+ chainId: number;
116
+ index: number;
117
+ commitment: string;
118
+ token: string;
119
+ amount: bigint;
120
+ npk: bigint;
121
+ mpk: bigint;
122
+ random: bigint;
123
+ nullifier: string;
124
+ spentAt?: number;
125
+ }): NoteRecord {
126
+ const { chainId, index, commitment, token, amount, npk, mpk, random, nullifier, spentAt } =
127
+ params;
128
+ const base: NoteRecord = {
129
+ chainId,
130
+ index,
131
+ token,
132
+ value: amount.toString(),
133
+ commitment,
134
+ npk: formatUint256(npk),
135
+ mpk: formatUint256(mpk),
136
+ random: formatUint256(random),
137
+ nullifier,
138
+ };
139
+ return spentAt === undefined ? base : { ...base, spentAt };
140
+ }
141
+
142
+ async function recordCommitment(
143
+ chainId: number,
144
+ record: CommitmentRecord,
145
+ allowRelaxedOrder = false,
146
+ ) {
147
+ const value = parseHexToBigInt(record.commitment);
148
+ const currentLeafCount = trees.getLeafCount(chainId);
149
+ if (record.index >= currentLeafCount) {
150
+ const { index } = trees.addLeaf(chainId, value);
151
+ if (index !== record.index && !allowRelaxedOrder) {
152
+ throw new CoreError("indexed commitments out of order");
153
+ }
154
+ await stateStore.putLeaf({
155
+ chainId,
156
+ index: record.index,
157
+ commitment: record.commitment,
158
+ });
159
+ }
160
+ await stateStore.putRoot({ chainId, root: record.root });
161
+ }
162
+
163
+ async function tryStoreNote(
164
+ chainId: number,
165
+ record: CommitmentRecord,
166
+ account: ZkAccount,
167
+ shouldCheckNullifiers: boolean,
168
+ ) {
169
+ const ciphertext = buildCiphertext(record.ciphertext);
170
+ let note;
171
+ try {
172
+ note = decryptNote(ciphertext, account.masterPublicKey);
173
+ } catch (err) {
174
+ if (err instanceof CoreError) {
175
+ return null;
176
+ }
177
+ throw err;
178
+ }
179
+ const random = note.random;
180
+ const amount = note.amount;
181
+ const token = note.token;
182
+ const npk = poseidon([account.masterPublicKey, random]);
183
+ const commitment = poseidon([npk, BigInt(token), amount]);
184
+ if (commitment !== parseHexToBigInt(record.commitment)) {
185
+ return null;
186
+ }
187
+
188
+ const nullifier = poseidon([account.nullifyingKey, BigInt(record.index)]);
189
+ const nullifierHex = formatUint256(nullifier);
190
+
191
+ const ciphertextBytes = encodeCiphertext(ciphertext);
192
+ await stateStore.putCiphertext(chainId, record.index, ciphertextBytes);
193
+ const baseNoteParams = {
194
+ chainId,
195
+ index: record.index,
196
+ commitment: record.commitment,
197
+ token,
198
+ amount,
199
+ npk,
200
+ mpk: account.masterPublicKey,
201
+ random,
202
+ nullifier: nullifierHex,
203
+ };
204
+
205
+ // Check if this note already exists in local storage
206
+ const existingNote = await stateStore.getNote(chainId, record.index);
207
+
208
+ // If note is already marked as spent locally, don't query indexer
209
+ // to ask for nullifier because we already know it's spent
210
+ if (existingNote?.spentAt !== undefined) {
211
+ // Ensure nullifier is stored for count optimization (idempotent)
212
+ await stateStore.putNullifier({
213
+ chainId,
214
+ nullifier: nullifierHex,
215
+ noteIndex: record.index,
216
+ });
217
+ return existingNote;
218
+ }
219
+
220
+ // Skip indexer nullifier query if nullifier counts match
221
+ if (!shouldCheckNullifiers) {
222
+ if (existingNote) {
223
+ return existingNote;
224
+ }
225
+ const stored = createNoteRecord(baseNoteParams);
226
+ await stateStore.putNote(stored);
227
+ return stored;
228
+ }
229
+
230
+ // Counts mismatched: query individual nullifier to check if this note was spent
231
+ const onChainNullifier = await indexerClient!.getNullifier({
232
+ chainId,
233
+ nullifier: nullifierHex,
234
+ });
235
+
236
+ if (onChainNullifier) {
237
+ const spentAtMs = onChainNullifier.spentAt * 1000;
238
+
239
+ // Store nullifier
240
+ await stateStore.putNullifier({
241
+ chainId,
242
+ nullifier: nullifierHex,
243
+ noteIndex: record.index,
244
+ });
245
+
246
+ if (existingNote) {
247
+ // Update existing unspent note to mark as spent
248
+ await stateStore.markNoteSpent(chainId, record.index, spentAtMs);
249
+ return existingNote;
250
+ }
251
+ const stored = createNoteRecord({
252
+ ...baseNoteParams,
253
+ spentAt: spentAtMs,
254
+ });
255
+ await stateStore.putNote(stored);
256
+ return stored;
257
+ }
258
+
259
+ // Skip re-storing unspent notes that already exist
260
+ if (existingNote) {
261
+ return existingNote;
262
+ }
263
+
264
+ const stored = createNoteRecord(baseNoteParams);
265
+ await stateStore.putNote(stored);
266
+ return stored;
267
+ }
268
+
269
+ // Pulls commitments from the indexer starting at `start`, updating local tree and notes.
270
+ async function ingestFrom(
271
+ chainId: number,
272
+ start: number,
273
+ account: ZkAccount,
274
+ ) {
275
+ const shouldCheckNullifiers = await shouldQueryNullifiers(chainId);
276
+ let cursor = start;
277
+ for (;;) {
278
+ const batch = await indexerClient!.fetchCommitmentBatch({
279
+ chainId,
280
+ start: cursor,
281
+ limit,
282
+ });
283
+
284
+ if (batch.commitments.length === 0) {
285
+ break;
286
+ }
287
+
288
+ for (const record of batch.commitments) {
289
+ const expectedIndex = trees.getLeafCount(chainId);
290
+ if (record.index !== expectedIndex) {
291
+ throw new Error("indexed commitments out of order");
292
+ }
293
+
294
+ const value = ByteUtils.hexToBigInt(record.commitment);
295
+ const { index } = trees.addLeaf(chainId, value);
296
+ if (index !== record.index) {
297
+ throw new Error("local merkle tree desynchronized");
298
+ }
299
+
300
+ await stateStore.putLeaf({
301
+ chainId,
302
+ index: record.index,
303
+ commitment: record.commitment,
304
+ });
305
+
306
+ await stateStore.putRoot({ chainId, root: record.root });
307
+ await tryStoreNote(chainId, record, account, shouldCheckNullifiers);
308
+ cursor = record.index + 1;
309
+ }
310
+
311
+ if (batch.commitments.length < limit) {
312
+ break;
313
+ }
314
+ }
315
+ }
316
+
317
+ // Clears local state for a chain and rehydrates fully from the indexer.
318
+ async function fullResync(chainId: number, account: ZkAccount) {
319
+ trees.reset(chainId);
320
+ await stateStore.clearLeaves(chainId);
321
+ await ingestFrom(chainId, 0, account);
322
+ }
323
+
324
+ /**
325
+ * Syncs a single chain into local state, optionally forcing a full rebuild.
326
+ * Skips overlapping runs via `inFlight` and falls back to full resync on
327
+ * ordering/root inconsistencies.
328
+ */
329
+ async function syncChain(
330
+ chainId: number,
331
+ account: ZkAccount,
332
+ opts: { forceFullResync?: boolean } = {},
333
+ ): Promise<void> {
334
+ ensureChainId(chainId);
335
+ const current = status.get(chainId) ?? {
336
+ inFlight: false,
337
+ lastSuccess: null,
338
+ };
339
+ if (current.inFlight) {
340
+ return;
341
+ }
342
+ status.set(chainId, { ...current, inFlight: true, lastError: undefined });
343
+
344
+ try {
345
+ const start = opts.forceFullResync
346
+ ? 0
347
+ : await rebuildTreeFromStore({
348
+ chainId,
349
+ trees,
350
+ loadLeaf: stateStore.getLeaf.bind(stateStore),
351
+ });
352
+ if (opts.forceFullResync) {
353
+ await fullResync(chainId, account);
354
+ } else {
355
+ try {
356
+ await ingestFrom(chainId, start, account);
357
+ } catch (err) {
358
+ // Fallback to full resync on ordering/root mismatch.
359
+ await fullResync(chainId, account);
360
+ }
361
+ }
362
+ status.set(chainId, {
363
+ inFlight: false,
364
+ lastSuccess: Date.now(),
365
+ });
366
+ } catch (err) {
367
+ status.set(chainId, {
368
+ inFlight: false,
369
+ lastSuccess: current.lastSuccess ?? null,
370
+ lastError: err instanceof Error ? err.message : String(err),
371
+ });
372
+ throw err;
373
+ }
374
+ }
375
+
376
+ /**
377
+ * Syncs multiple chains sequentially.
378
+ */
379
+ async function syncChains(
380
+ chainIds: number[],
381
+ account: ZkAccount,
382
+ opts?: { forceFullResync?: boolean },
383
+ ): Promise<void> {
384
+ for (const chainId of chainIds) {
385
+ await syncChain(chainId, account, opts);
386
+ }
387
+ }
388
+
389
+ /**
390
+ * Legacy sync method that returns synced notes.
391
+ * Kept for backward compatibility.
392
+ */
393
+ async function sync(
394
+ chainId: number,
395
+ account: ZkAccount,
396
+ opts?: { start?: number; forceFullScan?: boolean },
397
+ ): Promise<NoteRecord[]> {
398
+ ensureChainId(chainId);
399
+ await rebuildTreeFromStore({
400
+ chainId,
401
+ trees,
402
+ loadLeaf: stateStore.getLeaf.bind(stateStore),
403
+ });
404
+ const synced: NoteRecord[] = [];
405
+ let start = opts?.start ?? 0;
406
+
407
+ const shouldCheckNullifiers = await shouldQueryNullifiers(chainId);
408
+ let keepFetching = true;
409
+ while (keepFetching) {
410
+ const batch = await indexerClient!.fetchCommitmentBatch({
411
+ chainId,
412
+ start,
413
+ limit,
414
+ });
415
+ if (batch.commitments.length === 0) {
416
+ keepFetching = false;
417
+ continue;
418
+ }
419
+
420
+ for (const record of batch.commitments) {
421
+ await recordCommitment(chainId, record, !!opts?.forceFullScan);
422
+ const storedNote = await tryStoreNote(
423
+ chainId,
424
+ record,
425
+ account,
426
+ shouldCheckNullifiers,
427
+ );
428
+ if (storedNote) {
429
+ synced.push(storedNote);
430
+ }
431
+ start = record.index + 1;
432
+ }
433
+
434
+ if (batch.commitments.length < limit) {
435
+ keepFetching = false;
436
+ }
437
+ }
438
+
439
+ return synced;
440
+ }
441
+
442
+ function getStatus() {
443
+ return status;
444
+ }
445
+
446
+ return {
447
+ sync,
448
+ syncChain,
449
+ syncChains,
450
+ getStatus,
451
+ };
452
+ }
@@ -0,0 +1,73 @@
1
+ import type {
2
+ JobStatus,
3
+ PendingJobKind,
4
+ PendingJobRecord,
5
+ } from "../state/index.js";
6
+ import type {
7
+ BaseStateStore,
8
+ DepositSyncResult,
9
+ TransactSyncResult,
10
+ } from "./types.js";
11
+
12
+ type DepositClient = {
13
+ syncPendingDeposit(relayId: string): Promise<DepositSyncResult>;
14
+ };
15
+
16
+ type TransactService = {
17
+ syncPendingTransact(relayId: string): Promise<TransactSyncResult>;
18
+ };
19
+
20
+ type ReconcilerDeps = {
21
+ stateStore: BaseStateStore;
22
+ depositClient: DepositClient;
23
+ transactService: TransactService;
24
+ };
25
+
26
+ const ACTIVE_STATUSES: JobStatus[] = ["pending", "submitted", "broadcasting"];
27
+
28
+ export function createJobReconciler(deps: ReconcilerDeps) {
29
+ const { stateStore, depositClient, transactService } = deps;
30
+
31
+ async function reconcileJob(job: PendingJobRecord) {
32
+ if (job.kind === "deposit") {
33
+ return depositClient.syncPendingDeposit(job.relayId);
34
+ }
35
+ return transactService.syncPendingTransact(job.relayId);
36
+ }
37
+
38
+ async function reconcileRelay(relayId: string) {
39
+ const job = await stateStore.getPendingJob(relayId);
40
+ if (!job) {
41
+ throw new Error(`unknown relay ${relayId}`);
42
+ }
43
+ return reconcileJob(job);
44
+ }
45
+
46
+ async function reconcileAll(
47
+ filter: {
48
+ kind?: PendingJobKind;
49
+ statuses?: JobStatus[];
50
+ } = {},
51
+ ) {
52
+ const jobs = await stateStore.listPendingJobs({
53
+ kind: filter.kind,
54
+ statuses: filter.statuses ?? ACTIVE_STATUSES,
55
+ });
56
+ const results = [];
57
+ for (const job of jobs) {
58
+ try {
59
+ const res = await reconcileJob(job);
60
+ results.push(res);
61
+ } catch (err) {
62
+ // Allow caller to decide how to handle errors; we keep going.
63
+ results.push(err);
64
+ }
65
+ }
66
+ return results;
67
+ }
68
+
69
+ return {
70
+ reconcileRelay,
71
+ reconcileAll,
72
+ };
73
+ }