@zerc20/wallet-sdk 0.4.0 → 0.6.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 (73) hide show
  1. package/LICENSE +17 -20
  2. package/README.md +2 -2
  3. package/dist/accountId.d.ts +5 -0
  4. package/dist/accountId.js +19 -0
  5. package/dist/accountId.js.map +1 -0
  6. package/dist/chain.d.ts +86 -9
  7. package/dist/chain.js +61 -33
  8. package/dist/chain.js.map +1 -1
  9. package/dist/context.d.ts +8 -2
  10. package/dist/context.js +15 -2
  11. package/dist/context.js.map +1 -1
  12. package/dist/core.d.ts +35 -5
  13. package/dist/core.js +98 -25
  14. package/dist/core.js.map +1 -1
  15. package/dist/errors.d.ts +20 -3
  16. package/dist/errors.js +7 -1
  17. package/dist/errors.js.map +1 -1
  18. package/dist/index.d.ts +4 -2
  19. package/dist/index.js +4 -2
  20. package/dist/index.js.map +1 -1
  21. package/dist/network.d.ts +4 -2
  22. package/dist/network.js +3 -1
  23. package/dist/network.js.map +1 -1
  24. package/dist/pos.d.ts +10 -1
  25. package/dist/pos.js +219 -55
  26. package/dist/pos.js.map +1 -1
  27. package/dist/prove.d.ts +3 -0
  28. package/dist/prove.js +9 -0
  29. package/dist/prove.js.map +1 -1
  30. package/dist/rageExit.d.ts +27 -4
  31. package/dist/rageExit.js +160 -49
  32. package/dist/rageExit.js.map +1 -1
  33. package/dist/seed.d.ts +4 -0
  34. package/dist/seed.js +13 -1
  35. package/dist/seed.js.map +1 -1
  36. package/dist/servers.d.ts +5 -0
  37. package/dist/servers.js +2 -0
  38. package/dist/servers.js.map +1 -1
  39. package/dist/services.d.ts +14 -6
  40. package/dist/services.js +35 -14
  41. package/dist/services.js.map +1 -1
  42. package/dist/spend.d.ts +53 -11
  43. package/dist/spend.js +171 -65
  44. package/dist/spend.js.map +1 -1
  45. package/dist/state.d.ts +21 -0
  46. package/dist/state.js.map +1 -1
  47. package/dist/sync.d.ts +10 -5
  48. package/dist/sync.js +49 -18
  49. package/dist/sync.js.map +1 -1
  50. package/dist/wallet.d.ts +132 -21
  51. package/dist/wallet.js +342 -91
  52. package/dist/wallet.js.map +1 -1
  53. package/package.json +3 -3
  54. package/src/accountId.ts +22 -0
  55. package/src/chain.ts +101 -49
  56. package/src/context.ts +27 -3
  57. package/src/core.ts +112 -26
  58. package/src/errors.ts +15 -2
  59. package/src/index.ts +15 -2
  60. package/src/network.ts +6 -3
  61. package/src/pos.ts +247 -61
  62. package/src/prove.ts +10 -0
  63. package/src/rageExit.ts +237 -61
  64. package/src/seed.ts +24 -1
  65. package/src/servers.ts +9 -1
  66. package/src/services.ts +36 -22
  67. package/src/spend.ts +188 -75
  68. package/src/state.ts +25 -1
  69. package/src/sync.ts +54 -19
  70. package/src/wallet.ts +437 -109
  71. package/wasm/zerc20_wallet_core.d.ts +1 -1
  72. package/wasm/zerc20_wallet_core.js +1 -1
  73. package/wasm/zerc20_wallet_core_bg.wasm +0 -0
package/src/core.ts CHANGED
@@ -28,21 +28,22 @@ export interface Burn {
28
28
 
29
29
  /** The `index`-th burn address of `publicKey` on a chain and token, and its salt, from `burnKey`: the wallet's own burn
30
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. */
31
+ * (Web Workers in a browser, worker threads in Node) so that the app stays responsive; `signal` stops the search. One
32
+ * that nobody waits for, made ahead, is `urgent: false`: it gives way to those someone waits for. */
32
33
  export const burnAddress = (
33
34
  burnKey: Uint8Array,
34
35
  chainId: bigint,
35
36
  tokenId: number,
36
37
  index: number,
37
38
  publicKey: Hex,
38
- signal?: AbortSignal,
39
- ): Promise<Burn> => search({ burnKey, chainId, tokenId, index, publicKey }, signal);
39
+ opts: { signal?: AbortSignal; urgent?: boolean } = {},
40
+ ): Promise<Burn> => search({ burnKey, chainId, tokenId, index, publicKey }, opts.urgent ?? true, opts.signal);
40
41
 
41
42
  /** A burn address of someone else's `publicKey` (one they published to be paid), with a random salt that has the proof
42
43
  * of work: about 2^16 hashes too, searched the same way. Only who holds the salt finds it: the payer, and the owner once
43
44
  * told (`planSend` with `burn`). */
44
45
  export const burnAddressFor = (chainId: bigint, publicKey: Hex, signal?: AbortSignal): Promise<Burn> =>
45
- search({ chainId, publicKey }, signal);
46
+ search({ chainId, publicKey }, true, signal);
46
47
 
47
48
  export type BurnSearch =
48
49
  | { burnKey: Uint8Array; chainId: bigint; tokenId: number; index: number; publicKey: Hex }
@@ -60,16 +61,27 @@ export interface BurnBlock {
60
61
 
61
62
  export type BurnAnswer = { burn: Burn | null } | { error: string };
62
63
 
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
- }
64
+ /** What a block asks of the protocol code: `burnBlock`'s arguments, or `burnBlockFor`'s. */
65
+ export type BurnCall =
66
+ | { burnKey: Uint8Array; chainId: bigint; tokenId: number; index: number; publicKey: Hex; from: number; to: number }
67
+ | { chainId: bigint; publicKey: Hex; start: Hex; count: number };
68
+
69
+ /** A block's call. Its salts count up from a start: one the burn key derives, or for someone else's key a random one
70
+ * below 2^253, so that every salt is a field element. */
71
+ export function blockCall({ search: s, from, to }: BurnBlock): BurnCall {
72
+ if ('burnKey' in s) return { ...s, from, to };
73
+ const start = crypto.getRandomValues(new Uint8Array(32));
74
+ start[0] = start[0] & 0x1f;
75
+ return { chainId: s.chainId, publicKey: s.publicKey, start: bytesToHex(start), count: to - from + 1 };
76
+ }
77
+
78
+ /** The address a block holds, if any. */
79
+ export function runBlock(b: BurnBlock): Burn | null {
80
+ const c = blockCall(b);
81
+ const burn: unknown =
82
+ 'burnKey' in c
83
+ ? core.burnBlock(c.burnKey, c.chainId, c.tokenId, c.index, c.publicKey, c.from, c.to)
84
+ : core.burnBlockFor(c.chainId, c.publicKey, c.start, c.count);
73
85
  return (burn as Burn | undefined) ?? null;
74
86
  }
75
87
 
@@ -155,8 +167,17 @@ type NodeWorker = new (url: URL) => {
155
167
  terminate(): Promise<number>;
156
168
  };
157
169
 
158
- /** Searches in progress, oldest first: blocks go to the oldest that has one to hand out. */
159
- const searches: Search[] = [];
170
+ /** Searches in progress by owner (the burn key, or the key paid): those someone waits for, and those made ahead. Each
171
+ * owner in turn gets a block of its oldest search that has one, so that one owner's many searches hold up no other's,
172
+ * and each address it waits for comes as soon as its cores allow. */
173
+ // ponytail: fair per owner, so per point of sale; a merchant opening many accounts gets many turns, so cap each
174
+ // merchant's sales if that happens
175
+ const queues = { urgent: new Map<string, Search[]>(), later: new Map<string, Search[]>() };
176
+ /** One block in this many goes to the searches made ahead while some are waited for: a flood of urgent searches slows
177
+ * the making ahead, which keeps everyone else's sales from waiting, but does not stop it. */
178
+ const LATER_EVERY = 4;
179
+ /** Urgent blocks handed out in a row. */
180
+ let urgentRun = 0;
160
181
  let threads: Thread[] | null = null;
161
182
  let starting = false;
162
183
  /** Workers do not run here (a page whose CSP forbids them, a bundler that left burnWorker.js out, tests run from
@@ -167,27 +188,51 @@ let idle: ReturnType<typeof setTimeout> | undefined;
167
188
  /** An idle pool stops after this long, freeing a wasm instance per core. */
168
189
  const IDLE_MS = 30_000;
169
190
 
170
- async function search(s: BurnSearch, signal?: AbortSignal): Promise<Burn> {
191
+ async function search(s: BurnSearch, urgent: boolean, signal?: AbortSignal): Promise<Burn> {
171
192
  await initCore();
172
193
  signal?.throwIfAborted();
194
+ const queue = urgent ? queues.urgent : queues.later;
195
+ const owner = 'burnKey' in s ? bytesToHex(s.burnKey) : s.publicKey;
173
196
  return new Promise((resolve, reject) => {
174
197
  const abort = () => one.fail(signal!.reason);
175
198
  const one = new Search(s, (r) => {
176
199
  signal?.removeEventListener('abort', abort);
177
- searches.splice(searches.indexOf(one), 1);
200
+ const mine = queue.get(owner)!;
201
+ mine.splice(mine.indexOf(one), 1);
202
+ if (!mine.length) queue.delete(owner);
178
203
  if ('burn' in r) resolve(r.burn);
179
204
  else reject(r.error);
180
205
  });
181
206
  signal?.addEventListener('abort', abort, { once: true });
182
- searches.push(one);
207
+ const mine = queue.get(owner);
208
+ if (mine) mine.push(one);
209
+ else queue.set(owner, [one]);
183
210
  dispatch();
184
211
  });
185
212
  }
186
213
 
187
214
  function nextJob(): Job | null {
188
- for (const search of searches) {
189
- const block = search.take();
190
- if (block !== undefined) return { search, block };
215
+ const order = urgentRun >= LATER_EVERY - 1 ? [queues.later, queues.urgent] : [queues.urgent, queues.later];
216
+ for (const queue of order) {
217
+ const job = takeFrom(queue);
218
+ if (!job) continue;
219
+ urgentRun = queue === queues.urgent ? urgentRun + 1 : 0;
220
+ return job;
221
+ }
222
+ return null;
223
+ }
224
+
225
+ /** A block of the first owner in line that has one, from its oldest search that does; that owner then goes to the back
226
+ * of the line. */
227
+ function takeFrom(queue: Map<string, Search[]>): Job | null {
228
+ for (const [owner, mine] of queue) {
229
+ for (const search of mine) {
230
+ const block = search.take();
231
+ if (block === undefined) continue;
232
+ queue.delete(owner);
233
+ queue.set(owner, mine);
234
+ return { search, block };
235
+ }
191
236
  }
192
237
  return null;
193
238
  }
@@ -203,9 +248,49 @@ function deliver({ search, block }: Job, a: BurnAnswer) {
203
248
  else search.finish(block, a.burn);
204
249
  }
205
250
 
206
- /** Hands blocks to idle threads, starting the pool if it is not running. */
251
+ /** Runs blocks somewhere other than this module's wasm: a server's native process, say. */
252
+ export interface BurnRunner {
253
+ /** How many blocks it runs at once. */
254
+ slots: number;
255
+ run(call: BurnCall): Promise<Burn | null>;
256
+ }
257
+
258
+ let runner: BurnRunner | null = null;
259
+ /** Blocks the runner has and has not answered. */
260
+ const onRunner = new Set<Job>();
261
+
262
+ /** Runs every block from now on on `r`, or with null in this module's wasm again. The blocks the runner had are handed
263
+ * out again, and what it answers for them later is dropped. */
264
+ export function runBurnsOn(r: BurnRunner | null): void {
265
+ for (const job of onRunner) job.search.drop(job.block);
266
+ onRunner.clear();
267
+ runner = r;
268
+ dispatch();
269
+ }
270
+
271
+ function feed(r: BurnRunner) {
272
+ while (onRunner.size < r.slots) {
273
+ const job = nextJob();
274
+ if (!job) return;
275
+ onRunner.add(job);
276
+ void r
277
+ .run(blockCall(blockOf(job)))
278
+ .then(
279
+ (burn): BurnAnswer => ({ burn }),
280
+ (e: unknown): BurnAnswer => ({ error: e instanceof Error ? e.message : String(e) }),
281
+ )
282
+ .then((a) => {
283
+ if (!onRunner.delete(job)) return; // handed out again when the runner was taken away
284
+ deliver(job, a);
285
+ dispatch();
286
+ });
287
+ }
288
+ }
289
+
290
+ /** Hands blocks to the runner, or to idle threads, starting the pool if it is not running. */
207
291
  function dispatch() {
208
292
  clearTimeout(idle);
293
+ if (runner) return feed(runner);
209
294
  if (here) return void runHere();
210
295
  if (!threads) return void start();
211
296
  for (const t of threads) {
@@ -216,7 +301,7 @@ function dispatch() {
216
301
  t.busy(true);
217
302
  t.send(blockOf(job));
218
303
  }
219
- if (!searches.length && threads.every((t) => !t.job)) {
304
+ if (!queues.urgent.size && !queues.later.size && threads.every((t) => !t.job)) {
220
305
  idle = setTimeout(stop, IDLE_MS);
221
306
  (idle as { unref?: () => void }).unref?.(); // a process need not stay up for it
222
307
  }
@@ -313,7 +398,8 @@ async function spawn(count: number): Promise<Thread[]> {
313
398
  }
314
399
 
315
400
  /** 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. */
401
+ * which costs 4 ms in a visible tab and a whole second in a hidden one. A runner set meanwhile takes every block from
402
+ * the next on. */
317
403
  async function runHere() {
318
404
  if (!here) {
319
405
  here = true;
@@ -322,7 +408,7 @@ async function runHere() {
322
408
  if (runningHere) return;
323
409
  runningHere = true;
324
410
  let since = Date.now();
325
- for (let job; (job = nextJob());) {
411
+ for (let job; !runner && (job = nextJob());) {
326
412
  let a: BurnAnswer;
327
413
  try {
328
414
  a = { burn: runBlock(blockOf(job)) };
package/src/errors.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  // What can go wrong, by code, so that an app can tell the user what to do.
2
2
 
3
3
  import { BaseError, UserRejectedRequestError } from 'viem';
4
+ import type { CoinInfo } from './wallet.js';
4
5
 
5
6
  export type ErrorCode =
6
7
  /** Not enough value for the amount and its fees, even counting what is still settling. */
@@ -9,10 +10,14 @@ export type ErrorCode =
9
10
  * `fees({ kind: 'consolidate' })` tells how many rounds that takes. */
10
11
  | 'needs-consolidation'
11
12
  /** What is spendable now does not pay it, but notes or deposits are still settling (screening, awaiting
12
- * a root, or the change of an operation in flight): pay again once `balances()` shows them spendable. */
13
+ * a root, or the change of an operation in flight): pay again once `balances()` shows them spendable. The error's
14
+ * `waitsOn` tells which coin, where that is known. */
13
15
  | 'funds-settling'
14
16
  /** The fees are above the `maxFee` the caller allowed. */
15
17
  | 'fee-too-high'
18
+ /** Another device of this wallet spent a note or deposit the operation was spending, first: each device locks its
19
+ * inputs in its own state. The operation cannot land and paid nothing: pay again. */
20
+ | 'spent-elsewhere'
16
21
  /** The address never registered for private payments: pay it publicly or ask for a receive code. */
17
22
  | 'recipient-not-registered'
18
23
  /** The withdrawal's recipient (or a merge's fee recipient) fails the attester's AML screening: withdraw elsewhere. */
@@ -21,8 +26,12 @@ export type ErrorCode =
21
26
  | 'unsupported-account'
22
27
  /** The user declined a signature or a transaction in their wallet. */
23
28
  | 'user-rejected'
24
- /** The wallet is open in another tab or window: close it there (`close()`), then open it here. */
29
+ /** The operation was cancelled (`cancel()`) before anything went to the relayer: it paid nothing. */
30
+ | 'cancelled'
31
+ /** The wallet is open in another tab or window: close it there (`close()`), or open it here with `take`. */
25
32
  | 'wallet-in-use'
33
+ /** Another tab or window took this wallet (`taken`): open it again with `take` to take it back. */
34
+ | 'wallet-taken'
26
35
  /** This wallet watches its account by its view key (`Wallet.watch`): it spends and writes nothing. */
27
36
  | 'read-only'
28
37
  /** This wallet object was closed (`close()`): open the wallet again to go on. */
@@ -40,6 +49,10 @@ export class WalletError extends Error {
40
49
  constructor(
41
50
  readonly code: ErrorCode,
42
51
  message: string,
52
+ /** For `funds-settling`, the coin on its way whose turning spendable should let it pay: when, by its `readyBy`,
53
+ * and why, by its `state`. Null where it may wait on something with no estimate (a halted root, a note not
54
+ * confirmed yet, an operation in flight), or where what is on its way does not pay it either. */
55
+ readonly waitsOn: CoinInfo | null = null,
43
56
  ) {
44
57
  super(message);
45
58
  this.name = 'WalletError';
package/src/index.ts CHANGED
@@ -5,14 +5,25 @@
5
5
  // wallet.balances(); // spendable, and what is pending and why
6
6
  // await (await wallet.deposit({ token, amount })).wait();
7
7
  // const q = await wallet.fees({ kind: 'send', token, amount });
8
- // await (await wallet.send({ to: '0x…', token, amount, maxFee: q.fee })).wait();
8
+ // await (await wallet.send({ to: 'zerc20id1…', token, amount, maxFee: q.fee })).wait();
9
9
  // const watching = await Wallet.watch({ network, viewKey: wallet.viewKey(), persist }); // elsewhere, spends nothing
10
10
  //
11
11
  // Nothing secret leaves the device unencrypted: JoinSplit proofs are made here, a merge's by the merge prover, and the
12
12
  // attester and the prover get the witness sealed to an enclave whose measurement the wallet checks. Servers are only
13
13
  // ever asked for what every wallet downloads.
14
14
 
15
- export { Chain, type Referrer, type Token, MAX_REFERRAL_BPS, NATIVE, UNWRAP_INTENT } from './chain.js';
15
+ export { accountId, idAccount } from './accountId.js';
16
+ export {
17
+ Chain,
18
+ type Referrer,
19
+ type Refund,
20
+ type Token,
21
+ MAX_REFERRAL_BPS,
22
+ NATIVE,
23
+ UNWRAP_INTENT,
24
+ rpcTransport,
25
+ } from './chain.js';
26
+ export { type Burn, type BurnCall, type BurnRunner, runBurnsOn } from './core.js';
16
27
  export { type ErrorCode, WalletError } from './errors.js';
17
28
  export {
18
29
  type AttesterTrust,
@@ -31,9 +42,11 @@ export {
31
42
  type Balance,
32
43
  type CoinInfo,
33
44
  type CoinState,
45
+ type DepositAddressInfo,
34
46
  type DepositInfo,
35
47
  type DepositStep,
36
48
  type FeeQuote,
49
+ type MaxAmount,
37
50
  type OpenOptions,
38
51
  type Operation,
39
52
  type OperationKind,
package/src/network.ts CHANGED
@@ -24,8 +24,10 @@ export const NITRO_ROOT_SHA256 = '641a0321a3e244efe456463195d606317ed7cdcc3c1756
24
24
 
25
25
  export interface ChainConfig {
26
26
  chainId: bigint;
27
- /** Used for every read and for waiting on receipts, never the wallet's own RPC. */
28
- rpcUrl: string;
27
+ /** The chain's RPCs, in order, for every read and every wait on a receipt, never the wallet's own: a call goes on
28
+ * to the next one when one fails it, so one that a resolver, a firewall or an outage keeps from a device stops
29
+ * nothing. */
30
+ rpcUrls: string[];
29
31
  verifier: Address;
30
32
  depositAddressFactory: Address;
31
33
  indexer: string;
@@ -84,7 +86,8 @@ export function checkNetwork(n: Network): void {
84
86
  }
85
87
  };
86
88
  for (const c of n.chains) {
87
- servers([c.rpcUrl, c.indexer, c.asp, c.attester, c.transactRelayer, c.depositRelayer]);
89
+ if (!c.rpcUrls.length) bad(`chain ${c.chainId} names no RPC`);
90
+ servers([...c.rpcUrls, c.indexer, c.asp, c.attester, c.transactRelayer, c.depositRelayer]);
88
91
  trusted([c], 'attester', c.attesterTrust);
89
92
  }
90
93
  if (n.mergeProver) {