@zerc20/wallet-sdk 0.0.0-stage → 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.
Files changed (84) hide show
  1. package/LICENSE +24 -0
  2. package/README.md +26 -2
  3. package/dist/burnWorker.d.ts +1 -0
  4. package/dist/burnWorker.js +21 -0
  5. package/dist/burnWorker.js.map +1 -0
  6. package/dist/chain.d.ts +345 -0
  7. package/dist/chain.js +149 -0
  8. package/dist/chain.js.map +1 -0
  9. package/dist/context.d.ts +48 -0
  10. package/dist/context.js +40 -0
  11. package/dist/context.js.map +1 -0
  12. package/dist/core.d.ts +43 -0
  13. package/dist/core.js +106 -0
  14. package/dist/core.js.map +1 -0
  15. package/dist/errors.d.ts +36 -0
  16. package/dist/errors.js +26 -0
  17. package/dist/errors.js.map +1 -0
  18. package/dist/index.d.ts +6 -0
  19. package/dist/index.js +19 -0
  20. package/dist/index.js.map +1 -0
  21. package/dist/network.d.ts +66 -0
  22. package/dist/network.js +55 -0
  23. package/dist/network.js.map +1 -0
  24. package/dist/pos.d.ts +93 -0
  25. package/dist/pos.js +302 -0
  26. package/dist/pos.js.map +1 -0
  27. package/dist/prove.d.ts +13 -0
  28. package/dist/prove.js +53 -0
  29. package/dist/prove.js.map +1 -0
  30. package/dist/rageExit.d.ts +5 -0
  31. package/dist/rageExit.js +146 -0
  32. package/dist/rageExit.js.map +1 -0
  33. package/dist/scan.d.ts +17 -0
  34. package/dist/scan.js +47 -0
  35. package/dist/scan.js.map +1 -0
  36. package/dist/seed.d.ts +8 -0
  37. package/dist/seed.js +35 -0
  38. package/dist/seed.js.map +1 -0
  39. package/dist/servers.d.ts +60 -0
  40. package/dist/servers.js +117 -0
  41. package/dist/servers.js.map +1 -0
  42. package/dist/services.d.ts +62 -0
  43. package/dist/services.js +128 -0
  44. package/dist/services.js.map +1 -0
  45. package/dist/spend.d.ts +151 -0
  46. package/dist/spend.js +745 -0
  47. package/dist/spend.js.map +1 -0
  48. package/dist/state.d.ts +174 -0
  49. package/dist/state.js +45 -0
  50. package/dist/state.js.map +1 -0
  51. package/dist/sync.d.ts +84 -0
  52. package/dist/sync.js +615 -0
  53. package/dist/sync.js.map +1 -0
  54. package/dist/tree.d.ts +19 -0
  55. package/dist/tree.js +63 -0
  56. package/dist/tree.js.map +1 -0
  57. package/dist/wallet.d.ts +301 -0
  58. package/dist/wallet.js +992 -0
  59. package/dist/wallet.js.map +1 -0
  60. package/package.json +60 -4
  61. package/src/burnWorker.ts +25 -0
  62. package/src/chain.ts +205 -0
  63. package/src/context.ts +84 -0
  64. package/src/core.ts +147 -0
  65. package/src/errors.ts +61 -0
  66. package/src/index.ts +41 -0
  67. package/src/network.ts +94 -0
  68. package/src/pos.ts +415 -0
  69. package/src/prove.ts +67 -0
  70. package/src/rageExit.ts +168 -0
  71. package/src/scan.ts +62 -0
  72. package/src/seed.ts +46 -0
  73. package/src/servers.ts +172 -0
  74. package/src/services.ts +184 -0
  75. package/src/snarkjs.d.ts +11 -0
  76. package/src/spend.ts +912 -0
  77. package/src/state.ts +211 -0
  78. package/src/sync.ts +698 -0
  79. package/src/tree.ts +108 -0
  80. package/src/wallet.ts +1297 -0
  81. package/wasm/zerc20_wallet_core.d.ts +176 -0
  82. package/wasm/zerc20_wallet_core.js +783 -0
  83. package/wasm/zerc20_wallet_core_bg.wasm +0 -0
  84. package/wasm/zerc20_wallet_core_bg.wasm.d.ts +32 -0
package/dist/sync.js ADDED
@@ -0,0 +1,615 @@
1
+ // One pass of keeping up: new notes from storage, then per chain the wallet's tags and nullifiers among the
2
+ // indexer's new entries. A wallet only ever looks for what it already expects; see scan.ts.
3
+ import { commit, emptyState, planBurnBlock, planReceipt, refresh, } from '@zerc20/storage-sdk';
4
+ import { bytesToHex, hexToBytes, numberToHex, parseAbiItem } from 'viem';
5
+ import { chainCtx, chainState, spendKey } from './context.js';
6
+ import { burnAddress, core } from './core.js';
7
+ import { scan } from './scan.js';
8
+ import { PAGE } from './servers.js';
9
+ /** The ledger's log of each commitment, found by its index (`contracts/src/CommitmentLedger.sol`). */
10
+ const committedEvent = parseAbiItem('event Committed(uint64 indexed index, uint256[4] fields)');
11
+ const DAY_MS = 86_400_000;
12
+ /** Storage is read in full this often, in case a replica left entries out of its log. */
13
+ const FULL_REFRESH_MS = 3_600_000;
14
+ // `field0`'s high 32 bits (contracts/src/libs/Protocol.sol)
15
+ const NOTE_TAG = 0x789f26ebn;
16
+ const TRANSFER_TAG = 0xddf252adn;
17
+ /** Look at entries past the final count only this long after something was started or announced. */
18
+ export const WAIT_MS = 30 * 60_000;
19
+ const hex32 = (b) => bytesToHex(b);
20
+ const big = (b) => BigInt(hex32(b));
21
+ /** The final commitment count when the indexer last reported at or before `atMs`, so nothing announced after it
22
+ * can be committed before it. 0 without such a sample (a restore reads from the birthday). */
23
+ export function commitmentsAt(samples, atMs) {
24
+ let best = 0;
25
+ for (const s of samples)
26
+ if (s.atMs <= atMs)
27
+ best = Math.max(best, s.commitments);
28
+ return best;
29
+ }
30
+ /** A note's hash under the key that owns it (the wallet's, or with `keyIndex` a receive code's), and the watch for its
31
+ * tag. */
32
+ function watchFor(ctx, chainId, tokenId, value, salt, keyIndex, from) {
33
+ const { publicKey } = spendKey(ctx, keyIndex === undefined ? undefined : { chainId, tokenId, index: keyIndex });
34
+ const noteHash = core.noteHash(chainId, publicKey, `0x${value.toString(16)}`, tokenId, salt);
35
+ return { noteHash, watch: { key: numberToHex(core.noteTag(tokenId, noteHash), { size: 4 }), from, scannedTo: from } };
36
+ }
37
+ /** The storage state, empty until its first refresh. It watches every chain of the network, also one the network
38
+ * gained after the state was saved: the next refresh reads that one's lanes from slot 0. */
39
+ export function storageState(ctx) {
40
+ const chains = ctx.chains.map((c) => c.config.chainId);
41
+ const state = ctx.state.storage ?? emptyState(ctx.view, chains);
42
+ const gained = chains.filter((c) => !state.chains.includes(c));
43
+ return (ctx.state.storage = gained.length > 0 ? { ...state, chains: [...state.chains, ...gained] } : state);
44
+ }
45
+ async function syncStorage(ctx, tokens) {
46
+ const full = Date.now() - ctx.lastFullRefreshMs > FULL_REFRESH_MS;
47
+ takeRefresh(ctx, await refresh(ctx.storage, ctx.view, storageState(ctx), { full }), tokens);
48
+ if (full)
49
+ ctx.lastFullRefreshMs = Date.now();
50
+ await writeReceipts(ctx, tokens);
51
+ }
52
+ /** Writes each receipt storage does not hold yet to the own lane, one a batch: `planReceipt` takes the lane's next
53
+ * slot, which a second one in the batch would take too. The refresh that follows brings it back as written. */
54
+ async function writeReceipts(ctx, tokens) {
55
+ for (const r of ctx.state.receipts.filter((r) => r.unwritten)) {
56
+ const head = { chainId: r.chainId, tokenId: r.tokenId, burnAddress: hexToBytes(r.burnAddress) };
57
+ const receipt = r.kind === 'returned'
58
+ ? {
59
+ ...head,
60
+ kind: r.kind,
61
+ index: r.index,
62
+ recipient: hexToBytes(r.recipient),
63
+ amount: hexToBytes(numberToHex(r.amount, { size: 32 })),
64
+ txHash: hexToBytes(r.txHash),
65
+ }
66
+ : { ...head, kind: r.kind, depositAddress: hexToBytes(r.depositAddress) };
67
+ takeRefresh(ctx, await commit(ctx.storage, ctx.view, storageState(ctx), (s) => planReceipt(ctx.view, s, receipt)), tokens);
68
+ delete r.unwritten;
69
+ await ctx.save();
70
+ }
71
+ }
72
+ /** A storage receipt as the wallet keeps it. */
73
+ export const noteReceipt = (r) => ({
74
+ kind: r.kind,
75
+ amount: big(r.amount),
76
+ fee: big(r.fee),
77
+ counterparty: bytesToHex(r.counterparty),
78
+ });
79
+ /** Each chain's token ids. */
80
+ export async function tokenIds(ctx) {
81
+ const tokens = new Map();
82
+ await Promise.all(ctx.chains.map(async (c) => tokens.set(c.config.chainId, (await c.chain.tokens()).map((t) => t.tokenId))));
83
+ return tokens;
84
+ }
85
+ /** Takes a storage refresh: its state, plus what it found for this wallet: notes (to its address, its receive codes
86
+ * and itself), deposits payers told, burn blocks recorded, deposits it paid others, and its receipts. A record is
87
+ * announced by one refresh only, so `commit`'s refresh must come through here too: a payment to oneself, or one that
88
+ * arrived just before, would otherwise be lost. */
89
+ export function takeRefresh(ctx, { state, added }, tokens) {
90
+ // the wallet keeps its own records: storage's copies would only double the saved state
91
+ for (const lane of [...state.incoming, ...state.outgoing.lanes, { chains: state.own }]) {
92
+ for (const chain of lane.chains)
93
+ chain.records = [];
94
+ }
95
+ ctx.state.storage = state;
96
+ for (const a of added) {
97
+ const r = a.record;
98
+ // what it paid others: a note's receipt rides on its change; a deposit is watched for here
99
+ if (a.direction === 'outgoing') {
100
+ if (r.kind === 'deposit')
101
+ takeSent(ctx, r, a.peer, tokens);
102
+ continue;
103
+ }
104
+ if (r.kind === 'deposit')
105
+ takeDeposit(ctx, r, a.peer, tokens);
106
+ else if (r.kind === 'burn-block')
107
+ takeBurnBlock(ctx, r, a.lane?.via.type === 'pos', tokens);
108
+ else if (r.kind === 'note')
109
+ upsertNote(ctx, a, r, tokens);
110
+ else if (r.kind !== 'pos-lane')
111
+ takeReceipt(ctx, r); // a point of sale's lane is read by the storage refresh
112
+ }
113
+ }
114
+ /** A deposit this wallet paid into someone's burn address, as storage told it with the address: watched for its
115
+ * transfer, from a day before storage held it, to show once it landed. */
116
+ function takeSent(ctx, d, to, tokens) {
117
+ const { chainId, tokenId } = d;
118
+ if (!tokens.get(chainId)?.includes(tokenId))
119
+ return;
120
+ const address = bytesToHex(d.burnAddress);
121
+ if (ctx.state.sent.some((s) => s.chainId === chainId && s.address === address))
122
+ return;
123
+ const atMs = Number(d.atNs / 1000000n);
124
+ const from = commitmentsAt(chainState(ctx, chainId).samples, atMs - DAY_MS);
125
+ const watch = { key: numberToHex(core.transferTag(tokenId, address), { size: 4 }), from, scannedTo: from };
126
+ ctx.state.sent.push({
127
+ chainId,
128
+ tokenId,
129
+ address,
130
+ to: bytesToHex(to),
131
+ createdMs: atMs,
132
+ watches: [{ tokenId, watch }],
133
+ found: [],
134
+ });
135
+ }
136
+ /** A receipt from the own lane: one this device wrote, now held by storage (dated as storage holds it, alike on every
137
+ * device), or another device's. */
138
+ function takeReceipt(ctx, r) {
139
+ const burnAddress = bytesToHex(r.burnAddress);
140
+ const atMs = Number(r.atNs / 1000000n);
141
+ const same = ctx.state.receipts.find((x) => x.chainId === r.chainId &&
142
+ x.kind === r.kind &&
143
+ x.burnAddress === burnAddress &&
144
+ (x.kind !== 'returned' || (r.kind === 'returned' && x.index === r.index)));
145
+ if (same) {
146
+ delete same.unwritten;
147
+ same.atMs = atMs;
148
+ return;
149
+ }
150
+ const head = { chainId: r.chainId, tokenId: r.tokenId, burnAddress, atMs };
151
+ ctx.state.receipts.push(r.kind === 'returned'
152
+ ? {
153
+ ...head,
154
+ kind: r.kind,
155
+ index: r.index,
156
+ recipient: bytesToHex(r.recipient),
157
+ amount: big(r.amount),
158
+ txHash: bytesToHex(r.txHash),
159
+ }
160
+ : { ...head, kind: r.kind, depositAddress: bytesToHex(r.depositAddress) });
161
+ }
162
+ /** A burn address of this wallet's that a payer chose and told in storage, watched as a deposit of its own once its
163
+ * salt checks out under the wallet's key: one hash. */
164
+ export function takeDeposit(ctx, deposit, payer, tokens) {
165
+ const { chainId, tokenId } = deposit;
166
+ if (!tokens.get(chainId)?.includes(tokenId))
167
+ return;
168
+ const salt = hex32(deposit.burnSalt);
169
+ if (ctx.state.deposits.some((d) => d.chainId === chainId && d.salt === salt))
170
+ return;
171
+ const address = core.burnAddressOf(chainId, spendKey(ctx).publicKey, salt);
172
+ if (!address)
173
+ return;
174
+ const atMs = Number(deposit.atNs / 1000000n);
175
+ // storage held it before the payer's transfer; a day of margin covers clock skew
176
+ const from = commitmentsAt(chainState(ctx, chainId).samples, atMs - DAY_MS);
177
+ ctx.state.deposits.push({
178
+ chainId,
179
+ tokenId,
180
+ burnIndex: -1,
181
+ salt,
182
+ address,
183
+ createdMs: atMs,
184
+ depositAddress: null,
185
+ payer: hex32(payer),
186
+ watches: [
187
+ { tokenId, watch: { key: numberToHex(core.transferTag(tokenId, address), { size: 4 }), from, scannedTo: from } },
188
+ ],
189
+ found: [],
190
+ });
191
+ }
192
+ /** Burn addresses recorded in storage, by salt: each checked to be this wallet's own, which takes one hash, and watched
193
+ * from a day before it was recorded. A point of sale's (`pos`, from its lane) are its payment addresses, with no burn
194
+ * index of this wallet's. This wallet's own, from another device or from before a restore, are taken as handed out:
195
+ * none is handed out again. */
196
+ function takeBurnBlock(ctx, b, pos, tokens) {
197
+ if (!tokens.has(b.chainId))
198
+ return;
199
+ const { publicKey } = spendKey(ctx);
200
+ const atMs = Number(b.atNs / 1000000n);
201
+ const from = commitmentsAt(chainState(ctx, b.chainId).samples, atMs - DAY_MS);
202
+ b.salts.forEach((bytes, i) => {
203
+ const salt = hex32(bytes);
204
+ const address = core.burnAddressOf(b.chainId, publicKey, salt);
205
+ if (!address || ctx.state.deposits.some((d) => d.chainId === b.chainId && d.salt === salt))
206
+ return;
207
+ if (!pos) {
208
+ const next = chainState(ctx, b.chainId).burnNext;
209
+ next[b.tokenId] = Math.max(next[b.tokenId] ?? 0, b.first + i + 1);
210
+ }
211
+ const watch = { key: numberToHex(core.transferTag(b.tokenId, address), { size: 4 }), from, scannedTo: from };
212
+ const record = {
213
+ chainId: b.chainId,
214
+ tokenId: b.tokenId,
215
+ burnIndex: pos ? -1 : b.first + i,
216
+ salt,
217
+ address,
218
+ createdMs: atMs,
219
+ depositAddress: null,
220
+ watches: [{ tokenId: b.tokenId, watch }],
221
+ found: [],
222
+ };
223
+ if (pos)
224
+ record.managed = true;
225
+ ctx.state.deposits.push(record);
226
+ });
227
+ }
228
+ /** This wallet's lowest recorded burn address of a token that it has not handed out, if any. */
229
+ export function unusedBurn(ctx, chainId, tokenId) {
230
+ const next = chainState(ctx, chainId).burnNext[tokenId] ?? 0;
231
+ let best;
232
+ for (const d of ctx.state.deposits) {
233
+ if (d.chainId !== chainId || d.tokenId !== tokenId || d.managed || d.payer || d.burnIndex < next)
234
+ continue;
235
+ if (!best || d.burnIndex < best.burnIndex)
236
+ best = d;
237
+ }
238
+ return best;
239
+ }
240
+ /** Records this wallet's next burn address of a token in storage (a burn block of one), then watches it. Every device
241
+ * of the wallet, and a restore, then watches it too and never hands it out again. About 2^16 hashes. */
242
+ export async function recordBurn(ctx, chainId, tokenId, tokens, signal) {
243
+ let index = chainState(ctx, chainId).burnNext[tokenId] ?? 0;
244
+ for (const d of ctx.state.deposits) {
245
+ if (d.chainId === chainId && d.tokenId === tokenId && !d.managed)
246
+ index = Math.max(index, d.burnIndex + 1);
247
+ }
248
+ const { salt, address } = await burnAddress(ctx.seed, chainId, tokenId, index, signal);
249
+ const { commitmentsFinal: from } = await chainCtx(ctx, chainId).indexer.status();
250
+ const block = { chainId, tokenId, first: index, salts: [hexToBytes(salt)] };
251
+ const result = await commit(ctx.storage, ctx.view, storageState(ctx), (s) => planBurnBlock(ctx.view, s, block));
252
+ const record = {
253
+ chainId,
254
+ tokenId,
255
+ burnIndex: index,
256
+ salt,
257
+ address,
258
+ createdMs: Date.now(),
259
+ depositAddress: null,
260
+ // watched under this token right away, and under every other token from the next sync on
261
+ watches: [
262
+ { tokenId, watch: { key: numberToHex(core.transferTag(tokenId, address), { size: 4 }), from, scannedTo: from } },
263
+ ],
264
+ found: [],
265
+ };
266
+ // before the refresh, which brings the block back: taken as new, it would count as handed out
267
+ ctx.state.deposits.push(record);
268
+ takeRefresh(ctx, result, tokens);
269
+ await ctx.save();
270
+ return record;
271
+ }
272
+ /** Keeps one burn address recorded ahead for each token this wallet has handed one out for, so that handing it out
273
+ * writes nothing: a write then would mark the transfer that follows as this wallet's. A token's first one is recorded
274
+ * as it is handed out. Reads the tokens only when one is due. */
275
+ export async function topUpBurns(ctx, signal) {
276
+ const onNetwork = new Set(ctx.chains.map((c) => c.config.chainId));
277
+ const used = new Map(ctx.state.deposits
278
+ .filter((d) => !d.managed && onNetwork.has(d.chainId))
279
+ .map((d) => [`${d.chainId}:${d.tokenId}`, d]));
280
+ let tokens;
281
+ for (const { chainId, tokenId } of used.values()) {
282
+ if (!unusedBurn(ctx, chainId, tokenId))
283
+ await recordBurn(ctx, chainId, tokenId, (tokens ??= await tokenIds(ctx)), signal);
284
+ }
285
+ }
286
+ function upsertNote(ctx, a, note, tokens) {
287
+ const { chainId, tokenId, value, noteSalt } = note.opening;
288
+ if (!tokens.has(chainId))
289
+ return;
290
+ const salt = hex32(noteSalt);
291
+ const peer = a.peer && hex32(a.peer);
292
+ const { atNs } = note;
293
+ const existing = ctx.state.notes.find((n) => n.chainId === chainId && n.salt === salt);
294
+ const receipt = note.receipt && noteReceipt(note.receipt);
295
+ if (existing) {
296
+ // recorded before storage announced it (a payment's change): keep what was found
297
+ Object.assign(existing, { peer, atNs }, receipt && { receipt });
298
+ return;
299
+ }
300
+ // a payment to one of this wallet's codes is owned by the code's key, and is in the code's token
301
+ const via = a.lane?.via;
302
+ const code = via?.type === 'code' && 'index' in via ? via : null;
303
+ if (code && code.tokenId !== tokenId)
304
+ return;
305
+ const keyIndex = code?.index;
306
+ // storage first held it before the payer committed it on chain; a day of margin covers clock skew
307
+ const from = commitmentsAt(chainState(ctx, chainId).samples, Number(atNs / 1000000n) - DAY_MS);
308
+ ctx.state.notes.push({
309
+ chainId,
310
+ tokenId,
311
+ value: big(value),
312
+ salt,
313
+ source: a.direction === 'own' ? 'self' : 'received',
314
+ ...(receipt && { receipt }),
315
+ peer,
316
+ atNs,
317
+ ...(keyIndex === undefined ? {} : { keyIndex }),
318
+ ...watchFor(ctx, chainId, tokenId, big(value), salt, keyIndex, from),
319
+ found: [],
320
+ });
321
+ }
322
+ /** Whether something was started or announced recently enough to look past the final count for it. */
323
+ function waiting(ctx, chainId, now) {
324
+ const recent = (ms) => ms !== null && now - ms < WAIT_MS;
325
+ return (ctx.state.ops.some((o) => o.chainId === chainId && o.status === 'pending' && recent(o.createdMs)) ||
326
+ [...ctx.state.deposits, ...ctx.state.sent].some((d) => d.chainId === chainId && d.found.length === 0 && recent(d.createdMs)) ||
327
+ ctx.state.notes.some((n) => n.chainId === chainId && n.found.length === 0 && n.atNs !== null && recent(Number(n.atNs / 1000000n))));
328
+ }
329
+ /** One chain: new tags, deposit details, then nullifiers. */
330
+ async function syncChain(ctx, c, tokens) {
331
+ const chainId = c.config.chainId;
332
+ const s = chainState(ctx, chainId);
333
+ const status = await c.indexer.status();
334
+ const now = Date.now();
335
+ const last = s.samples.at(-1);
336
+ if (!last || status.commitmentsFinal >= last.commitments + PAGE) {
337
+ s.samples.push({ atMs: now, commitments: status.commitmentsFinal, nullifiers: status.nullifiersFinal });
338
+ }
339
+ // every burn address is watched under every token, new tokens included
340
+ for (const d of ctx.state.deposits.filter((d) => d.chainId === chainId)) {
341
+ for (const tokenId of tokens) {
342
+ if (d.watches.some((w) => w.tokenId === tokenId))
343
+ continue;
344
+ const from = d.watches[0].watch.from;
345
+ d.watches.push({
346
+ tokenId,
347
+ watch: { key: numberToHex(core.transferTag(tokenId, d.address), { size: 4 }), from, scannedTo: from },
348
+ });
349
+ }
350
+ }
351
+ // tags: notes, deposits, and deposits paid to others
352
+ const notes = ctx.state.notes.filter((n) => n.chainId === chainId);
353
+ const deposits = ctx.state.deposits.filter((d) => d.chainId === chainId);
354
+ const sent = ctx.state.sent.filter((s) => s.chainId === chainId);
355
+ const transfers = [...deposits, ...sent];
356
+ const watches = [
357
+ ...notes.flatMap((n) => (n.watch ? [n.watch] : [])),
358
+ ...transfers.flatMap((d) => d.watches.map((w) => w.watch)),
359
+ ];
360
+ const live = waiting(ctx, chainId, now) ? status.commitments : status.commitmentsFinal;
361
+ const hits = await scan(watches, 4, (p) => c.indexer.page('tags', p), status.commitmentsFinal, live);
362
+ applyHits(notes, transfers, hits);
363
+ dropReorged([...notes, ...transfers], status.commitmentsFinal);
364
+ const fieldsAt = commitmentFields(c.indexer);
365
+ // a note is counted once its commitment is the note it expects: a 4-byte tag matches someone else's now and then.
366
+ // Read as soon as it is seen, so that it shows; read again once it is final (`place` clears it)
367
+ for (const n of notes) {
368
+ for (const f of n.found.filter((f) => !f.confirmed)) {
369
+ const fields = await fieldsAt(f.index);
370
+ if (!fields)
371
+ continue;
372
+ const ours = fields[0] === ((NOTE_TAG << 32n) | BigInt(n.tokenId)) &&
373
+ fields[1] === BigInt(n.noteHash) &&
374
+ fields[2] === 0n &&
375
+ fields[3] === 0n;
376
+ if (ours)
377
+ f.confirmed = true;
378
+ else
379
+ n.found.splice(n.found.indexOf(f), 1);
380
+ }
381
+ // a note's hash is unique (its payer chose a fresh salt): once confirmed where no reorg moves it, stop watching for it
382
+ if (n.found.some((f) => f.confirmed && !f.tentative))
383
+ n.watch = null;
384
+ }
385
+ await readTransfers(fieldsAt, transfers);
386
+ const asp = await readDecisions(c.asp, deposits);
387
+ // only a hint for the app: it holds back nothing
388
+ if (asp)
389
+ await estimateDecisions(c, status.finalBlock, asp.delaySecs, deposits).catch(() => { });
390
+ // each one's block time, read with the page of commitments just read, so it tells the indexer nothing more
391
+ const timeAt = commitmentTimes(c.indexer);
392
+ for (const f of [...notes.flatMap((r) => r.found), ...transfers.flatMap((r) => r.found)])
393
+ f.atMs ??= (await timeAt(f.index)) ?? undefined;
394
+ // nullifiers of everything final and unspent
395
+ const marks = new Map();
396
+ const nullifierFrom = async (index) => {
397
+ const commitmentPage = Math.floor(index / PAGE);
398
+ const p = Math.floor(commitmentPage / PAGE);
399
+ if (!marks.has(p))
400
+ marks.set(p, await c.indexer.page('marks', p));
401
+ const at = (commitmentPage % PAGE) * 16 + 8;
402
+ const page = marks.get(p);
403
+ return at + 8 <= page.length ? Number(new DataView(page.buffer, page.byteOffset).getBigUint64(at, false)) : 0;
404
+ };
405
+ // a zero-value change is never spent: it carries its payment's receipt
406
+ const owned = [
407
+ ...notes
408
+ .filter((n) => n.value > 0n)
409
+ .flatMap((n) => n.found.map((f) => ({
410
+ f,
411
+ code: n.keyIndex === undefined ? undefined : { chainId, tokenId: n.tokenId, index: n.keyIndex },
412
+ }))),
413
+ ...deposits.flatMap((d) => d.found.map((f) => ({ f, code: undefined }))),
414
+ ].filter(({ f }) => !f.tentative && !f.spent);
415
+ for (const { f, code } of owned) {
416
+ if (f.nullifier)
417
+ continue;
418
+ const nullifier = core.nullifier(chainId, BigInt(f.index), spendKey(ctx, code).nullifyingKey);
419
+ const from = await nullifierFrom(f.index);
420
+ f.nullifier = { key: nullifier.slice(0, 18), from, scannedTo: from };
421
+ }
422
+ const nullifierWatches = owned.map(({ f }) => f.nullifier);
423
+ const spent = await scan(nullifierWatches, 8, (p) => c.indexer.page('nullifiers', p), status.nullifiersFinal, live === status.commitments ? status.nullifiers : status.nullifiersFinal);
424
+ const byNullifier = new Map(owned.map(({ f }) => [f.nullifier.key, f]));
425
+ for (const hit of spent) {
426
+ const f = byNullifier.get(hit.key);
427
+ if (f && !hit.tentative)
428
+ f.spent = true;
429
+ }
430
+ // a spent position needs no more watching
431
+ for (const r of [...notes, ...deposits])
432
+ for (const f of r.found)
433
+ if (f.spent)
434
+ delete f.nullifier;
435
+ }
436
+ /** The four fields of the commitment at an index, read from its page of commitments, each page once per reader: `null`
437
+ * until the page is served that far. */
438
+ export function commitmentFields(indexer) {
439
+ const pages = new Map();
440
+ return async (index) => {
441
+ const chunk = Math.floor(index / PAGE);
442
+ let page = pages.get(chunk);
443
+ if (!page)
444
+ pages.set(chunk, (page = indexer.commitments(chunk).then((p) => p.entries)));
445
+ const entries = await page;
446
+ const at = (index % PAGE) * 128;
447
+ if (entries.length < at + 128)
448
+ return null; // not served yet
449
+ return [0, 1, 2, 3].map((f) => BigInt(bytesToHex(entries.subarray(at + 32 * f, at + 32 * (f + 1)))));
450
+ };
451
+ }
452
+ /** The block time (ms) of the commitment at an index, from its page of times, each page once per reader: `null` until
453
+ * the page is served that far, or when it cannot be read. */
454
+ export function commitmentTimes(indexer) {
455
+ const pages = new Map();
456
+ return async (index) => {
457
+ const p = Math.floor(index / PAGE);
458
+ let page = pages.get(p);
459
+ if (!page)
460
+ pages.set(p, (page = indexer.page('times', p).catch(() => null)));
461
+ const bytes = await page;
462
+ const at = (index % PAGE) * 8;
463
+ if (!bytes || bytes.length < at + 8)
464
+ return null;
465
+ return Number(new DataView(bytes.buffer, bytes.byteOffset).getBigUint64(at, false)) * 1000;
466
+ };
467
+ }
468
+ /** The transaction that committed `index`: the ledger's log of it, in the block the indexer names. Null while the
469
+ * indexer does not serve that block yet; its hash null while the chain's RPC does not show the log. */
470
+ export async function commitmentTx(chain, indexer, index) {
471
+ const blocks = await indexer.page('blocks', Math.floor(index / PAGE));
472
+ const at = (index % PAGE) * 8;
473
+ if (blocks.length < at + 8)
474
+ return null;
475
+ const block = BigInt(bytesToHex(blocks.subarray(at, at + 8)));
476
+ const [log] = await chain.client.getLogs({
477
+ address: await chain.ledger(),
478
+ event: committedEvent,
479
+ args: { index: BigInt(index) },
480
+ fromBlock: block,
481
+ toBlock: block,
482
+ });
483
+ return { hash: log?.transactionHash ?? null, block };
484
+ }
485
+ /** Each deposit's sender and amount, read as soon as it is seen, so that it shows; read again once it is final (`place`
486
+ * clears them), since a reorg may still move what sits at a position until then. A tag collision is dropped. */
487
+ export async function readTransfers(fieldsAt, deposits) {
488
+ for (const d of deposits) {
489
+ for (const f of d.found.filter((f) => f.amount === null)) {
490
+ const fields = await fieldsAt(f.index);
491
+ if (!fields)
492
+ continue;
493
+ if (fields[0] !== ((TRANSFER_TAG << 32n) | BigInt(f.tokenId)) || fields[2] !== BigInt(d.address)) {
494
+ d.found.splice(d.found.indexOf(f), 1);
495
+ continue;
496
+ }
497
+ f.sender = numberToHex(fields[1], { size: 20 });
498
+ f.amount = fields[3];
499
+ }
500
+ }
501
+ }
502
+ /** The ASP's decisions on deposits it has not decided yet: it decides only final ones. Returns the ASP's status, if it
503
+ * was read. */
504
+ export async function readDecisions(asp, deposits) {
505
+ const undecided = deposits.flatMap((d) => d.found).filter((f) => !f.spent && f.asp === null);
506
+ if (undecided.length === 0)
507
+ return null;
508
+ const status = await asp.status();
509
+ const bitmaps = new Map();
510
+ for (const f of undecided.filter((f) => f.index < status.decided)) {
511
+ const chunk = Math.floor(f.index / PAGE);
512
+ if (!bitmaps.has(chunk))
513
+ bitmaps.set(chunk, await asp.bitmap(chunk));
514
+ const j = f.index % PAGE;
515
+ f.asp = (bitmaps.get(chunk)[j >> 3] >> (j & 7)) & 1 ? 'approved' : 'rejected';
516
+ }
517
+ return status;
518
+ }
519
+ /** When the ASP should decide each deposit it has not: once its block is final and `delaySecs` old. Taken from block
520
+ * numbers, the latest block and the final one, so that no server is asked about a block of this wallet's. */
521
+ async function estimateDecisions(c, finalBlock, delaySecs, deposits) {
522
+ const undecided = deposits.flatMap((d) => d.found).filter((f) => !f.spent && f.asp === null);
523
+ if (undecided.length === 0 || finalBlock === null)
524
+ return;
525
+ const [latest, final] = await Promise.all([
526
+ c.chain.client.getBlock(),
527
+ c.chain.client.getBlock({ blockNumber: BigInt(finalBlock) }),
528
+ ]);
529
+ const at = (b) => ({ number: Number(b.number), timestamp: Number(b.timestamp) });
530
+ const pages = new Map();
531
+ for (const f of undecided) {
532
+ const p = Math.floor(f.index / PAGE);
533
+ if (!pages.has(p))
534
+ pages.set(p, await c.indexer.page('blocks', p));
535
+ const page = pages.get(p);
536
+ const o = (f.index % PAGE) * 8;
537
+ if (page.length < o + 8)
538
+ continue; // not served yet
539
+ const block = Number(new DataView(page.buffer, page.byteOffset).getBigUint64(o, false));
540
+ f.decidedByMs = decisionEstimate(block, at(latest), at(final), delaySecs) ?? f.decidedByMs;
541
+ }
542
+ }
543
+ /** When a deposit in `block` should be decided (ms), with blocks taken to come evenly from the final block to the
544
+ * latest, and to become final as late after their time as the final one has: null where no block came in between. */
545
+ export function decisionEstimate(block, latest, final, delaySecs) {
546
+ const blocks = latest.number - final.number;
547
+ if (blocks <= 0)
548
+ return null;
549
+ const lag = latest.timestamp - final.timestamp;
550
+ const time = latest.timestamp - ((latest.number - block) * lag) / blocks;
551
+ return (time + Math.max(delaySecs, lag)) * 1000;
552
+ }
553
+ /** Records tag hits on the notes and deposits they belong to, through one map from key to owners: a restore can
554
+ * bring thousands of hits for thousands of notes. */
555
+ export function applyHits(notes, deposits, hits) {
556
+ const owners = new Map();
557
+ const add = (key, o) => {
558
+ const list = owners.get(key);
559
+ if (list)
560
+ list.push(o);
561
+ else
562
+ owners.set(key, [o]);
563
+ };
564
+ for (const n of notes)
565
+ if (n.watch)
566
+ add(n.watch.key, { note: n });
567
+ for (const d of deposits)
568
+ for (const w of d.watches)
569
+ add(w.watch.key, { deposit: d, tokenId: w.tokenId });
570
+ for (const hit of hits) {
571
+ for (const o of owners.get(hit.key) ?? []) {
572
+ const at = { index: hit.position, tentative: hit.tentative, spent: false };
573
+ if ('note' in o)
574
+ place(o.note.found, { ...at, confirmed: false });
575
+ else
576
+ place(o.deposit.found, { ...at, tokenId: o.tokenId, sender: null, amount: null, asp: null });
577
+ }
578
+ }
579
+ }
580
+ /** Records a found position once. A later final sighting replaces a tentative one, with whatever was read of it (a
581
+ * reorg may have moved what sat there, and to another block), but not that this wallet spent it. */
582
+ function place(found, hit) {
583
+ const same = found.find((f) => f.index === hit.index);
584
+ if (!same)
585
+ found.push(hit);
586
+ else if (same.tentative && !hit.tentative) {
587
+ Object.assign(same, hit, { spent: same.spent });
588
+ delete same.atMs;
589
+ }
590
+ }
591
+ /** Drops each position seen before it was final that the final count has passed without finding it there: a reorg took
592
+ * it away (it shows up again where it went). The final scan covers every tentative position's index, as its watch is
593
+ * kept until the position is final. */
594
+ export function dropReorged(records, finalCount) {
595
+ for (const r of records)
596
+ r.found = r.found.filter((f) => !f.tentative || f.index >= finalCount);
597
+ }
598
+ /** Everything new since the last sync, then saved. A chain or storage that fails holds back only itself: the rest go
599
+ * on, and its error is returned. */
600
+ export async function syncAll(ctx) {
601
+ const errors = [];
602
+ const kept = (p) => p.catch((e) => (errors.push(e), null));
603
+ const listed = await Promise.all(ctx.chains.map((c) => kept(c.chain.tokens().then((ts) => [c.config.chainId, ts.map((t) => t.tokenId)]))));
604
+ const tokens = new Map(listed.filter((l) => l !== null));
605
+ // storage announces a record once, and a refresh drops one of a chain or token it is not given: only with them all
606
+ if (tokens.size === ctx.chains.length)
607
+ await kept(syncStorage(ctx, tokens));
608
+ // side by side: each changes only its own chain's records. Every one settles before the next sync can start
609
+ await Promise.all(ctx.chains
610
+ .filter((c) => tokens.has(c.config.chainId))
611
+ .map((c) => kept(syncChain(ctx, c, tokens.get(c.config.chainId)))));
612
+ await ctx.save();
613
+ return errors;
614
+ }
615
+ //# sourceMappingURL=sync.js.map