@zerc20/wallet-sdk 0.1.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.
Files changed (57) hide show
  1. package/dist/burnWorker.js +6 -6
  2. package/dist/burnWorker.js.map +1 -1
  3. package/dist/chain.d.ts +19 -5
  4. package/dist/chain.js +17 -11
  5. package/dist/chain.js.map +1 -1
  6. package/dist/context.d.ts +23 -4
  7. package/dist/context.js +35 -6
  8. package/dist/context.js.map +1 -1
  9. package/dist/core.d.ts +32 -17
  10. package/dist/core.js +253 -70
  11. package/dist/core.js.map +1 -1
  12. package/dist/errors.d.ts +2 -0
  13. package/dist/errors.js.map +1 -1
  14. package/dist/index.d.ts +2 -2
  15. package/dist/index.js +2 -2
  16. package/dist/index.js.map +1 -1
  17. package/dist/pos.js +2 -2
  18. package/dist/pos.js.map +1 -1
  19. package/dist/rageExit.js +3 -5
  20. package/dist/rageExit.js.map +1 -1
  21. package/dist/seed.d.ts +4 -0
  22. package/dist/seed.js +12 -1
  23. package/dist/seed.js.map +1 -1
  24. package/dist/services.d.ts +5 -5
  25. package/dist/services.js +10 -7
  26. package/dist/services.js.map +1 -1
  27. package/dist/spend.d.ts +1 -1
  28. package/dist/spend.js +5 -4
  29. package/dist/spend.js.map +1 -1
  30. package/dist/state.d.ts +14 -4
  31. package/dist/state.js +10 -7
  32. package/dist/state.js.map +1 -1
  33. package/dist/sync.d.ts +16 -3
  34. package/dist/sync.js +90 -25
  35. package/dist/sync.js.map +1 -1
  36. package/dist/wallet.d.ts +27 -13
  37. package/dist/wallet.js +125 -62
  38. package/dist/wallet.js.map +1 -1
  39. package/package.json +4 -3
  40. package/src/burnWorker.ts +9 -10
  41. package/src/chain.ts +35 -12
  42. package/src/context.ts +48 -8
  43. package/src/core.ts +279 -87
  44. package/src/errors.ts +2 -0
  45. package/src/index.ts +2 -2
  46. package/src/pos.ts +2 -2
  47. package/src/rageExit.ts +3 -4
  48. package/src/seed.ts +13 -1
  49. package/src/services.ts +18 -7
  50. package/src/spend.ts +5 -5
  51. package/src/state.ts +17 -7
  52. package/src/sync.ts +95 -24
  53. package/src/wallet.ts +159 -64
  54. package/wasm/zerc20_wallet_core.d.ts +16 -13
  55. package/wasm/zerc20_wallet_core.js +49 -42
  56. package/wasm/zerc20_wallet_core_bg.wasm +0 -0
  57. package/wasm/zerc20_wallet_core_bg.wasm.d.ts +3 -3
package/package.json CHANGED
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "@zerc20/wallet-sdk",
3
- "version": "0.1.0",
3
+ "version": "0.3.0",
4
4
  "description": "A zERC20 wallet for browsers and Node: deposits, private payments, withdrawals and rage exits.",
5
5
  "type": "module",
6
6
  "license": "Unlicense",
7
7
  "repository": {
8
8
  "type": "git",
9
- "url": "git+https://github.com/kbizikav/zerc20-v2-new.git",
9
+ "url": "git+https://github.com/zerc20io/zERC20v2.git",
10
10
  "directory": "wallet/sdk"
11
11
  },
12
12
  "main": "dist/index.js",
@@ -35,7 +35,8 @@
35
35
  "@noble/ciphers": "^2.3.0",
36
36
  "@noble/curves": "^2.3.0",
37
37
  "@noble/hashes": "^2.3.0",
38
- "@zerc20/storage-sdk": "^0.1.0",
38
+ "@scure/bip39": "^2.4.0",
39
+ "@zerc20/storage-sdk": "^0.3.0",
39
40
  "snarkjs": "0.7.6"
40
41
  },
41
42
  "peerDependencies": {
package/src/burnWorker.ts CHANGED
@@ -1,25 +1,24 @@
1
- // Searches a burn address off the main thread: core.ts starts one per search, a Web Worker in a browser and a worker
2
- // thread in Node.
1
+ // Runs blocks of burn address searches off the main thread, one at a time: core.ts keeps a pool of these, Web Workers
2
+ // in a browser and worker threads in Node.
3
3
 
4
- import { type BurnAnswer, type BurnSearch, initCore, searchHere } from './core.js';
4
+ import { type BurnAnswer, type BurnBlock, initCore, runBlock } from './core.js';
5
5
 
6
- async function answer(s: BurnSearch): Promise<BurnAnswer> {
6
+ async function answer(b: BurnBlock): Promise<BurnAnswer> {
7
7
  try {
8
8
  await initCore();
9
- return { burn: searchHere(s) };
9
+ return { burn: runBlock(b) };
10
10
  } catch (e) {
11
11
  return { error: e instanceof Error ? e.message : String(e) };
12
12
  }
13
13
  }
14
14
 
15
15
  if (typeof self !== 'undefined') {
16
- self.onmessage = async ({ data }: MessageEvent<BurnSearch>) => postMessage(await answer(data));
16
+ self.onmessage = async ({ data }: MessageEvent<BurnBlock>) => postMessage(await answer(data));
17
17
  } else {
18
18
  // a variable, so that bundlers for the browser leave Node's module alone
19
19
  const threads = 'node:worker_threads';
20
- type Port = { postMessage(a: BurnAnswer): void };
21
- void import(/* @vite-ignore */ threads).then(
22
- async ({ parentPort, workerData }: { parentPort: Port; workerData: BurnSearch }) =>
23
- parentPort.postMessage(await answer(workerData)),
20
+ type Port = { on(event: 'message', f: (b: BurnBlock) => void): void; postMessage(a: BurnAnswer): void };
21
+ void import(/* @vite-ignore */ threads).then(({ parentPort }: { parentPort: Port }) =>
22
+ parentPort.on('message', (b) => void answer(b).then((a) => parentPort.postMessage(a))),
24
23
  );
25
24
  }
package/src/chain.ts CHANGED
@@ -57,9 +57,19 @@ export const erc20Abi = parseAbi([
57
57
  ]);
58
58
 
59
59
  export const depositFactoryAbi = parseAbi([
60
- 'function predict(address token, address burnAddress, address refundAddress, address feeRecipient, uint256 fee) view returns (address)',
60
+ 'function predict(address token, address burnAddress, address refundAddress, address feeRecipient, uint256 fee, address referrer, uint16 referralBps) view returns (address)',
61
61
  ]);
62
62
 
63
+ /** The factory's `MAX_REFERRAL_BPS`: the most of a deposit, after the relayer's fee, that a referrer can take. */
64
+ export const MAX_REFERRAL_BPS = 300;
65
+
66
+ /** An app that hands out deposit addresses and takes `bps` of each payment to them, of what is left after the relayer's
67
+ * fee. `address` is paid, and also names the app on chain. */
68
+ export interface Referrer {
69
+ address: Address;
70
+ bps: number;
71
+ }
72
+
63
73
  export const depositAddressAbi = parseAbi(['function refund(address asset)']);
64
74
 
65
75
  /** The native asset's stand-in for `underlyingAsset`. */
@@ -106,35 +116,48 @@ export class Chain {
106
116
  }
107
117
 
108
118
  /** The deposit address the factory deploys for `token`, `burnAddress` and `refundAddress`, whose flush pays `fee`
109
- * to `feeRecipient`. */
119
+ * to `feeRecipient` and `referralBps` of the rest to `referrer`. */
110
120
  predictDepositAddress(
111
121
  token: Address,
112
122
  burnAddress: Hex,
113
123
  refundAddress: Address,
114
124
  feeRecipient: Address,
115
125
  fee: bigint,
126
+ referrer: Address,
127
+ referralBps: number,
116
128
  ): Promise<Address> {
117
129
  return this.client.readContract({
118
130
  address: this.config.depositAddressFactory,
119
131
  abi: depositFactoryAbi,
120
132
  functionName: 'predict',
121
- args: [token, burnAddress, refundAddress, feeRecipient, fee],
133
+ args: [token, burnAddress, refundAddress, feeRecipient, fee, referrer, referralBps],
122
134
  });
123
135
  }
124
136
 
125
137
  /** The refund address of `address` if it is a deployed deposit address for `token` and `burnAddress`, whatever its
126
- * refund address and fee; null if it is not one. A clone's code ends with its immutable args, `abi.encode(token,
127
- * burnAddress, refundAddress, feeRecipient, fee)`; the factory's address for them confirms it, so code made to look
128
- * like one does not pass. */
138
+ * refund address, fee and referral; null if it is not one. A clone's code ends with its immutable args,
139
+ * `abi.encode(token, burnAddress, refundAddress, feeRecipient, fee, referrer, referralBps)`; the factory's address
140
+ * for them confirms it, so code made to look like one does not pass. */
129
141
  async depositAddressRefund(address: Address, token: Address, burnAddress: Hex): Promise<Address | null> {
130
142
  const code = await this.client.getCode({ address });
131
- if (!code || size(code) < 160) return null;
132
- const [refundAddress, feeRecipient, fee] = decodeAbiParameters(
133
- [{ type: 'address' }, { type: 'address' }, { type: 'uint256' }],
134
- slice(code, size(code) - 96),
143
+ if (!code || size(code) < 224) return null;
144
+ // the uint16 read as a whole word, so code made to look like a clone cannot make the decoding throw
145
+ const [refundAddress, feeRecipient, fee, referrer, referralBps] = decodeAbiParameters(
146
+ [{ type: 'address' }, { type: 'address' }, { type: 'uint256' }, { type: 'address' }, { type: 'uint256' }],
147
+ slice(code, size(code) - 160),
148
+ );
149
+ // predict would revert
150
+ if (refundAddress === zeroAddress || feeRecipient === zeroAddress) return null;
151
+ if (referralBps > MAX_REFERRAL_BPS || (referrer === zeroAddress && referralBps !== 0n)) return null;
152
+ const expected = await this.predictDepositAddress(
153
+ token,
154
+ burnAddress,
155
+ refundAddress,
156
+ feeRecipient,
157
+ fee,
158
+ referrer,
159
+ Number(referralBps),
135
160
  );
136
- if (refundAddress === zeroAddress || feeRecipient === zeroAddress) return null; // predict would revert
137
- const expected = await this.predictDepositAddress(token, burnAddress, refundAddress, feeRecipient, fee);
138
161
  return expected.toLowerCase() === address.toLowerCase() ? refundAddress : null;
139
162
  }
140
163
 
package/src/context.ts CHANGED
@@ -1,9 +1,10 @@
1
1
  // Everything an open wallet works with, shared by sync and the operations.
2
2
 
3
3
  import type { CanisterClient, ViewKey } from '@zerc20/storage-sdk';
4
- import { type Hex, type WalletClient, publicActions } from 'viem';
4
+ import { type Account, type Hex, type WalletClient, createWalletClient, http, publicActions } from 'viem';
5
5
  import type { Chain } from './chain.js';
6
6
  import { core } from './core.js';
7
+ import { guard } from './errors.js';
7
8
  import type { ChainConfig, Network } from './network.js';
8
9
  import type { Asp, Indexer } from './servers.js';
9
10
  import type { ChainState, WalletState } from './state.js';
@@ -24,11 +25,21 @@ export interface SpendKey {
24
25
  nullifyingKey: Hex;
25
26
  }
26
27
 
28
+ /** What a sync needs of a key: its public key and nullifying key, which find and follow what it owns but spend none of
29
+ * it. */
30
+ export type Owner = Pick<SpendKey, 'publicKey' | 'nullifyingKey'>;
31
+
27
32
  export interface Ctx {
28
33
  network: Network;
29
- seed: Uint8Array;
34
+ /** The root of every key. Opened with a function for it, held only while something needs it; otherwise always. */
35
+ seed: Uint8Array | null;
36
+ /** The owners derived while the seed was held, by key: the wallet's own (`''`) and each receive code's. */
37
+ owners: Map<string, Owner>;
30
38
  address: Hex;
31
- walletClient: WalletClient;
39
+ /** The account that holds the public funds, and signs for them. */
40
+ account: Account;
41
+ /** The user's wallet, when it is what signs and sends rather than an account of the app's own (`walletOn`). */
42
+ walletClient: WalletClient | null;
32
43
  storage: CanisterClient;
33
44
  view: ViewKey;
34
45
  chains: ChainCtx[];
@@ -47,20 +58,36 @@ export interface CodeId {
47
58
  index: number;
48
59
  }
49
60
 
61
+ const idOf = (code?: CodeId) => (code ? `${code.chainId}:${code.tokenId}:${code.index}` : '');
62
+
63
+ /** The seed, which the wallet holds whenever it does what needs it. */
64
+ export function seedOf(ctx: Ctx): Uint8Array {
65
+ if (!ctx.seed) throw new Error('the wallet does not hold its seed now');
66
+ return ctx.seed;
67
+ }
68
+
50
69
  /** The key that owns this seed's notes and deposits on every chain and token, or with `code`, the notes paid to that
51
- * receive code. */
70
+ * receive code. Its owner is kept for when the seed is not held. */
52
71
  export function spendKey(ctx: Ctx, code?: CodeId): SpendKey {
53
- let bySeed = keys.get(ctx.seed);
54
- if (!bySeed) keys.set(ctx.seed, (bySeed = new Map<string, SpendKey>()));
55
- const id = code ? `${code.chainId}:${code.tokenId}:${code.index}` : '';
72
+ const seed = seedOf(ctx);
73
+ let bySeed = keys.get(seed);
74
+ if (!bySeed) keys.set(seed, (bySeed = new Map<string, SpendKey>()));
75
+ const id = idOf(code);
56
76
  let key = bySeed.get(id);
57
77
  if (!key) {
58
- key = (code ? core.codeKey(ctx.seed, code.chainId, code.tokenId, code.index) : core.spendKey(ctx.seed)) as SpendKey;
78
+ key = (code ? core.codeSpendKey(seed, code.chainId, code.tokenId, code.index) : core.spendKey(seed)) as SpendKey;
59
79
  bySeed.set(id, key);
60
80
  }
81
+ if (!ctx.owners.has(id)) ctx.owners.set(id, { publicKey: key.publicKey, nullifyingKey: key.nullifyingKey });
61
82
  return key;
62
83
  }
63
84
 
85
+ /** `spendKey`'s owner: derived before, or now if the seed is held; null if neither. The wallet's own is derived as it
86
+ * opens. */
87
+ export function owner(ctx: Ctx, code?: CodeId): Owner | null {
88
+ return ctx.owners.get(idOf(code)) ?? (ctx.seed ? spendKey(ctx, code) : null);
89
+ }
90
+
64
91
  export function chainCtx(ctx: Ctx, chainId: bigint): ChainCtx {
65
92
  const c = ctx.chains.find((c) => c.config.chainId === chainId);
66
93
  if (!c) throw new Error(`chain ${chainId} is not in this network`);
@@ -73,6 +100,19 @@ export function chainState(ctx: Ctx, chainId: bigint): ChainState {
73
100
  return s;
74
101
  }
75
102
 
103
+ /** `wc` on `chainId`, switched there first: a browser wallet is on one chain at a time. */
104
+ export async function switched(wc: WalletClient, chainId: bigint): Promise<WalletClient> {
105
+ if ((await wc.getChainId()) !== Number(chainId)) await guard(wc.switchChain({ id: Number(chainId) }));
106
+ return wc;
107
+ }
108
+
109
+ /** The client that sends the account's transactions on `chainId`: the user's wallet, switched to the chain, or the
110
+ * app's own account, through the chain's RPC. */
111
+ export async function walletOn(ctx: Ctx, chainId: bigint): Promise<WalletClient> {
112
+ if (ctx.walletClient) return switched(ctx.walletClient, chainId);
113
+ return createWalletClient({ account: ctx.account, transport: http(chainCtx(ctx, chainId).config.rpcUrl) });
114
+ }
115
+
76
116
  /** Waits until the wallet's own node has a transaction mined on ours too: the wallet simulates the next one there, and
77
117
  * where blocks are fast that node lags ours now and then, so the next one would show as reverting. A wallet that does
78
118
  * not answer is not waited on. */
package/src/core.ts CHANGED
@@ -26,27 +26,17 @@ export interface Burn {
26
26
  address: Address;
27
27
  }
28
28
 
29
- /** A seed's `index`-th burn address of a token, with its salt: about 2^16 hashes, up to tens of seconds on a phone.
30
- * Searched off the main thread (a Web Worker in a browser, a worker thread in Node), so that the app stays
31
- * responsive; `signal` stops the search. */
29
+ /** The `index`-th burn address of `publicKey` on a chain and token, and its salt, from `burnKey`: the wallet's own burn
30
+ * key, or a point of sale's (`wallet_keys::derive_burn_address`). About 2^16 hashes, spread over the device's cores
31
+ * (Web Workers in a browser, worker threads in Node) so that the app stays responsive; `signal` stops the search. */
32
32
  export const burnAddress = (
33
- seed: Uint8Array,
33
+ burnKey: Uint8Array,
34
34
  chainId: bigint,
35
35
  tokenId: number,
36
36
  index: number,
37
- signal?: AbortSignal,
38
- ): Promise<Burn> => search({ seed, chainId, tokenId, index }, signal);
39
-
40
- /** The `k`-th burn address a point of sale holding `laneKey` makes for its owner's `publicKey` on a chain and token
41
- * (`keys::pos_burn`), with its salt: about 2^16 hashes too, searched the same way. */
42
- export const posBurnAddress = (
43
- laneKey: Uint8Array,
44
- chainId: bigint,
45
- tokenId: number,
46
- k: number,
47
37
  publicKey: Hex,
48
38
  signal?: AbortSignal,
49
- ): Promise<Burn> => search({ laneKey, chainId, tokenId, k, publicKey }, signal);
39
+ ): Promise<Burn> => search({ burnKey, chainId, tokenId, index, publicKey }, signal);
50
40
 
51
41
  /** A burn address of someone else's `publicKey` (one they published to be paid), with a random salt that has the proof
52
42
  * of work: about 2^16 hashes too, searched the same way. Only who holds the salt finds it: the payer, and the owner once
@@ -55,93 +45,295 @@ export const burnAddressFor = (chainId: bigint, publicKey: Hex, signal?: AbortSi
55
45
  search({ chainId, publicKey }, signal);
56
46
 
57
47
  export type BurnSearch =
58
- | { seed: Uint8Array; chainId: bigint; tokenId: number; index: number }
59
- | { laneKey: Uint8Array; chainId: bigint; tokenId: number; k: number; publicKey: Hex }
48
+ | { burnKey: Uint8Array; chainId: bigint; tokenId: number; index: number; publicKey: Hex }
60
49
  | { chainId: bigint; publicKey: Hex };
61
50
 
62
- /** A search, where it runs: a seed's index, or a point of sale's counter, derives its salts; someone else's key takes
63
- * random ones. */
64
- export function searchHere(s: BurnSearch): Burn {
65
- if ('seed' in s) return core.burnAddress(s.seed, s.chainId, s.tokenId, s.index) as Burn;
66
- if ('laneKey' in s) return core.posBurnAddress(s.laneKey, s.chainId, s.tokenId, s.k, s.publicKey) as Burn;
67
- for (;;) {
68
- const bytes = crypto.getRandomValues(new Uint8Array(32));
69
- bytes[0] = bytes[0] & 0x1f; // below 2^253: a canonical field element
70
- const salt = bytesToHex(bytes);
71
- const address = core.burnAddressOf(s.chainId, s.publicKey, salt) as Address | undefined;
72
- if (address) return { salt, address };
51
+ /** Counters per block: about 20 ms of hashing on a laptop, a few times that on a phone. */
52
+ const BLOCK = 512;
53
+
54
+ /** A block of a search: its salt counters `from..=to`, or as many random salts for someone else's key. */
55
+ export interface BurnBlock {
56
+ search: BurnSearch;
57
+ from: number;
58
+ to: number;
59
+ }
60
+
61
+ export type BurnAnswer = { burn: Burn | null } | { error: string };
62
+
63
+ /** The address a block holds, if any. Its salts count up from a start: one the burn key derives, or for someone else's
64
+ * key a random one below 2^253, so that every salt is a field element. */
65
+ export function runBlock({ search: s, from, to }: BurnBlock): Burn | null {
66
+ let burn: unknown;
67
+ if ('burnKey' in s) burn = core.burnBlock(s.burnKey, s.chainId, s.tokenId, s.index, s.publicKey, from, to);
68
+ else {
69
+ const start = crypto.getRandomValues(new Uint8Array(32));
70
+ start[0] = start[0] & 0x1f;
71
+ burn = core.burnBlockFor(s.chainId, s.publicKey, bytesToHex(start), to - from + 1);
72
+ }
73
+ return (burn as Burn | undefined) ?? null;
74
+ }
75
+
76
+ type Outcome = { burn: Burn } | { error: unknown };
77
+
78
+ /** A search in progress, in blocks `0, 1, …` of `BLOCK` counters. From a burn key, the address is the first block's
79
+ * hit, so that every device derives the same one; for someone else's key, it is the first found. */
80
+ export class Search {
81
+ readonly s: BurnSearch;
82
+ readonly #settle: (r: Outcome) => void;
83
+ /** From a burn key: the address is the first block's hit, not the first found. */
84
+ readonly #derived: boolean;
85
+ #settled = false;
86
+ #next = 0;
87
+ /** Blocks a worker took and died with, handed out again before any other. */
88
+ #dropped: number[] = [];
89
+ /** What the finished blocks from `#checked` on hold: every block below it holds none. */
90
+ #found = new Map<number, Burn | null>();
91
+ #checked = 0;
92
+ /** The lowest block known to hold an address: no block above it is needed. */
93
+ #lowest = Infinity;
94
+
95
+ constructor(s: BurnSearch, settle: (r: Outcome) => void) {
96
+ this.s = s;
97
+ this.#settle = settle;
98
+ this.#derived = 'burnKey' in s;
99
+ }
100
+
101
+ take(): number | undefined {
102
+ if (this.#settled) return undefined;
103
+ if (this.#dropped.length) return this.#dropped.shift();
104
+ return this.#next < this.#lowest ? this.#next++ : undefined;
105
+ }
106
+
107
+ drop(block: number) {
108
+ this.#dropped.unshift(block);
109
+ }
110
+
111
+ finish(block: number, burn: Burn | null) {
112
+ if (this.#settled) return;
113
+ if (!this.#derived) {
114
+ if (burn) this.#end({ burn });
115
+ return;
116
+ }
117
+ this.#found.set(block, burn);
118
+ if (burn) this.#lowest = Math.min(this.#lowest, block);
119
+ for (let found; (found = this.#found.get(this.#checked)) !== undefined; this.#checked++) {
120
+ if (found) return this.#end({ burn: found });
121
+ this.#found.delete(this.#checked);
122
+ }
123
+ }
124
+
125
+ fail(error: unknown) {
126
+ if (!this.#settled) this.#end({ error });
127
+ }
128
+
129
+ #end(r: Outcome) {
130
+ this.#settled = true;
131
+ this.#settle(r);
73
132
  }
74
133
  }
75
134
 
76
- export type BurnAnswer = { burn: Burn } | { error: string };
135
+ /** A worker running one block at a time. */
136
+ interface Thread {
137
+ job: Job | null;
138
+ /** Whether it ever answered: one that dies before it does never ran. */
139
+ answered: boolean;
140
+ send(b: BurnBlock): void;
141
+ stop(): void;
142
+ /** In Node, a thread keeps the process up while it works, and only then. */
143
+ busy(working: boolean): void;
144
+ }
145
+
146
+ type Job = { search: Search; block: number };
77
147
 
78
148
  /** What this module takes from Node's worker threads, typed here so that the package needs no Node types. */
79
- type NodeThread = new (
80
- url: URL,
81
- opts: { workerData: BurnSearch },
82
- ) => {
83
- once(event: 'message', f: (a: BurnAnswer) => void): void;
84
- once(event: 'error', f: () => void): void;
149
+ type NodeWorker = new (url: URL) => {
150
+ on(event: 'message', f: (a: BurnAnswer) => void): void;
151
+ on(event: 'error' | 'exit' | 'messageerror', f: () => void): void;
152
+ postMessage(b: BurnBlock): void;
153
+ ref(): void;
154
+ unref(): void;
85
155
  terminate(): Promise<number>;
86
156
  };
87
157
 
88
- /** Starts `s` in a worker, which hands `done` its answer, or null if it did not run; returns how to stop it, or null
89
- * where no worker starts. */
90
- // ponytail: a worker per search, each compiling the wasm; a pool of availableParallelism() workers if a server makes many
91
- async function spawn(s: BurnSearch, done: (a: BurnAnswer | null) => void): Promise<(() => void) | null> {
92
- if (typeof Worker !== 'undefined') {
93
- const worker = new Worker(new URL('./burnWorker.js', import.meta.url), { type: 'module' });
94
- worker.onmessage = ({ data }: MessageEvent<BurnAnswer>) => done(data);
95
- // a worker that does not start (a bundler that left it out) costs responsiveness only
96
- worker.onerror = (e) => {
97
- e.preventDefault();
98
- done(null);
99
- };
100
- worker.postMessage(s);
101
- return () => worker.terminate();
102
- }
103
- // variables, so that bundlers for the browser leave Node's module and a second copy of the worker alone
104
- const [threads, file] = ['node:worker_threads', './burnWorker.js'];
105
- const { Worker: Thread } = (await import(/* @vite-ignore */ threads).catch(() => ({}))) as { Worker?: NodeThread };
106
- if (!Thread) return null;
107
- const thread = new Thread(new URL(file, import.meta.url), { workerData: s });
108
- thread.once('message', done);
109
- thread.once('error', () => done(null)); // run from source, as tests are, there is no burnWorker.js
110
- return () => void thread.terminate();
111
- }
158
+ /** Searches in progress, oldest first: blocks go to the oldest that has one to hand out. */
159
+ const searches: Search[] = [];
160
+ let threads: Thread[] | null = null;
161
+ let starting = false;
162
+ /** Workers do not run here (a page whose CSP forbids them, a bundler that left burnWorker.js out, tests run from
163
+ * source): blocks run on this thread instead. */
164
+ let here = false;
165
+ let runningHere = false;
166
+ let idle: ReturnType<typeof setTimeout> | undefined;
167
+ /** An idle pool stops after this long, freeing a wasm instance per core. */
168
+ const IDLE_MS = 30_000;
112
169
 
113
170
  async function search(s: BurnSearch, signal?: AbortSignal): Promise<Burn> {
114
- signal?.throwIfAborted();
115
171
  await initCore();
172
+ signal?.throwIfAborted();
116
173
  return new Promise((resolve, reject) => {
117
- let stop: (() => void) | null = null;
118
- let settled = false;
119
- const settle = (f: () => void) => {
120
- if (settled) return;
121
- settled = true;
122
- stop?.();
174
+ const abort = () => one.fail(signal!.reason);
175
+ const one = new Search(s, (r) => {
123
176
  signal?.removeEventListener('abort', abort);
124
- f();
125
- };
126
- const here = () =>
127
- settle(() => {
128
- try {
129
- resolve(searchHere(s));
130
- } catch (err) {
131
- reject(err);
132
- }
133
- });
134
- const abort = () => settle(() => reject(signal!.reason));
177
+ searches.splice(searches.indexOf(one), 1);
178
+ if ('burn' in r) resolve(r.burn);
179
+ else reject(r.error);
180
+ });
135
181
  signal?.addEventListener('abort', abort, { once: true });
136
- spawn(s, (a) =>
137
- a === null ? here() : settle(() => ('burn' in a ? resolve(a.burn) : reject(new Error(a.error)))),
138
- ).then(
139
- (stopIt) => {
140
- if (!stopIt) here();
141
- else if (settled) stopIt();
142
- else stop = stopIt;
143
- },
144
- here, // a page whose CSP forbids workers
145
- );
182
+ searches.push(one);
183
+ dispatch();
184
+ });
185
+ }
186
+
187
+ function nextJob(): Job | null {
188
+ for (const search of searches) {
189
+ const block = search.take();
190
+ if (block !== undefined) return { search, block };
191
+ }
192
+ return null;
193
+ }
194
+
195
+ const blockOf = ({ search, block }: Job): BurnBlock => ({
196
+ search: search.s,
197
+ from: block * BLOCK,
198
+ to: block * BLOCK + BLOCK - 1,
199
+ });
200
+
201
+ function deliver({ search, block }: Job, a: BurnAnswer) {
202
+ if ('error' in a) search.fail(new Error(a.error));
203
+ else search.finish(block, a.burn);
204
+ }
205
+
206
+ /** Hands blocks to idle threads, starting the pool if it is not running. */
207
+ function dispatch() {
208
+ clearTimeout(idle);
209
+ if (here) return void runHere();
210
+ if (!threads) return void start();
211
+ for (const t of threads) {
212
+ if (t.job) continue;
213
+ const job = nextJob();
214
+ if (!job) break;
215
+ t.job = job;
216
+ t.busy(true);
217
+ t.send(blockOf(job));
218
+ }
219
+ if (!searches.length && threads.every((t) => !t.job)) {
220
+ idle = setTimeout(stop, IDLE_MS);
221
+ (idle as { unref?: () => void }).unref?.(); // a process need not stay up for it
222
+ }
223
+ }
224
+
225
+ async function start() {
226
+ if (starting) return;
227
+ starting = true;
228
+ const cores = globalThis.navigator?.hardwareConcurrency ?? 1;
229
+ // Node leaves a core to its event loop: a point of sale's server answers while it searches
230
+ const count = Math.max(1, Math.min(16, typeof Worker !== 'undefined' ? cores : cores - 1));
231
+ threads = await spawn(count).catch(() => []);
232
+ starting = false;
233
+ if (!threads.length) {
234
+ threads = null;
235
+ void runHere();
236
+ }
237
+ dispatch();
238
+ }
239
+
240
+ function stop() {
241
+ const stopped = threads ?? [];
242
+ threads = null;
243
+ for (const t of stopped) t.stop();
244
+ }
245
+
246
+ function answered(t: Thread, a: BurnAnswer) {
247
+ t.answered = true;
248
+ const job = t.job!;
249
+ t.job = null;
250
+ t.busy(false);
251
+ deliver(job, a);
252
+ dispatch();
253
+ }
254
+
255
+ function died(t: Thread) {
256
+ if (!threads?.includes(t)) return; // stopped, or already counted
257
+ threads = threads.filter((other) => other !== t);
258
+ if (t.job) t.job.search.drop(t.job.block);
259
+ if (!t.answered) {
260
+ // it never ran: neither will the others
261
+ for (const other of threads) if (other.job) other.job.search.drop(other.job.block);
262
+ stop();
263
+ void runHere();
264
+ return;
265
+ }
266
+ if (!threads.length) threads = null; // started again on the next dispatch
267
+ dispatch();
268
+ }
269
+
270
+ /** Starts `count` workers; none where there are no workers. */
271
+ async function spawn(count: number): Promise<Thread[]> {
272
+ if (typeof Worker !== 'undefined') {
273
+ return Array.from({ length: count }, () => {
274
+ // this exact form is what bundlers (Vite, webpack) look for to emit the worker
275
+ const worker = new Worker(new URL('./burnWorker.js', import.meta.url), { type: 'module' });
276
+ const t: Thread = {
277
+ job: null,
278
+ answered: false,
279
+ send: (b) => worker.postMessage(b),
280
+ stop: () => worker.terminate(),
281
+ busy: () => {},
282
+ };
283
+ worker.onmessage = ({ data }: MessageEvent<BurnAnswer>) => answered(t, data);
284
+ worker.onerror = (e) => {
285
+ e.preventDefault();
286
+ died(t);
287
+ };
288
+ worker.onmessageerror = () => died(t);
289
+ return t;
290
+ });
291
+ }
292
+ // variables, so that bundlers for the browser leave Node's module and a second copy of the worker alone
293
+ const [threadsModule, file] = ['node:worker_threads', './burnWorker.js'];
294
+ const { Worker: ThreadWorker } = (await import(/* @vite-ignore */ threadsModule).catch(() => ({}))) as {
295
+ Worker?: NodeWorker;
296
+ };
297
+ if (!ThreadWorker) return [];
298
+ return Array.from({ length: count }, () => {
299
+ const worker = new ThreadWorker(new URL(file, import.meta.url));
300
+ worker.unref();
301
+ const t: Thread = {
302
+ job: null,
303
+ answered: false,
304
+ send: (b) => worker.postMessage(b),
305
+ stop: () => void worker.terminate(),
306
+ busy: (working) => (working ? worker.ref() : worker.unref()),
307
+ };
308
+ worker.on('message', (a) => answered(t, a));
309
+ // run from source, as tests are, there is no burnWorker.js: the thread exits before it answers
310
+ for (const event of ['error', 'exit', 'messageerror'] as const) worker.on(event, () => died(t));
311
+ return t;
146
312
  });
147
313
  }
314
+
315
+ /** Runs the blocks on this thread, yielding to the event loop every ~100 ms, and no more often: each yield is a timer,
316
+ * which costs 4 ms in a visible tab and a whole second in a hidden one. */
317
+ async function runHere() {
318
+ if (!here) {
319
+ here = true;
320
+ console.warn('zerc20: burn addresses are searched on the main thread, slower: no worker starts here');
321
+ }
322
+ if (runningHere) return;
323
+ runningHere = true;
324
+ let since = Date.now();
325
+ for (let job; (job = nextJob());) {
326
+ let a: BurnAnswer;
327
+ try {
328
+ a = { burn: runBlock(blockOf(job)) };
329
+ } catch (e) {
330
+ a = { error: e instanceof Error ? e.message : String(e) };
331
+ }
332
+ deliver(job, a);
333
+ if (Date.now() - since >= 100) {
334
+ await new Promise((resolve) => setTimeout(resolve, 0));
335
+ since = Date.now();
336
+ }
337
+ }
338
+ runningHere = false;
339
+ }
package/src/errors.ts CHANGED
@@ -15,6 +15,8 @@ export type ErrorCode =
15
15
  | 'fee-too-high'
16
16
  /** The address never registered for private payments: pay it publicly or ask for a receive code. */
17
17
  | 'recipient-not-registered'
18
+ /** The withdrawal's recipient (or a merge's fee recipient) fails the attester's AML screening: withdraw elsewhere. */
19
+ | 'recipient-refused'
18
20
  /** A contract account: it cannot sign the messages a wallet needs. */
19
21
  | 'unsupported-account'
20
22
  /** The user declined a signature or a transaction in their wallet. */