@zerc20/wallet-sdk 0.4.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 (60) 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 +8 -2
  9. package/dist/context.js +15 -2
  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/index.d.ts +4 -2
  15. package/dist/index.js +4 -2
  16. package/dist/index.js.map +1 -1
  17. package/dist/pos.d.ts +7 -1
  18. package/dist/pos.js +213 -55
  19. package/dist/pos.js.map +1 -1
  20. package/dist/rageExit.d.ts +27 -4
  21. package/dist/rageExit.js +160 -49
  22. package/dist/rageExit.js.map +1 -1
  23. package/dist/seed.d.ts +4 -0
  24. package/dist/seed.js +13 -1
  25. package/dist/seed.js.map +1 -1
  26. package/dist/servers.d.ts +2 -0
  27. package/dist/servers.js +1 -0
  28. package/dist/servers.js.map +1 -1
  29. package/dist/services.d.ts +11 -4
  30. package/dist/services.js +26 -7
  31. package/dist/services.js.map +1 -1
  32. package/dist/spend.d.ts +30 -9
  33. package/dist/spend.js +46 -17
  34. package/dist/spend.js.map +1 -1
  35. package/dist/state.d.ts +21 -0
  36. package/dist/state.js.map +1 -1
  37. package/dist/sync.d.ts +10 -5
  38. package/dist/sync.js +49 -18
  39. package/dist/sync.js.map +1 -1
  40. package/dist/wallet.d.ts +92 -14
  41. package/dist/wallet.js +191 -56
  42. package/dist/wallet.js.map +1 -1
  43. package/package.json +2 -2
  44. package/src/accountId.ts +22 -0
  45. package/src/chain.ts +95 -49
  46. package/src/context.ts +27 -3
  47. package/src/core.ts +109 -24
  48. package/src/index.ts +15 -2
  49. package/src/pos.ts +240 -61
  50. package/src/rageExit.ts +237 -61
  51. package/src/seed.ts +24 -1
  52. package/src/servers.ts +4 -1
  53. package/src/services.ts +28 -15
  54. package/src/spend.ts +58 -23
  55. package/src/state.ts +25 -1
  56. package/src/sync.ts +54 -19
  57. package/src/wallet.ts +246 -72
  58. package/wasm/zerc20_wallet_core.d.ts +1 -1
  59. package/wasm/zerc20_wallet_core.js +1 -1
  60. package/wasm/zerc20_wallet_core_bg.wasm +0 -0
package/src/pos.ts CHANGED
@@ -3,7 +3,7 @@
3
3
  // every wallet downloads. Stolen, it gives away which addresses are the merchant's, and nothing else: the merchant's
4
4
  // wallet alone spends what they receive, merges it and withdraws it.
5
5
 
6
- import { BURN_BLOCK, CanisterClient, lookupRecipient, writePosBlock } from '@zerc20/storage-sdk';
6
+ import { BURN_BLOCK, CanisterClient, MAX_POS_BLOCKS, lookupRecipient, writePosBlocks } from '@zerc20/storage-sdk';
7
7
  import {
8
8
  type Address,
9
9
  type Hex,
@@ -16,11 +16,11 @@ import {
16
16
  zeroAddress,
17
17
  } from 'viem';
18
18
  import { Chain, type Token } from './chain.js';
19
- import { burnAddress, core, initCore } from './core.js';
20
- import { fail } from './errors.js';
19
+ import { type Burn, burnAddress, core, initCore } from './core.js';
20
+ import { WalletError, fail } from './errors.js';
21
21
  import { type ChainConfig, type Network, checkNetwork } from './network.js';
22
22
  import { scan } from './scan.js';
23
- import { Asp, Indexer } from './servers.js';
23
+ import { Asp, Indexer, type IndexerStatus } from './servers.js';
24
24
  import { registerDeposit } from './services.js';
25
25
  import type { DepositRecord } from './state.js';
26
26
  import { applyHits, commitmentFields, commitmentTx, dropReorged, readDecisions, readTransfers } from './sync.js';
@@ -28,6 +28,8 @@ import { applyHits, commitmentFields, commitmentTx, dropReorged, readDecisions,
28
28
  /** The longest a refill waits once a token's addresses run low: a write at a random time after the sale that drew
29
29
  * them down, not right after it, so that the write's time does not mark that sale. */
30
30
  const REFILL_WITHIN_MS = 10 * 60_000;
31
+ /** How long what a pass over the chains read may go unsaved: lost, it is read from the chains again. */
32
+ const PASS_SAVED_WITHIN_MS = 60_000;
31
33
 
32
34
  /** What a point of sale needs of a token: where it is, and its id for the burn addresses' derivation and tags. */
33
35
  export type PosToken = Pick<Token, 'chainId' | 'tokenId' | 'address'>;
@@ -74,6 +76,16 @@ export interface SaleStatus {
74
76
 
75
77
  type Watched = Pick<DepositRecord, 'address' | 'watches' | 'found'>;
76
78
 
79
+ /** A token's addresses: made and recorded ahead of its sales. */
80
+ interface Pool {
81
+ /** The next counter to derive. */
82
+ next: number;
83
+ /** Addresses made and not recorded yet, a block of counters `first..` each. */
84
+ made: { first: number; burns: Burn[] }[];
85
+ /** Addresses recorded in the lane that no sale has had. */
86
+ ready: Address[];
87
+ }
88
+
77
89
  interface Connection {
78
90
  merchant: Address;
79
91
  /** The zERC20 public key the merchant registered in storage, which its burn addresses are under. */
@@ -81,8 +93,8 @@ interface Connection {
81
93
  laneKey: Hex;
82
94
  /** Per chain, the lane's first slot not known to be taken. */
83
95
  slots: Record<string, number>;
84
- /** Per `chainId:tokenId`, the next counter to derive, and the addresses recorded in the lane that no sale has had. */
85
- pools: Record<string, { next: number; ready: Address[] }>;
96
+ /** Per `chainId:tokenId`. */
97
+ pools: Record<string, Pool>;
86
98
  }
87
99
 
88
100
  interface Saved {
@@ -103,7 +115,29 @@ const decode = (b: Uint8Array): Saved =>
103
115
  ) as Saved;
104
116
 
105
117
  const same = (a: string, b: string) => a.toLowerCase() === b.toLowerCase();
106
- const pairOf = (token: PosToken) => `${token.chainId}:${token.tokenId}`;
118
+ const pairOf = (token: Pick<PosToken, 'chainId' | 'tokenId'>) => `${token.chainId}:${token.tokenId}`;
119
+ const madeOf = (pool: Pool) => pool.made.length * BURN_BLOCK;
120
+
121
+ /** A chain's indexer as one pass reads it: its status, read once for every point of sale of the process that asks while
122
+ * it is read, and the tag pages they read after it, each once. The points of sale of a server run their passes side by
123
+ * side, so the indexer serves each page once a pass, not once a merchant; and a page read after the status is never
124
+ * older than it, which the scan needs. */
125
+ interface Pass {
126
+ status: IndexerStatus;
127
+ tags: Map<number, Promise<Uint8Array>>;
128
+ }
129
+ const passes = new Map<string, Promise<Pass>>();
130
+
131
+ function passOf(indexer: Indexer): Promise<Pass> {
132
+ let pass = passes.get(indexer.url);
133
+ if (!pass) {
134
+ const read = indexer.status().then((status) => ({ status, tags: new Map() }));
135
+ const done = () => void (passes.get(indexer.url) === read && passes.delete(indexer.url));
136
+ read.then(done, done);
137
+ passes.set(indexer.url, (pass = read));
138
+ }
139
+ return pass;
140
+ }
107
141
 
108
142
  export class PointOfSale {
109
143
  readonly #network: Network;
@@ -111,12 +145,23 @@ export class PointOfSale {
111
145
  readonly #persist: { save(bytes: Uint8Array): Promise<void> };
112
146
  readonly #refillWithinMs: number;
113
147
  readonly #chains = new Map<bigint, Chain>();
114
- /** The refill running for each token, and the one waiting to. */
115
- readonly #fills = new Map<string, Promise<void>>();
148
+ /** Sales by lowercase id. */
149
+ readonly #byId = new Map<string, Sale>();
150
+ /** The block each token is making ahead: one at a time, beside those made for a sale that waits. */
151
+ readonly #making = new Map<string, Promise<void>>();
152
+ /** The write each token has under way: one at a time. */
153
+ readonly #recording = new Map<string, Promise<void>>();
154
+ /** The write each token waits to do, at a random time. */
116
155
  readonly #timers = new Map<string, ReturnType<typeof setTimeout>>();
156
+ /** How many sales of each token wait for an address. */
157
+ readonly #waiting = new Map<string, number>();
158
+ /** The searches of the lane connected now: another lane, or closing, stops them. */
159
+ #searches = new AbortController();
117
160
  #storage: Promise<CanisterClient> | undefined;
118
161
  #queue: Promise<unknown> = Promise.resolve();
119
162
  #saving: Promise<unknown> = Promise.resolve();
163
+ #passSave: ReturnType<typeof setTimeout> | undefined;
164
+ #closed = false;
120
165
 
121
166
  private constructor(
122
167
  network: Network,
@@ -128,10 +173,12 @@ export class PointOfSale {
128
173
  this.#state = state;
129
174
  this.#persist = persist;
130
175
  this.#refillWithinMs = refillWithinMs;
176
+ for (const s of state.sales) this.#byId.set(s.id.toLowerCase(), s);
131
177
  }
132
178
 
133
179
  /** Opens the point of sale's saved state. It keeps which addresses it handed out: lose that, and it may hand one out
134
- * twice. One state, one point of sale: two open on it would hand out the same addresses. */
180
+ * twice. One state, one point of sale: two open on it would hand out the same addresses, so `close` the one before
181
+ * opening it again. */
135
182
  static async open(opts: {
136
183
  network: Network;
137
184
  persist: { load(): Promise<Uint8Array | null | undefined>; save(bytes: Uint8Array): Promise<void> };
@@ -141,6 +188,8 @@ export class PointOfSale {
141
188
  await initCore();
142
189
  const saved = await opts.persist.load();
143
190
  const state: Saved = saved?.length ? decode(saved) : { connection: null, sales: [], seen: {}, txs: {} };
191
+ // a state saved before addresses were made ahead
192
+ for (const pool of Object.values(state.connection?.pools ?? {})) pool.made ??= [];
144
193
  return new PointOfSale(opts.network, state, opts.persist, opts.refillWithinMs ?? REFILL_WITHIN_MS);
145
194
  }
146
195
 
@@ -159,9 +208,7 @@ export class PointOfSale {
159
208
  if (c && same(c.merchant, merchant) && same(c.laneKey, laneKey)) return;
160
209
  const recipient = await lookupRecipient(await this.#client(), hexToBytes(merchant));
161
210
  if (!recipient) fail('invalid', `${merchant} has not registered for private payments: open its wallet once`);
162
- for (const t of this.#timers.values()) clearTimeout(t);
163
- this.#timers.clear();
164
- this.#fills.clear();
211
+ this.#stop();
165
212
  this.#state.connection = {
166
213
  merchant: getAddress(merchant),
167
214
  publicKey: bytesToHex(recipient!.publicKey),
@@ -173,11 +220,20 @@ export class PointOfSale {
173
220
  });
174
221
  }
175
222
 
223
+ /** Stops making and recording, then saves the state as it is: nothing is saved after, so the state may be opened
224
+ * again. A sale under way then fails rather than hand out an address it could not save. */
225
+ async close(): Promise<void> {
226
+ this.#stop();
227
+ const last = this.#save();
228
+ this.#closed = true;
229
+ await last;
230
+ }
231
+
176
232
  /** Readies addresses of `token` ahead of its sales: an app calls it for each token it takes, as it starts and after
177
233
  * sales. Nothing happens while enough are ready, or a refill is under way or due. */
178
234
  prepare(token: PosToken): void {
179
235
  chainOf(this.#network, token.chainId);
180
- this.#topUp(token);
236
+ this.#supply(token);
181
237
  }
182
238
 
183
239
  /** How many addresses of `token` are recorded and wait for a sale. */
@@ -191,9 +247,19 @@ export class PointOfSale {
191
247
  const { token } = opts;
192
248
  const c = chainOf(this.#network, token.chainId);
193
249
  this.#connection();
194
- const { commitmentsFinal: from } = await new Indexer(c.indexer).status();
250
+ const pair = pairOf(token);
251
+ // counted from the start, so that the sales that come at once have their addresses made at once
252
+ this.#waiting.set(pair, (this.#waiting.get(pair) ?? 0) + 1);
195
253
  let burnAddress: Address | undefined;
196
- while (!(burnAddress = this.#connection().pools[pairOf(token)]?.ready.shift())) await this.#fill(token);
254
+ let from: number;
255
+ try {
256
+ ({ commitmentsFinal: from } = await new Indexer(c.indexer).status());
257
+ while (!(burnAddress = this.#pool(token).ready.shift())) await this.#record(token);
258
+ } finally {
259
+ const left = this.#waiting.get(pair)! - 1;
260
+ if (left) this.#waiting.set(pair, left);
261
+ else this.#waiting.delete(pair);
262
+ }
197
263
  // saved as taken before the deposit relayer is asked: a sale that fails half-way never hands it out again
198
264
  const sale: Sale = {
199
265
  id: burnAddress,
@@ -209,9 +275,10 @@ export class PointOfSale {
209
275
  };
210
276
  const watch = { key: tag(token.tokenId, burnAddress), from, scannedTo: from };
211
277
  this.#state.sales.push(sale);
278
+ this.#byId.set(burnAddress.toLowerCase(), sale);
212
279
  this.#state.seen[burnAddress] = { address: burnAddress, watches: [{ tokenId: token.tokenId, watch }], found: [] };
213
280
  await this.#save();
214
- this.#topUp(token);
281
+ this.#supply(token);
215
282
  return opts.via === 'deposit' ? this.#exclusive(() => this.#register(sale, opts.refundTo)) : sale;
216
283
  }
217
284
 
@@ -231,7 +298,9 @@ export class PointOfSale {
231
298
  if (same(sale.refundTo!, to)) return sale;
232
299
  return fail('invalid', `sale ${sale.id} already has a deposit address, refunding to ${sale.refundTo}`);
233
300
  }
234
- const { depositAddress, fee } = await registerDeposit(this.#chain(sale.chainId), sale.token, sale.burnAddress, to);
301
+ const { depositAddress, fee } = await registerDeposit(this.#chain(sale.chainId), sale.token, sale.burnAddress, {
302
+ address: to,
303
+ });
235
304
  Object.assign(sale, { depositAddress, depositFee: fee, refundTo: to });
236
305
  await this.#save();
237
306
  return sale;
@@ -241,6 +310,11 @@ export class PointOfSale {
241
310
  return [...this.#state.sales];
242
311
  }
243
312
 
313
+ /** Sale `id`. */
314
+ sale(id: Address): Sale {
315
+ return this.#sale(id);
316
+ }
317
+
244
318
  /** What reached each of sales `ids`: every transfer to its burn address, with where each stands. One pass per chain
245
319
  * for them all, reading only what every wallet downloads, so no server learns which addresses it asks about. A chain
246
320
  * that fails keeps what was read before, and its error is returned. */
@@ -257,7 +331,7 @@ export class PointOfSale {
257
331
  ).catch((e: unknown) => void errors.push(e)),
258
332
  ),
259
333
  );
260
- await this.#save();
334
+ this.#saveSoon();
261
335
  return { sales: sales.map((s) => this.#statusOf(s)), errors };
262
336
  });
263
337
  }
@@ -278,9 +352,14 @@ export class PointOfSale {
278
352
  const c = chainOf(this.#network, chainId);
279
353
  const indexer = new Indexer(c.indexer);
280
354
  const seen = sales.map((s) => this.#state.seen[s.id]);
281
- const status = await indexer.status();
355
+ const { status, tags } = await passOf(indexer);
356
+ const page = (p: number) => {
357
+ let read = tags.get(p);
358
+ if (!read) tags.set(p, (read = indexer.page('tags', p)));
359
+ return read;
360
+ };
282
361
  const watches = seen.flatMap((r) => r.watches.map((w) => w.watch));
283
- const hits = await scan(watches, 4, (p) => indexer.page('tags', p), status.commitmentsFinal, status.commitments);
362
+ const hits = await scan(watches, 4, page, status.commitmentsFinal, status.commitments);
284
363
  applyHits([], seen, hits);
285
364
  dropReorged(seen, status.commitmentsFinal);
286
365
  await readTransfers(commitmentFields(indexer), seen);
@@ -314,67 +393,156 @@ export class PointOfSale {
314
393
  return { sale, payments, received };
315
394
  }
316
395
 
317
- /** Refills `token`'s addresses once they run low: right away when none is left, otherwise at a random time within
318
- * `refillWithinMs`. */
319
- #topUp(token: PosToken): void {
396
+ /** How many addresses of a token to keep made, and recorded, ahead: twice its sales of the last `refillWithinMs`, so
397
+ * that they last through the wait for a refill as sales pick up; in whole blocks, at least one, at most one write. */
398
+ #low(pair: string): number {
399
+ const sales = this.#state.sales;
400
+ const since = Date.now() - this.#refillWithinMs;
401
+ let recent = 0;
402
+ // sales are kept in the order they were made
403
+ for (let i = sales.length - 1; i >= 0 && sales[i].createdMs >= since; i--) if (pairOf(sales[i]) === pair) recent++;
404
+ return BURN_BLOCK * Math.min(MAX_POS_BLOCKS, Math.max(1, Math.ceil((2 * recent) / BURN_BLOCK)));
405
+ }
406
+
407
+ /** Keeps `token` supplied. Its addresses are recorded once fewer than `#low` are ready: right away when none is,
408
+ * otherwise at a random time within `refillWithinMs`, so that the time of a write does not mark the sale that drew
409
+ * them down. They are made ahead, which shows nobody anything, until `#low` are made. */
410
+ #supply(token: PosToken): void {
411
+ if (!this.#state.connection || this.#closed) return;
320
412
  const pair = pairOf(token);
321
- const left = this.#state.connection?.pools[pair]?.ready.length ?? 0;
322
- if (!this.#state.connection || left >= BURN_BLOCK || this.#fills.has(pair) || this.#timers.has(pair)) return;
323
- // a refill that fails is tried again by the next sale, which tells why
324
- const fill = () => void this.#fill(token).catch(() => undefined);
325
- if (left === 0) return fill();
326
- const timer = setTimeout(() => {
327
- this.#timers.delete(pair);
328
- fill();
329
- }, Math.random() * this.#refillWithinMs);
330
- (timer as { unref?: () => void }).unref?.(); // a process need not stay up for it
331
- this.#timers.set(pair, timer);
413
+ const pool = this.#pool(token);
414
+ const low = this.#low(pair);
415
+ if (pool.ready.length < low && !this.#recording.has(pair) && !this.#timers.has(pair)) {
416
+ // a write that fails is tried again by the next sale, which tells why
417
+ if (pool.ready.length === 0) void this.#record(token).catch(() => undefined);
418
+ else this.#later(token);
419
+ }
420
+ if (madeOf(pool) < low) void this.#ahead(token).catch(() => undefined);
332
421
  }
333
422
 
334
- /** Makes `BURN_BLOCK` more addresses of `token`, records them in the lane, then adds them to its pool: one refill at
335
- * a time per token. One that fails changes nothing. */
336
- #fill(token: PosToken): Promise<void> {
423
+ /** The block `token` is making ahead, begun if there is none; the next follows while fewer than `#low` are made. */
424
+ #ahead(token: PosToken): Promise<void> {
337
425
  const pair = pairOf(token);
338
- const running = this.#fills.get(pair);
426
+ const running = this.#making.get(pair);
427
+ if (running) return running;
428
+ const run: Promise<void> = this.#make(token, false).finally(() => {
429
+ if (this.#making.get(pair) === run) this.#making.delete(pair);
430
+ });
431
+ this.#making.set(pair, run);
432
+ // one that fails is made again by the next sale, or the next pass's `prepare`
433
+ run.then(
434
+ () => this.#supply(token),
435
+ () => undefined,
436
+ );
437
+ return run;
438
+ }
439
+
440
+ /** Makes `token`'s next block of addresses. Its counters are taken as it starts, so that blocks made side by side
441
+ * never share one; a block that fails, or ends after the lane changed, leaves a gap in the counters, which no reader
442
+ * minds. What is made is saved with the next save: lost, it is made again from the same counters, to the same
443
+ * addresses, none of which a sale has had. */
444
+ async #make(token: PosToken, urgent: boolean): Promise<void> {
445
+ const c = this.#connection();
446
+ const pool = this.#pool(token);
447
+ const first = pool.next;
448
+ pool.next += BURN_BLOCK;
449
+ const laneKey = hexToBytes(c.laneKey);
450
+ const signal = this.#searches.signal;
451
+ const burns = await Promise.all(
452
+ Array.from({ length: BURN_BLOCK }, (_, i) =>
453
+ burnAddress(laneKey, token.chainId, token.tokenId, first + i, c.publicKey, { signal, urgent }),
454
+ ),
455
+ );
456
+ if (this.#state.connection !== c) return;
457
+ pool.made.push({ first, burns });
458
+ this.#saveSoon();
459
+ }
460
+
461
+ /** Records made addresses of `token` in the lane, in one write, and readies them: as many blocks as the sales that
462
+ * wait and the low mark ask for. One write at a time per token: a sale that finds none ready waits for the one under
463
+ * way. A pool with none ready has the blocks its waiting sales need, one at least, made side by side and ahead of
464
+ * every search made ahead, since the next sale waits for them. A timer that finds none made waits again: writing
465
+ * right after the making would mark the sale that began it. */
466
+ #record(token: PosToken): Promise<void> {
467
+ const pair = pairOf(token);
468
+ const running = this.#recording.get(pair);
339
469
  if (running) return running;
340
470
  clearTimeout(this.#timers.get(pair));
341
471
  this.#timers.delete(pair);
342
472
  const run: Promise<void> = (async () => {
343
473
  const c = this.#connection();
344
- const pool = (c.pools[pair] ??= { next: 0, ready: [] });
345
- const k = pool.next;
346
- const laneKey = hexToBytes(c.laneKey);
347
- const burns = await Promise.all(
348
- Array.from({ length: BURN_BLOCK }, (_, i) =>
349
- burnAddress(laneKey, token.chainId, token.tokenId, k + i, c.publicKey),
350
- ),
351
- );
352
- const block = {
353
- chainId: token.chainId,
354
- tokenId: token.tokenId,
355
- first: k,
356
- salts: burns.map((b) => hexToBytes(b.salt)),
357
- };
474
+ const pool = this.#pool(token);
475
+ const waiting = Math.ceil((this.#waiting.get(pair) ?? 0) / BURN_BLOCK);
476
+ if (pool.ready.length === 0) {
477
+ const short = Math.max(waiting, 1) - pool.made.length;
478
+ await Promise.all(Array.from({ length: Math.max(0, short) }, () => this.#make(token, true)));
479
+ } else if (pool.made.length === 0) return this.#later(token);
480
+ if (this.#state.connection !== c) return;
481
+ const short = Math.ceil((this.#low(pair) - pool.ready.length) / BURN_BLOCK);
482
+ const blocks = pool.made.slice(0, Math.min(MAX_POS_BLOCKS, Math.max(waiting, short, 1)));
483
+ if (!blocks.length) return;
358
484
  const chain = `${token.chainId}`;
359
- const slot = await writePosBlock(await this.#client(), laneKey, block, c.slots[chain] ?? 0);
360
- if (this.#state.connection !== c) return; // connected to another lane meanwhile
485
+ const slot = await writePosBlocks(
486
+ await this.#client(),
487
+ hexToBytes(c.laneKey),
488
+ blocks.map((b) => ({
489
+ chainId: token.chainId,
490
+ tokenId: token.tokenId,
491
+ first: b.first,
492
+ salts: b.burns.map((burn) => hexToBytes(burn.salt)),
493
+ })),
494
+ c.slots[chain] ?? 0,
495
+ );
496
+ if (this.#state.connection !== c) return;
361
497
  c.slots[chain] = Math.max(c.slots[chain] ?? 0, slot);
362
- pool.ready.push(...burns.map((b) => b.address));
363
- pool.next = k + BURN_BLOCK;
498
+ // what is made is only ever added to, after these
499
+ pool.made.splice(0, blocks.length);
500
+ pool.ready.push(...blocks.flatMap((b) => b.burns.map((burn) => burn.address)));
364
501
  await this.#save();
365
502
  })().finally(() => {
366
- if (this.#fills.get(pair) === run) this.#fills.delete(pair);
503
+ if (this.#recording.get(pair) === run) this.#recording.delete(pair);
367
504
  });
368
- this.#fills.set(pair, run);
505
+ this.#recording.set(pair, run);
506
+ // what was made ahead is recorded now: make the next, and record again if still short
507
+ run.then(
508
+ () => this.#supply(token),
509
+ () => undefined,
510
+ );
369
511
  return run;
370
512
  }
371
513
 
514
+ /** Records `token`'s addresses at a random time within `refillWithinMs`. */
515
+ #later(token: PosToken): void {
516
+ const pair = pairOf(token);
517
+ const timer = setTimeout(() => {
518
+ this.#timers.delete(pair);
519
+ void this.#record(token).catch(() => undefined);
520
+ }, Math.random() * this.#refillWithinMs);
521
+ (timer as { unref?: () => void }).unref?.(); // a process need not stay up for it
522
+ this.#timers.set(pair, timer);
523
+ }
524
+
525
+ /** Stops the searches, timers and writes of the lane connected now. Those under way finish, and drop what they
526
+ * bring, since the lane is no longer the one connected or the point of sale is closed. */
527
+ #stop(): void {
528
+ this.#searches.abort();
529
+ this.#searches = new AbortController();
530
+ for (const t of this.#timers.values()) clearTimeout(t);
531
+ this.#timers.clear();
532
+ this.#making.clear();
533
+ this.#recording.clear();
534
+ }
535
+
372
536
  #connection(): Connection {
373
537
  return this.#state.connection ?? fail('invalid', "not connected: connect the merchant's wallet first");
374
538
  }
375
539
 
540
+ #pool(token: PosToken): Pool {
541
+ return (this.#connection().pools[pairOf(token)] ??= { next: 0, made: [], ready: [] });
542
+ }
543
+
376
544
  #sale(id: Address): Sale {
377
- return this.#state.sales.find((s) => same(s.id, id)) ?? fail('invalid', `no sale ${id}`);
545
+ return this.#byId.get(id.toLowerCase()) ?? fail('invalid', `no sale ${id}`);
378
546
  }
379
547
 
380
548
  #chain(chainId: bigint): Chain {
@@ -400,12 +568,23 @@ export class PointOfSale {
400
568
  }
401
569
 
402
570
  /** Saves the state as it is when the save runs, after every save asked for before: an older state never lands last,
403
- * which would hand out again an address taken since. */
571
+ * which would hand out again an address taken since. Closed, it saves nothing and fails. */
404
572
  #save(): Promise<void> {
573
+ if (this.#closed) return Promise.reject(new WalletError('unavailable', 'the point of sale is closed'));
574
+ clearTimeout(this.#passSave);
575
+ this.#passSave = undefined;
405
576
  const run = this.#saving.then(() => this.#persist.save(encode(this.#state)));
406
577
  this.#saving = run.catch(() => undefined);
407
578
  return run;
408
579
  }
580
+
581
+ /** Saves within `PASS_SAVED_WITHIN_MS`, for what can be read or made again: a pass's findings, addresses made. */
582
+ // ponytail: the whole state on every save; split it once a merchant has some 10k sales
583
+ #saveSoon(): void {
584
+ if (this.#passSave || this.#closed) return;
585
+ this.#passSave = setTimeout(() => void this.#save().catch(() => undefined), PASS_SAVED_WITHIN_MS);
586
+ (this.#passSave as { unref?: () => void }).unref?.(); // a process need not stay up for it
587
+ }
409
588
  }
410
589
 
411
590
  function chainOf(network: Network, chainId: bigint): ChainConfig {