@zerc20/wallet-sdk 0.2.0 → 0.3.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/src/state.ts CHANGED
@@ -21,10 +21,16 @@ export interface Position {
21
21
  spent: boolean;
22
22
  /** Looking for its nullifier, once final. */
23
23
  nullifier?: Watch;
24
+ /** Where among the nullifiers its own was seen past the final count: spent by a JoinSplit in a block, which a reorg
25
+ * may still drop. */
26
+ nullifiedAt?: number;
24
27
  /** Its block's time (ms), read with its commitment: the same on every device. Unset until read. */
25
28
  atMs?: number;
26
29
  }
27
30
 
31
+ /** Neither spent nor spent by a JoinSplit in a block: one waits only to be final, so it is gone already. */
32
+ export const unspent = (f: Position): boolean => !f.spent && f.nullifiedAt === undefined;
33
+
28
34
  /** What a payment, withdrawal or merge was, kept on its change or merged note in storage (the storage SDK's
29
35
  * `Receipt`): every device of the wallet shows it once that note lands. */
30
36
  export interface NoteReceipt {
@@ -51,6 +57,9 @@ export interface NoteRecord {
51
57
  atNs: bigint | null;
52
58
  /** For a note paid to a receive code, the code's index: the code's own key owns it. Otherwise the wallet's key does. */
53
59
  keyIndex?: number;
60
+ /** Paid to a receive code whose key was not derived when storage told of it (another device's code, while the seed
61
+ * was not held): its hash and watch wait for the seed, and nothing counts it until then. */
62
+ unkeyed?: true;
54
63
  /** Its hash under the key that owns it. */
55
64
  noteHash: Hex;
56
65
  /** Its tag, watched until it is found where no reorg moves it. */
@@ -183,22 +192,23 @@ export const emptyWalletState = (address: Hex): WalletState => ({
183
192
  });
184
193
 
185
194
  const TAG = new TextEncoder().encode('zerc20/wallet/state/v1');
186
- const keyOf = (seed: Uint8Array) => hkdf(sha256, seed, TAG, new TextEncoder().encode('wallet-state'), 32);
195
+ /** The key the state is sealed under: the seed's, kept while the seed is not, so that a sync saves without it. */
196
+ export const stateKey = (seed: Uint8Array) => hkdf(sha256, seed, TAG, new TextEncoder().encode('wallet-state'), 32);
187
197
 
188
- /** `state` as bytes only the seed opens: `nonce(12) ‖ ChaCha20-Poly1305(JSON)`. */
189
- export function sealState(seed: Uint8Array, state: WalletState): Uint8Array {
198
+ /** `state` as bytes only the seed's `stateKey` opens: `nonce(12) ‖ ChaCha20-Poly1305(JSON)`. */
199
+ export function sealState(key: Uint8Array, state: WalletState): Uint8Array {
190
200
  const json = JSON.stringify(state, (_, v: unknown) =>
191
201
  typeof v === 'bigint' ? { $n: v.toString() } : v instanceof Uint8Array ? { $b: bytesToHex(v) } : v,
192
202
  );
193
203
  const nonce = randomBytes(12);
194
- const sealed = chacha20poly1305(keyOf(seed), nonce, TAG).encrypt(new TextEncoder().encode(json));
204
+ const sealed = chacha20poly1305(key, nonce, TAG).encrypt(new TextEncoder().encode(json));
195
205
  return new Uint8Array([...nonce, ...sealed]);
196
206
  }
197
207
 
198
- /** The state `sealState` wrote, or `null` if `bytes` is not one this seed opens. */
199
- export function openState(seed: Uint8Array, bytes: Uint8Array): WalletState | null {
208
+ /** The state `sealState` wrote, or `null` if `bytes` is not one `key` opens. */
209
+ export function openState(key: Uint8Array, bytes: Uint8Array): WalletState | null {
200
210
  try {
201
- const plain = chacha20poly1305(keyOf(seed), bytes.subarray(0, 12), TAG).decrypt(bytes.subarray(12));
211
+ const plain = chacha20poly1305(key, bytes.subarray(0, 12), TAG).decrypt(bytes.subarray(12));
202
212
  const state = JSON.parse(new TextDecoder().decode(plain), (_, v: unknown) => {
203
213
  if (typeof v === 'object' && v !== null && '$n' in v) return BigInt(v.$n as string);
204
214
  if (typeof v === 'object' && v !== null && '$b' in v) return hexToBytes(v.$b as string);
package/src/sync.ts CHANGED
@@ -9,6 +9,7 @@ import {
9
9
  type OwnReceipt,
10
10
  type Receipt,
11
11
  type SyncState,
12
+ burnKey,
12
13
  commit,
13
14
  emptyState,
14
15
  planBurnBlock,
@@ -16,12 +17,12 @@ import {
16
17
  refresh,
17
18
  } from '@zerc20/storage-sdk';
18
19
  import { type Hex, bytesToHex, hexToBytes, numberToHex, parseAbiItem } from 'viem';
19
- import type { Chain } from './chain.js';
20
- import { type ChainCtx, type Ctx, chainCtx, chainState, spendKey } from './context.js';
20
+ import type { Chain, Token } from './chain.js';
21
+ import { type ChainCtx, type Ctx, chainCtx, chainState, owner } from './context.js';
21
22
  import { burnAddress, core } from './core.js';
22
23
  import { type Hit, type Watch, scan } from './scan.js';
23
24
  import { type Asp, type AspStatus, type Indexer, PAGE } from './servers.js';
24
- import type { DepositRecord, NoteReceipt, NoteRecord, Sample } from './state.js';
25
+ import type { DepositRecord, NoteReceipt, NoteRecord, Position, Sample } from './state.js';
25
26
 
26
27
  /** The ledger's log of each commitment, found by its index (`contracts/src/CommitmentLedger.sol`). */
27
28
  const committedEvent = parseAbiItem('event Committed(uint64 indexed index, uint256[4] fields)');
@@ -47,7 +48,7 @@ export function commitmentsAt(samples: Sample[], atMs: number): number {
47
48
  }
48
49
 
49
50
  /** 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
50
- * tag. */
51
+ * tag. Null while that key's owner is not derived. */
51
52
  function watchFor(
52
53
  ctx: Ctx,
53
54
  chainId: bigint,
@@ -57,8 +58,9 @@ function watchFor(
57
58
  keyIndex: number | undefined,
58
59
  from: number,
59
60
  ) {
60
- const { publicKey } = spendKey(ctx, keyIndex === undefined ? undefined : { chainId, tokenId, index: keyIndex });
61
- const noteHash = core.noteHash(chainId, publicKey, `0x${value.toString(16)}`, tokenId, salt) as Hex;
61
+ const key = owner(ctx, keyIndex === undefined ? undefined : { chainId, tokenId, index: keyIndex });
62
+ if (!key) return null;
63
+ const noteHash = core.noteHash(chainId, key.publicKey, `0x${value.toString(16)}`, tokenId, salt) as Hex;
62
64
  return { noteHash, watch: { key: numberToHex(core.noteTag(tokenId, noteHash), { size: 4 }), from, scannedTo: from } };
63
65
  }
64
66
 
@@ -214,7 +216,7 @@ export function takeDeposit(ctx: Ctx, deposit: Deposit, payer: Uint8Array, token
214
216
  if (!tokens.get(chainId)?.includes(tokenId)) return;
215
217
  const salt = hex32(deposit.burnSalt);
216
218
  if (ctx.state.deposits.some((d) => d.chainId === chainId && d.salt === salt)) return;
217
- const address = core.burnAddressOf(chainId, spendKey(ctx).publicKey, salt) as Hex | undefined;
219
+ const address = core.burnAddressOf(chainId, owner(ctx)!.publicKey, salt) as Hex | undefined;
218
220
  if (!address) return;
219
221
  const atMs = Number(deposit.atNs / 1_000_000n);
220
222
  // storage held it before the payer's transfer; a day of margin covers clock skew
@@ -246,7 +248,7 @@ function takeBurnBlock(
246
248
  tokens: Map<bigint, number[]>,
247
249
  ): void {
248
250
  if (!tokens.has(b.chainId)) return;
249
- const { publicKey } = spendKey(ctx);
251
+ const { publicKey } = owner(ctx)!;
250
252
  const atMs = Number(b.atNs / 1_000_000n);
251
253
  const from = commitmentsAt(chainState(ctx, b.chainId).samples, atMs - DAY_MS);
252
254
  b.salts.forEach((bytes, i) => {
@@ -285,8 +287,22 @@ export function unusedBurn(ctx: Ctx, chainId: bigint, tokenId: number): DepositR
285
287
  return best;
286
288
  }
287
289
 
290
+ /** The index of the next burn address of a token to record: past every one recorded, here or by another device. */
291
+ function toRecord(ctx: Ctx, chainId: bigint, tokenId: number): number {
292
+ let index = chainState(ctx, chainId).burnNext[tokenId] ?? 0;
293
+ for (const d of ctx.state.deposits) {
294
+ if (d.chainId === chainId && d.tokenId === tokenId && !d.managed) index = Math.max(index, d.burnIndex + 1);
295
+ }
296
+ return index;
297
+ }
298
+
299
+ /** The index of the burn address of a token that is handed out next, and so of the receive code: the one recorded
300
+ * ahead, or else the next to record. */
301
+ export const nextBurn = (ctx: Ctx, chainId: bigint, tokenId: number): number =>
302
+ unusedBurn(ctx, chainId, tokenId)?.burnIndex ?? toRecord(ctx, chainId, tokenId);
303
+
288
304
  /** Records this wallet's next burn address of a token in storage (a burn block of one), then watches it. Every device
289
- * of the wallet, and a restore, then watches it too and never hands it out again. About 2^16 hashes. */
305
+ * of the wallet, and a restore, then watches it too and never hands it out again. About 2^16 hashes; needs no seed. */
290
306
  export async function recordBurn(
291
307
  ctx: Ctx,
292
308
  chainId: bigint,
@@ -294,11 +310,9 @@ export async function recordBurn(
294
310
  tokens: Map<bigint, number[]>,
295
311
  signal?: AbortSignal,
296
312
  ): Promise<DepositRecord> {
297
- let index = chainState(ctx, chainId).burnNext[tokenId] ?? 0;
298
- for (const d of ctx.state.deposits) {
299
- if (d.chainId === chainId && d.tokenId === tokenId && !d.managed) index = Math.max(index, d.burnIndex + 1);
300
- }
301
- const { salt, address } = await burnAddress(ctx.seed, chainId, tokenId, index, signal);
313
+ const index = toRecord(ctx, chainId, tokenId);
314
+ const key = burnKey(ctx.view.seed);
315
+ const { salt, address } = await burnAddress(key, chainId, tokenId, index, owner(ctx)!.publicKey, signal);
302
316
  const { commitmentsFinal: from } = await chainCtx(ctx, chainId).indexer.status();
303
317
  const block = { chainId, tokenId, first: index, salts: [hexToBytes(salt)] };
304
318
  const result = await commit(ctx.storage, ctx.view, storageState(ctx), (s) => planBurnBlock(ctx.view, s, block));
@@ -370,11 +384,43 @@ function upsertNote(ctx: Ctx, a: NewRecord, note: Note, tokens: Map<bigint, numb
370
384
  peer,
371
385
  atNs,
372
386
  ...(keyIndex === undefined ? {} : { keyIndex }),
373
- ...watchFor(ctx, chainId, tokenId, big(value), salt, keyIndex, from),
387
+ // storage tells of a note once: one whose key waits for the seed is kept, not dropped
388
+ ...(watchFor(ctx, chainId, tokenId, big(value), salt, keyIndex, from) ?? {
389
+ unkeyed: true,
390
+ noteHash: '0x',
391
+ watch: null,
392
+ }),
374
393
  found: [],
375
394
  });
376
395
  }
377
396
 
397
+ /** Receive codes of each token whose keys are derived while the seed is held, to hand out while it is not. */
398
+ export const CODES_AHEAD = 4;
399
+
400
+ /** With the seed held, each owner not derived yet: the wallet's own, each of its burn addresses' (a receive code each),
401
+ * the next `CODES_AHEAD` codes' of each of `tokens`, and each note's key's; and the watch of each note that waited for
402
+ * its key. */
403
+ export function keyAll(ctx: Ctx, tokens: Token[]): void {
404
+ owner(ctx);
405
+ // ponytail: one derivation per own burn address as the wallet opens; keep owners in the saved state if wallets grow
406
+ // to thousands of them
407
+ for (const d of ctx.state.deposits) {
408
+ if (d.burnIndex >= 0 && !d.managed) owner(ctx, { chainId: d.chainId, tokenId: d.tokenId, index: d.burnIndex });
409
+ }
410
+ for (const { chainId, tokenId } of tokens) {
411
+ const next = nextBurn(ctx, chainId, tokenId);
412
+ for (let index = next; index < next + CODES_AHEAD; index++) owner(ctx, { chainId, tokenId, index });
413
+ }
414
+ for (const n of ctx.state.notes) {
415
+ if (n.keyIndex === undefined) continue;
416
+ owner(ctx, { chainId: n.chainId, tokenId: n.tokenId, index: n.keyIndex });
417
+ if (!n.unkeyed) continue;
418
+ const from = commitmentsAt(chainState(ctx, n.chainId).samples, Number((n.atNs ?? 0n) / 1_000_000n) - DAY_MS);
419
+ Object.assign(n, watchFor(ctx, n.chainId, n.tokenId, n.value, n.salt, n.keyIndex, from));
420
+ delete n.unkeyed;
421
+ }
422
+ }
423
+
378
424
  /** Whether something was started or announced recently enough to look past the final count for it. */
379
425
  function waiting(ctx: Ctx, chainId: bigint, now: number): boolean {
380
426
  const recent = (ms: number | null) => ms !== null && now - ms < WAIT_MS;
@@ -384,7 +430,12 @@ function waiting(ctx: Ctx, chainId: bigint, now: number): boolean {
384
430
  (d) => d.chainId === chainId && d.found.length === 0 && recent(d.createdMs),
385
431
  ) ||
386
432
  ctx.state.notes.some(
387
- (n) => n.chainId === chainId && n.found.length === 0 && n.atNs !== null && recent(Number(n.atNs / 1_000_000n)),
433
+ (n) =>
434
+ n.chainId === chainId &&
435
+ !n.unkeyed &&
436
+ n.found.length === 0 &&
437
+ n.atNs !== null &&
438
+ recent(Number(n.atNs / 1_000_000n)),
388
439
  )
389
440
  );
390
441
  }
@@ -476,12 +527,13 @@ async function syncChain(ctx: Ctx, c: ChainCtx, tokens: number[]): Promise<void>
476
527
  ...deposits.flatMap((d) => d.found.map((f) => ({ f, code: undefined }))),
477
528
  ].filter(({ f }) => !f.tentative && !f.spent);
478
529
  for (const { f, code } of owned) {
479
- if (f.nullifier) continue;
480
- const nullifier = core.nullifier(chainId, BigInt(f.index), spendKey(ctx, code).nullifyingKey);
530
+ const key = owner(ctx, code);
531
+ if (f.nullifier || !key) continue; // without its owner: looked for once the seed is held
532
+ const nullifier = core.nullifier(chainId, BigInt(f.index), key.nullifyingKey);
481
533
  const from = await nullifierFrom(f.index);
482
534
  f.nullifier = { key: nullifier.slice(0, 18) as Hex, from, scannedTo: from };
483
535
  }
484
- const nullifierWatches = owned.map(({ f }) => f.nullifier!);
536
+ const nullifierWatches = owned.flatMap(({ f }) => (f.nullifier ? [f.nullifier] : []));
485
537
  const spent = await scan(
486
538
  nullifierWatches,
487
539
  8,
@@ -489,13 +541,32 @@ async function syncChain(ctx: Ctx, c: ChainCtx, tokens: number[]): Promise<void>
489
541
  status.nullifiersFinal,
490
542
  live === status.commitments ? status.nullifiers : status.nullifiersFinal,
491
543
  );
492
- const byNullifier = new Map(owned.map(({ f }) => [f.nullifier!.key, f]));
493
- for (const hit of spent) {
544
+ applySpends(
545
+ [...notes, ...deposits].flatMap((r): Position[] => r.found),
546
+ spent,
547
+ );
548
+ }
549
+
550
+ /** Records nullifier hits on the positions they spend: a final one spends it, one past the final count marks it
551
+ * `nullifiedAt`, until the final nullifiers pass that place without it (a reorg dropped that JoinSplit, which shows up
552
+ * again where it went). Every device sees an input gone once its JoinSplit is in a block, as the one that sent it does. */
553
+ export function applySpends(positions: Position[], hits: Hit[]): void {
554
+ const byNullifier = new Map(positions.filter((f) => f.nullifier).map((f) => [f.nullifier!.key, f]));
555
+ for (const hit of hits) {
494
556
  const f = byNullifier.get(hit.key);
495
- if (f && !hit.tentative) f.spent = true;
557
+ if (!f) continue;
558
+ if (hit.tentative) f.nullifiedAt = hit.position;
559
+ else f.spent = true;
560
+ }
561
+ for (const f of positions) {
562
+ // a spent position needs no more watching
563
+ if (f.spent) {
564
+ delete f.nullifier;
565
+ delete f.nullifiedAt;
566
+ } else if (f.nullifier && f.nullifiedAt !== undefined && f.nullifiedAt < f.nullifier.scannedTo) {
567
+ delete f.nullifiedAt;
568
+ }
496
569
  }
497
- // a spent position needs no more watching
498
- for (const r of [...notes, ...deposits]) for (const f of r.found) if (f.spent) delete f.nullifier;
499
570
  }
500
571
 
501
572
  /** The four fields of the commitment at an index, read from its page of commitments, each page once per reader: `null`