@zerc20/wallet-sdk 0.3.0 → 0.5.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 (68) hide show
  1. package/README.md +1 -1
  2. package/dist/accountId.d.ts +5 -0
  3. package/dist/accountId.js +19 -0
  4. package/dist/accountId.js.map +1 -0
  5. package/dist/chain.d.ts +85 -9
  6. package/dist/chain.js +60 -33
  7. package/dist/chain.js.map +1 -1
  8. package/dist/context.d.ts +14 -6
  9. package/dist/context.js +17 -3
  10. package/dist/context.js.map +1 -1
  11. package/dist/core.d.ts +35 -5
  12. package/dist/core.js +95 -23
  13. package/dist/core.js.map +1 -1
  14. package/dist/errors.d.ts +2 -0
  15. package/dist/errors.js.map +1 -1
  16. package/dist/index.d.ts +5 -2
  17. package/dist/index.js +6 -2
  18. package/dist/index.js.map +1 -1
  19. package/dist/pos.d.ts +7 -1
  20. package/dist/pos.js +213 -55
  21. package/dist/pos.js.map +1 -1
  22. package/dist/rageExit.d.ts +27 -4
  23. package/dist/rageExit.js +160 -49
  24. package/dist/rageExit.js.map +1 -1
  25. package/dist/seed.d.ts +4 -0
  26. package/dist/seed.js +13 -1
  27. package/dist/seed.js.map +1 -1
  28. package/dist/servers.d.ts +2 -0
  29. package/dist/servers.js +1 -0
  30. package/dist/servers.js.map +1 -1
  31. package/dist/services.d.ts +11 -4
  32. package/dist/services.js +26 -7
  33. package/dist/services.js.map +1 -1
  34. package/dist/spend.d.ts +30 -9
  35. package/dist/spend.js +46 -17
  36. package/dist/spend.js.map +1 -1
  37. package/dist/state.d.ts +21 -0
  38. package/dist/state.js.map +1 -1
  39. package/dist/sync.d.ts +10 -5
  40. package/dist/sync.js +53 -19
  41. package/dist/sync.js.map +1 -1
  42. package/dist/viewKey.d.ts +14 -0
  43. package/dist/viewKey.js +45 -0
  44. package/dist/viewKey.js.map +1 -0
  45. package/dist/wallet.d.ts +109 -16
  46. package/dist/wallet.js +291 -85
  47. package/dist/wallet.js.map +1 -1
  48. package/package.json +3 -2
  49. package/src/accountId.ts +22 -0
  50. package/src/chain.ts +95 -49
  51. package/src/context.ts +35 -8
  52. package/src/core.ts +109 -24
  53. package/src/errors.ts +2 -0
  54. package/src/index.ts +18 -2
  55. package/src/pos.ts +240 -61
  56. package/src/rageExit.ts +237 -61
  57. package/src/seed.ts +24 -1
  58. package/src/servers.ts +4 -1
  59. package/src/services.ts +28 -15
  60. package/src/spend.ts +58 -23
  61. package/src/state.ts +25 -1
  62. package/src/sync.ts +57 -20
  63. package/src/viewKey.ts +49 -0
  64. package/src/wallet.ts +387 -106
  65. package/wasm/zerc20_wallet_core.d.ts +8 -1
  66. package/wasm/zerc20_wallet_core.js +31 -1
  67. package/wasm/zerc20_wallet_core_bg.wasm +0 -0
  68. package/wasm/zerc20_wallet_core_bg.wasm.d.ts +1 -0
package/src/spend.ts CHANGED
@@ -26,6 +26,7 @@ import { core } from './core.js';
26
26
  import { fail } from './errors.js';
27
27
  import { prove } from './prove.js';
28
28
  import { scan } from './scan.js';
29
+ import type { Indexer } from './servers.js';
29
30
  import {
30
31
  type Relayed,
31
32
  RelayUnknown,
@@ -74,8 +75,10 @@ export interface Step {
74
75
  request?: object;
75
76
  /** Reverts with no sign of it on chain: proved again with the same outputs a few times, then failed. */
76
77
  attempts: number;
77
- /** Once relayed: the indexer's nullifier count just after. Final nullifiers past it, and the input's still not among
78
- * them, means a reorg dropped the transaction. */
78
+ /** Once relayed: the block the relayer saw it mined in. */
79
+ relayedBlock?: number;
80
+ /** The indexer's nullifier count once it is past that block, or just after a relay that found it landed already.
81
+ * Final nullifiers past it, and the input's still not among them, means a reorg dropped the transaction. */
79
82
  relayedAt?: number;
80
83
  }
81
84
 
@@ -100,6 +103,8 @@ export interface MergeStep {
100
103
  /** Refused by the prover, or reverting with no sign of it on chain: built again with the same output a few times,
101
104
  * then failed. */
102
105
  attempts: number;
106
+ /** As a JoinSplit's. */
107
+ relayedBlock?: number;
103
108
  relayedAt?: number;
104
109
  }
105
110
 
@@ -249,14 +254,20 @@ export function payWith(all: Coin[], need: bigint, fee: bigint, onTheWay: boolea
249
254
  return fail('insufficient-funds', `${need} is more than the balance`);
250
255
  }
251
256
 
252
- /** The smallest public withdrawal that still pays `net` after the protocol's cut. */
257
+ /** The smallest public withdrawal that still pays `net` after the protocol's cut. The cut is a rate of the whole
258
+ * withdrawal, rounded down, and the rate read off one withdrawal (`fee / gross`) is at most its tier's: the smallest
259
+ * gross that pays `net` at that rate is never more than the answer within the tier, so a read or two settles it.
260
+ * ponytail: where a lower tier's bound lies between two reads, that bound may already pay `net`, a little less than
261
+ * the gross this returns; a getter for the schedule's tiers would find it. */
253
262
  export async function grossed(ctx: Ctx, chainId: bigint, tokenId: number, net: bigint): Promise<bigint> {
254
263
  const chain = chainCtx(ctx, chainId).chain;
255
264
  let gross = net;
256
- for (let i = 0; i < 8; i++) {
265
+ // each read moves on by a unit at least, and a few settle each tier a withdrawal crosses
266
+ for (let i = 0; i < 64; i++) {
257
267
  const paid = gross - (await chain.withdrawFee(tokenId, gross));
258
268
  if (paid >= net) return gross;
259
- gross += net - paid;
269
+ if (paid <= 0n) break;
270
+ gross = ((net - 1n) * gross) / paid + 1n;
260
271
  }
261
272
  return fail('unavailable', 'the withdrawal fee schedule does not settle');
262
273
  }
@@ -276,10 +287,16 @@ export async function withdrawalOf(
276
287
  return { withdrawn, protocol: withdrawn - net };
277
288
  }
278
289
 
290
+ /** The rate, in basis points, of a protocol's `cut` out of a withdrawal of `gross`. The cut is rounded down, so this is
291
+ * exact once `gross` is 10,000 base units or more. */
292
+ export const bpsOf = (cut: bigint, gross: bigint): number =>
293
+ gross === 0n ? 0 : Number((cut * 10_000n + gross - 1n) / gross);
294
+
279
295
  /** The most a payment out of `all` pays its recipient, the relayer's `fee` and the protocol's cut on top
280
296
  * (`withdrawalOf`): a withdrawal of what the relayer leaves, less the cut on it, or a private payment of what the
281
297
  * relayer's grossed fee leaves. Coins one JoinSplit cannot spend together (`pick`) are merged first, each merge
282
- * paying the relayer's grossed fee, as a consolidation's quote does. */
298
+ * paying the relayer's grossed fee, as a consolidation's quote does. `fee` is all the fees take of `all`; where they
299
+ * take it all, what they would be with nothing left to pay. */
283
300
  export async function maxPayment(
284
301
  ctx: Ctx,
285
302
  chainId: bigint,
@@ -287,13 +304,16 @@ export async function maxPayment(
287
304
  isPublic: boolean,
288
305
  all: Coin[],
289
306
  fee: bigint,
290
- ): Promise<bigint> {
307
+ ): Promise<{ amount: bigint; fee: bigint; merges: number }> {
291
308
  const whole = all.length < 2 || (all.length === 2 && all.some((c) => c.kind === 'note'));
292
- const merges = whole ? 0n : BigInt(roundsToMerge(all)) * (await grossed(ctx, chainId, tokenId, fee));
293
- const total = all.reduce((s, c) => s + c.value, 0n) - merges;
294
- const left = total - (isPublic ? fee : await grossed(ctx, chainId, tokenId, fee));
295
- if (left <= 0n) return 0n;
296
- return isPublic ? left - (await chainCtx(ctx, chainId).chain.withdrawFee(tokenId, left)) : left;
309
+ const merges = whole ? 0 : roundsToMerge(all);
310
+ const gross = !isPublic || merges > 0 ? await grossed(ctx, chainId, tokenId, fee) : fee;
311
+ const fees = BigInt(merges) * gross + (isPublic ? fee : gross);
312
+ const total = all.reduce((s, c) => s + c.value, 0n);
313
+ const left = total - fees;
314
+ if (left <= 0n) return { amount: 0n, fee: fees, merges };
315
+ const amount = isPublic ? left - (await chainCtx(ctx, chainId).chain.withdrawFee(tokenId, left)) : left;
316
+ return { amount, fee: total - amount, merges };
297
317
  }
298
318
 
299
319
  /** Plans the next step of `op`: a payment's only JoinSplit (or throws why there is none), or a consolidation's next
@@ -643,12 +663,21 @@ async function inputSpent(ctx: Ctx, op: SpendOperation, step: { inputs: Coin[] }
643
663
  return hits.length > 0;
644
664
  }
645
665
 
646
- /** Where a JoinSplit relayed when the indexer had `relayedAt` nullifiers stands, by its first input `f` as sync last
647
- * saw it: spent, or the final nullifiers passed it without its own (a reorg dropped it, or the relayer never sent it),
648
- * or neither yet. */
649
- export function relayedState(f: Position | undefined, relayedAt: number): 'landed' | 'dropped' | 'waiting' {
666
+ /** Where a relayed JoinSplit stands, by its first input `f` as sync last saw it and `relayedAt`, a nullifier count past
667
+ * its own (`undefined` while not known): spent, or the final nullifiers passed that count without its own (a reorg
668
+ * dropped it, or the relayer never sent it), or neither yet. */
669
+ export function relayedState(f: Position | undefined, relayedAt: number | undefined): 'landed' | 'dropped' | 'waiting' {
650
670
  if (f?.spent) return 'landed';
651
- return f?.nullifier && f.nullifier.scannedTo >= relayedAt ? 'dropped' : 'waiting';
671
+ return f?.nullifier && relayedAt !== undefined && f.nullifier.scannedTo >= relayedAt ? 'dropped' : 'waiting';
672
+ }
673
+
674
+ /** The indexer's nullifier count once it is past `block`, so past the nullifiers of a transaction mined there unless a
675
+ * reorg moved them; `undefined` until then. The relayer answers once the block is mined, which may be before the
676
+ * indexer has read it. */
677
+ export async function nullifiersPast(indexer: Pick<Indexer, 'status'>, block: number): Promise<number | undefined> {
678
+ if ((await indexer.status()).cursor < block) return undefined;
679
+ // a status reads its counts before its cursor, so the count comes from the next one
680
+ return (await indexer.status()).nullifiers;
652
681
  }
653
682
 
654
683
  /** Whether `op`'s payment or withdrawal is on chain: done, or relayed, which the relayer answers once it is mined. Until
@@ -775,17 +804,20 @@ export async function advanceSpend(ctx: Ctx, op: SpendOperation): Promise<void>
775
804
  await ctx.save();
776
805
  return;
777
806
  }
778
- // an earlier relay of it landed, one whose answer was lost
807
+ // an earlier relay of it landed, one whose answer was lost, and the indexer counts its nullifier already
808
+ step.relayedAt = (await c.indexer.status()).nullifiers;
779
809
  } else {
780
810
  placeChange(ctx, op, step.outputs[1].salt, result.noteIndex2);
781
811
  data.txHashes = [...(data.txHashes ?? []), result.txHash];
812
+ step.relayedBlock = result.block;
813
+ delete step.relayedAt;
782
814
  }
783
- step.relayedAt = (await c.indexer.status()).nullifiers;
784
815
  step.stage = 'relayed';
785
816
  await ctx.save();
786
817
  }
787
818
  if (step.stage === 'relayed') {
788
- const now = relayedState(positionOf(ctx, op.chainId, step.inputs[0].index), step.relayedAt!);
819
+ step.relayedAt ??= await nullifiersPast(c.indexer, step.relayedBlock!);
820
+ const now = relayedState(positionOf(ctx, op.chainId, step.inputs[0].index), step.relayedAt);
789
821
  if (now === 'dropped') {
790
822
  // relay it again next sync; if it did land after all, that reverts and finds it
791
823
  step.stage = 'attested';
@@ -888,16 +920,19 @@ async function advanceMerge(ctx: Ctx, op: SpendOperation, step: MergeStep): Prom
888
920
  if ('reverted' in result) {
889
921
  // stale roots, most likely: built again with the same output, next sync
890
922
  if (!(await inputSpent(ctx, op, step))) return again(result.reverted);
891
- // an earlier relay of it landed, one whose answer was lost
923
+ // an earlier relay of it landed, one whose answer was lost, and the indexer counts its nullifiers already
924
+ step.relayedAt = (await c.indexer.status()).nullifiers;
892
925
  } else {
893
926
  placeChange(ctx, op, step.output.salt, result.noteIndex);
894
927
  data.txHashes = [...(data.txHashes ?? []), result.txHash];
928
+ step.relayedBlock = result.block;
929
+ delete step.relayedAt;
895
930
  }
896
- step.relayedAt = (await c.indexer.status()).nullifiers;
897
931
  step.stage = 'relayed';
898
932
  await ctx.save();
899
933
  }
900
- const now = relayedState(positionOf(ctx, op.chainId, step.inputs[0].index), step.relayedAt!);
934
+ step.relayedAt ??= await nullifiersPast(c.indexer, step.relayedBlock!);
935
+ const now = relayedState(positionOf(ctx, op.chainId, step.inputs[0].index), step.relayedAt);
901
936
  if (now === 'dropped') {
902
937
  // relay it again next sync; if it did land after all, that reverts and finds it
903
938
  step.stage = 'proved';
package/src/state.ts CHANGED
@@ -86,6 +86,10 @@ export interface DepositRecord {
86
86
  /** Chosen by a payer, who told it in storage (a burn), as claimed: its salt is the payer's, with no index
87
87
  * (`burnIndex` -1), and it is never handed out. */
88
88
  payer?: Hex;
89
+ /** Recorded by another device of the wallet, or before a restore: taken as handed out, so never handed out here. A
90
+ * flag of its own rather than the token's `burnNext`, which would also pass the one this device recorded ahead and
91
+ * still holds: with two devices open, each would then record one more ahead each sync, without end. */
92
+ elsewhere?: true;
89
93
  /** The transfer's tag under each token. */
90
94
  watches: { tokenId: number; watch: Watch }[];
91
95
  found: (Position & {
@@ -114,9 +118,27 @@ export interface SentRecord extends Pick<
114
118
  * `unwritten` until storage holds it: every sync tries again. */
115
119
  export type ReceiptRecord = { chainId: bigint; tokenId: number; burnAddress: Hex; atMs: number; unwritten?: true } & (
116
120
  | { kind: 'returned'; index: number; recipient: Hex; amount: bigint; txHash: Hex }
117
- | { kind: 'deposit-address'; depositAddress: Hex }
121
+ | {
122
+ kind: 'deposit-address';
123
+ depositAddress: Hex;
124
+ /** From the relayer's answer, or as storage told them. Absent where storage's record does not tell them, and then
125
+ * only the relayer flushes it. */
126
+ args?: DepositAddressArgs;
127
+ }
118
128
  );
119
129
 
130
+ /** What a deposit address was registered with besides its token and burn address: with those, all a flush of it takes.
131
+ * A wallet's refunds go where a signer of its own signs for, so its refund address is zero. */
132
+ export interface DepositAddressArgs {
133
+ fee: bigint;
134
+ feeRecipient: Hex;
135
+ refundSigner: Hex;
136
+ referrer: Hex;
137
+ referralBps: number;
138
+ }
139
+
140
+ export type DepositAddressReceipt = Extract<ReceiptRecord, { kind: 'deposit-address' }>;
141
+
120
142
  /** A sample of the indexer's final counts, to bound where a note announced at some time can be. */
121
143
  export interface Sample {
122
144
  atMs: number;
@@ -155,6 +177,8 @@ export interface DepositOperation extends OperationBase {
155
177
  burnAddress: Hex;
156
178
  /** Whose burn address it is, when not this wallet's (`deposit({ to })`). */
157
179
  to?: Hex;
180
+ /** The receive code it paid, when `to` was one. */
181
+ code?: string;
158
182
  /** Its transaction, once sent. */
159
183
  txHash?: Hex;
160
184
  };
package/src/sync.ts CHANGED
@@ -4,6 +4,7 @@
4
4
  import {
5
5
  type Deposit,
6
6
  type LaneRecord,
7
+ type DepositArgs,
7
8
  type NewRecord,
8
9
  type Note,
9
10
  type OwnReceipt,
@@ -18,11 +19,11 @@ import {
18
19
  } from '@zerc20/storage-sdk';
19
20
  import { type Hex, bytesToHex, hexToBytes, numberToHex, parseAbiItem } from 'viem';
20
21
  import type { Chain, Token } from './chain.js';
21
- import { type ChainCtx, type Ctx, chainCtx, chainState, owner } from './context.js';
22
+ import { type ChainCtx, type Ctx, chainCtx, chainState, owner, refundSigner } from './context.js';
22
23
  import { burnAddress, core } from './core.js';
23
24
  import { type Hit, type Watch, scan } from './scan.js';
24
25
  import { type Asp, type AspStatus, type Indexer, PAGE } from './servers.js';
25
- import type { DepositRecord, NoteReceipt, NoteRecord, Position, Sample } from './state.js';
26
+ import type { DepositAddressArgs, DepositRecord, NoteReceipt, NoteRecord, Position, Sample } from './state.js';
26
27
 
27
28
  /** The ledger's log of each commitment, found by its index (`contracts/src/CommitmentLedger.sol`). */
28
29
  const committedEvent = parseAbiItem('event Committed(uint64 indexed index, uint256[4] fields)');
@@ -95,7 +96,12 @@ async function writeReceipts(ctx: Ctx, tokens: Map<bigint, number[]>): Promise<v
95
96
  amount: hexToBytes(numberToHex(r.amount, { size: 32 })),
96
97
  txHash: hexToBytes(r.txHash),
97
98
  }
98
- : { ...head, kind: r.kind, depositAddress: hexToBytes(r.depositAddress) };
99
+ : {
100
+ ...head,
101
+ kind: r.kind,
102
+ depositAddress: hexToBytes(r.depositAddress),
103
+ ...(r.args && { args: depositArgsBytes(r.args) }),
104
+ };
99
105
  takeRefresh(
100
106
  ctx,
101
107
  await commit(ctx.storage, ctx.view, storageState(ctx), (s) => planReceipt(ctx.view, s, receipt)),
@@ -106,6 +112,24 @@ async function writeReceipts(ctx: Ctx, tokens: Map<bigint, number[]>): Promise<v
106
112
  }
107
113
  }
108
114
 
115
+ /** A deposit address's arguments as the wallet keeps them, from storage's. */
116
+ const depositArgs = (a: DepositArgs): DepositAddressArgs => ({
117
+ fee: big(a.fee),
118
+ feeRecipient: bytesToHex(a.feeRecipient),
119
+ refundSigner: bytesToHex(a.refundSigner),
120
+ referrer: bytesToHex(a.referrer),
121
+ referralBps: a.referralBps,
122
+ });
123
+
124
+ /** And storage's, from the wallet's. */
125
+ const depositArgsBytes = (a: DepositAddressArgs): DepositArgs => ({
126
+ fee: hexToBytes(numberToHex(a.fee, { size: 32 })),
127
+ feeRecipient: hexToBytes(a.feeRecipient),
128
+ refundSigner: hexToBytes(a.refundSigner),
129
+ referrer: hexToBytes(a.referrer),
130
+ referralBps: a.referralBps,
131
+ });
132
+
109
133
  /** A storage receipt as the wallet keeps it. */
110
134
  export const noteReceipt = (r: Receipt): NoteReceipt => ({
111
135
  kind: r.kind,
@@ -182,16 +206,24 @@ function takeSent(ctx: Ctx, d: Deposit, to: Uint8Array, tokens: Map<bigint, numb
182
206
  function takeReceipt(ctx: Ctx, r: OwnReceipt & { atNs: bigint }): void {
183
207
  const burnAddress = bytesToHex(r.burnAddress);
184
208
  const atMs = Number(r.atNs / 1_000_000n);
209
+ // a return is told apart by its deposit, a deposit address by itself: one burn address can have more than one, each
210
+ // registered with arguments of its own
185
211
  const same = ctx.state.receipts.find(
186
212
  (x) =>
187
213
  x.chainId === r.chainId &&
188
- x.kind === r.kind &&
189
214
  x.burnAddress === burnAddress &&
190
- (x.kind !== 'returned' || (r.kind === 'returned' && x.index === r.index)),
215
+ (x.kind === 'returned' && r.kind === 'returned'
216
+ ? x.index === r.index
217
+ : x.kind === 'deposit-address' &&
218
+ r.kind === 'deposit-address' &&
219
+ x.depositAddress.toLowerCase() === bytesToHex(r.depositAddress)),
191
220
  );
221
+ // what its address was registered with, where the record tells it
222
+ const args = r.kind === 'deposit-address' && r.args && { args: depositArgs(r.args) };
192
223
  if (same) {
193
224
  delete same.unwritten;
194
225
  same.atMs = atMs;
226
+ Object.assign(same, args);
195
227
  return;
196
228
  }
197
229
  const head = { chainId: r.chainId, tokenId: r.tokenId, burnAddress, atMs };
@@ -205,7 +237,7 @@ function takeReceipt(ctx: Ctx, r: OwnReceipt & { atNs: bigint }): void {
205
237
  amount: big(r.amount),
206
238
  txHash: bytesToHex(r.txHash),
207
239
  }
208
- : { ...head, kind: r.kind, depositAddress: bytesToHex(r.depositAddress) },
240
+ : { ...head, kind: r.kind, depositAddress: bytesToHex(r.depositAddress), ...args },
209
241
  );
210
242
  }
211
243
 
@@ -255,10 +287,6 @@ function takeBurnBlock(
255
287
  const salt = hex32(bytes);
256
288
  const address = core.burnAddressOf(b.chainId, publicKey, salt) as Hex | undefined;
257
289
  if (!address || ctx.state.deposits.some((d) => d.chainId === b.chainId && d.salt === salt)) return;
258
- if (!pos) {
259
- const next = chainState(ctx, b.chainId).burnNext;
260
- next[b.tokenId] = Math.max(next[b.tokenId] ?? 0, b.first + i + 1);
261
- }
262
290
  const watch = { key: numberToHex(core.transferTag(b.tokenId, address), { size: 4 }), from, scannedTo: from };
263
291
  const record: DepositRecord = {
264
292
  chainId: b.chainId,
@@ -272,6 +300,7 @@ function takeBurnBlock(
272
300
  found: [],
273
301
  };
274
302
  if (pos) record.managed = true;
303
+ else record.elsewhere = true;
275
304
  ctx.state.deposits.push(record);
276
305
  });
277
306
  }
@@ -281,7 +310,8 @@ export function unusedBurn(ctx: Ctx, chainId: bigint, tokenId: number): DepositR
281
310
  const next = chainState(ctx, chainId).burnNext[tokenId] ?? 0;
282
311
  let best: DepositRecord | undefined;
283
312
  for (const d of ctx.state.deposits) {
284
- if (d.chainId !== chainId || d.tokenId !== tokenId || d.managed || d.payer || d.burnIndex < next) continue;
313
+ if (d.chainId !== chainId || d.tokenId !== tokenId || d.managed || d.payer || d.elsewhere || d.burnIndex < next)
314
+ continue;
285
315
  if (!best || d.burnIndex < best.burnIndex) best = d;
286
316
  }
287
317
  return best;
@@ -302,17 +332,18 @@ export const nextBurn = (ctx: Ctx, chainId: bigint, tokenId: number): number =>
302
332
  unusedBurn(ctx, chainId, tokenId)?.burnIndex ?? toRecord(ctx, chainId, tokenId);
303
333
 
304
334
  /** Records this wallet's next burn address of a token in storage (a burn block of one), then watches it. Every device
305
- * of the wallet, and a restore, then watches it too and never hands it out again. About 2^16 hashes; needs no seed. */
335
+ * of the wallet, and a restore, then watches it too and never hands it out again. About 2^16 hashes, `urgent` unless
336
+ * it is one made ahead; needs no seed. */
306
337
  export async function recordBurn(
307
338
  ctx: Ctx,
308
339
  chainId: bigint,
309
340
  tokenId: number,
310
341
  tokens: Map<bigint, number[]>,
311
- signal?: AbortSignal,
342
+ opts: { signal?: AbortSignal; urgent?: boolean } = {},
312
343
  ): Promise<DepositRecord> {
313
344
  const index = toRecord(ctx, chainId, tokenId);
314
345
  const key = burnKey(ctx.view.seed);
315
- const { salt, address } = await burnAddress(key, chainId, tokenId, index, owner(ctx)!.publicKey, signal);
346
+ const { salt, address } = await burnAddress(key, chainId, tokenId, index, owner(ctx)!.publicKey, opts);
316
347
  const { commitmentsFinal: from } = await chainCtx(ctx, chainId).indexer.status();
317
348
  const block = { chainId, tokenId, first: index, salts: [hexToBytes(salt)] };
318
349
  const result = await commit(ctx.storage, ctx.view, storageState(ctx), (s) => planBurnBlock(ctx.view, s, block));
@@ -350,7 +381,7 @@ export async function topUpBurns(ctx: Ctx, signal?: AbortSignal): Promise<void>
350
381
  let tokens: Map<bigint, number[]> | undefined;
351
382
  for (const { chainId, tokenId } of used.values()) {
352
383
  if (!unusedBurn(ctx, chainId, tokenId))
353
- await recordBurn(ctx, chainId, tokenId, (tokens ??= await tokenIds(ctx)), signal);
384
+ await recordBurn(ctx, chainId, tokenId, (tokens ??= await tokenIds(ctx)), { signal, urgent: false });
354
385
  }
355
386
  }
356
387
 
@@ -394,12 +425,13 @@ function upsertNote(ctx: Ctx, a: NewRecord, note: Note, tokens: Map<bigint, numb
394
425
  });
395
426
  }
396
427
 
397
- /** Receive codes of each token whose keys are derived while the seed is held, to hand out while it is not. */
428
+ /** Receive codes of each token whose keys are derived while the seed is held, to hand out while it is not, and as many
429
+ * deposit addresses' refund signers. */
398
430
  export const CODES_AHEAD = 4;
399
431
 
400
432
  /** 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. */
433
+ * the next `CODES_AHEAD` codes' of each of `tokens` with their deposit addresses' refund signers, and each note's key's;
434
+ * and the watch of each note that waited for its key. Without it, the watch of each note whose key was derived before. */
403
435
  export function keyAll(ctx: Ctx, tokens: Token[]): void {
404
436
  owner(ctx);
405
437
  // ponytail: one derivation per own burn address as the wallet opens; keep owners in the saved state if wallets grow
@@ -409,14 +441,19 @@ export function keyAll(ctx: Ctx, tokens: Token[]): void {
409
441
  }
410
442
  for (const { chainId, tokenId } of tokens) {
411
443
  const next = nextBurn(ctx, chainId, tokenId);
412
- for (let index = next; index < next + CODES_AHEAD; index++) owner(ctx, { chainId, tokenId, index });
444
+ for (let index = next; index < next + CODES_AHEAD; index++) {
445
+ owner(ctx, { chainId, tokenId, index });
446
+ refundSigner(ctx, { chainId, tokenId, index });
447
+ }
413
448
  }
414
449
  for (const n of ctx.state.notes) {
415
450
  if (n.keyIndex === undefined) continue;
416
451
  owner(ctx, { chainId: n.chainId, tokenId: n.tokenId, index: n.keyIndex });
417
452
  if (!n.unkeyed) continue;
418
453
  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));
454
+ const watched = watchFor(ctx, n.chainId, n.tokenId, n.value, n.salt, n.keyIndex, from);
455
+ if (!watched) continue; // still waits for the seed
456
+ Object.assign(n, watched);
420
457
  delete n.unkeyed;
421
458
  }
422
459
  }
package/src/viewKey.ts ADDED
@@ -0,0 +1,49 @@
1
+ // A view key as text: what `Wallet.watch` follows an account with, spending nothing.
2
+
3
+ import { bech32m } from '@scure/base';
4
+ import type { ViewKey } from '@zerc20/storage-sdk';
5
+ import { type Address, type Hex, bytesToHex, concat, getAddress, hexToBytes } from 'viem';
6
+ import type { Owner } from './context.js';
7
+ import { core } from './core.js';
8
+
9
+ const PREFIX = 'zerc20view';
10
+
11
+ /** `view` and the wallet's own `owner` as checksummed text (bech32m, `zerc20view1…`): `address(20) ‖ view seed(32) ‖
12
+ * auth key hash(32) ‖ nullifying key(32)`. The public key is left for the holder to make from the last two, so that a
13
+ * nullifying key that is not the account's makes a public key that is not either. */
14
+ export function encodeViewKey(view: ViewKey, owner: Owner): string {
15
+ const bytes = concat([view.address, view.seed, hexToBytes(owner.authKeyHash), hexToBytes(owner.nullifyingKey)]);
16
+ return bech32m.encode(PREFIX, bech32m.toWords(bytes), false);
17
+ }
18
+
19
+ function bytesOf(text: string): Uint8Array | null {
20
+ try {
21
+ const { prefix, bytes } = bech32m.decodeToBytes(text.trim(), false);
22
+ return prefix === PREFIX && bytes.length === 116 ? bytes : null;
23
+ } catch {
24
+ return null;
25
+ }
26
+ }
27
+
28
+ /** The account `text` is a view key of, or `null` if it is not one: to tell a key apart before it is opened. */
29
+ export function viewKeyAccount(text: string): Address | null {
30
+ const bytes = bytesOf(text);
31
+ return bytes && getAddress(bytesToHex(bytes.subarray(0, 20)));
32
+ }
33
+
34
+ /** The view key and owner `text` holds, or `null` for anything else. Needs `initCore`. */
35
+ export function decodeViewKey(text: string): { view: ViewKey; owner: Owner } | null {
36
+ const bytes = bytesOf(text);
37
+ if (!bytes) return null;
38
+ const authKeyHash = bytesToHex(bytes.subarray(52, 84));
39
+ const nullifyingKey = bytesToHex(bytes.subarray(84));
40
+ try {
41
+ const publicKey = core.publicKey(authKeyHash, nullifyingKey) as Hex;
42
+ return {
43
+ view: { address: bytes.slice(0, 20), seed: bytes.slice(20, 52) },
44
+ owner: { authKeyHash, publicKey, nullifyingKey },
45
+ };
46
+ } catch {
47
+ return null; // a value that is not a field element
48
+ }
49
+ }