@zerc20/wallet-sdk 0.3.0 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/wallet.ts CHANGED
@@ -10,6 +10,7 @@ import {
10
10
  encodeCode,
11
11
  lookupRecipient,
12
12
  nextPosLane,
13
+ notifyKey,
13
14
  planDeposit,
14
15
  planPosLane,
15
16
  posKey,
@@ -23,13 +24,25 @@ import {
23
24
  type WalletClient,
24
25
  bytesToHex,
25
26
  formatUnits,
27
+ getAddress,
26
28
  hexToBytes,
27
29
  isAddress,
28
30
  numberToHex,
29
31
  zeroAddress,
30
32
  } from 'viem';
33
+ import { toAccount } from 'viem/accounts';
31
34
  import { Chain, MAX_REFERRAL_BPS, NATIVE, type Referrer, type Token, erc20Abi, tokenAbi } from './chain.js';
32
- import { type Ctx, type SpendKey, chainCtx, chainState, owner, switched, walletOn, walletSees } from './context.js';
35
+ import {
36
+ type ChainCtx,
37
+ type Ctx,
38
+ type SpendKey,
39
+ chainCtx,
40
+ chainState,
41
+ owner,
42
+ switched,
43
+ walletOn,
44
+ walletSees,
45
+ } from './context.js';
33
46
  import { burnAddressFor, core, initCore } from './core.js';
34
47
  import { type ErrorCode, WalletError, fail, guard } from './errors.js';
35
48
  import { type Network, checkNetwork } from './network.js';
@@ -79,6 +92,7 @@ import {
79
92
  topUpBurns,
80
93
  unusedBurn,
81
94
  } from './sync.js';
95
+ import { decodeViewKey, encodeViewKey } from './viewKey.js';
82
96
 
83
97
  /** The ledger's bound outside an emergency. */
84
98
  const NO_BOUND = 2n ** 64n - 1n;
@@ -106,6 +120,11 @@ export interface OpenOptions {
106
120
  referrer?: Referrer;
107
121
  }
108
122
 
123
+ export interface WatchOptions extends Pick<OpenOptions, 'network' | 'persist' | 'birthday'> {
124
+ /** The account's view key, from its wallet's `viewKey()`. */
125
+ viewKey: string;
126
+ }
127
+
109
128
  /** What a deposit, or a deposit address, waits on: for an app to show while it does. */
110
129
  export type DepositStep =
111
130
  /** Behind another call of this wallet: a sync, or another operation. */
@@ -253,6 +272,7 @@ export class Wallet {
253
272
  #release = () => {};
254
273
  #referrer: Referrer | undefined;
255
274
  readonly #unlock: (() => Promise<Uint8Array>) | undefined;
275
+ #readOnly = false;
256
276
  #closed = new AbortController();
257
277
 
258
278
  private constructor(ctx: Ctx, unlock?: () => Promise<Uint8Array>) {
@@ -272,6 +292,18 @@ export class Wallet {
272
292
  return this.#ctx.address;
273
293
  }
274
294
 
295
+ /** The account's view key, for `watch` to follow it with on another device: whoever holds it sees what this wallet
296
+ * shows, except the private payments to receive codes, and can spend none of it. Checksummed text (bech32m,
297
+ * `zerc20view1…`). */
298
+ viewKey(): string {
299
+ return encodeViewKey(this.#ctx.view, owner(this.#ctx)!);
300
+ }
301
+
302
+ /** Whether this wallet only watches its account (`watch`): it shows what the account holds and spends nothing. */
303
+ get readOnly(): boolean {
304
+ return this.#readOnly;
305
+ }
306
+
275
307
  /** Opens the wallet. The first time on this address, registers it for payments: one signature. */
276
308
  static async open(opts: OpenOptions): Promise<Wallet> {
277
309
  checkNetwork(opts.network);
@@ -301,12 +333,7 @@ export class Wallet {
301
333
 
302
334
  static async #open(opts: OpenOptions, seed: Uint8Array, account: Account, release: () => void): Promise<Wallet> {
303
335
  const address = account.address;
304
- const chains = opts.network.chains.map((config) => ({
305
- config,
306
- chain: new Chain(config),
307
- indexer: new Indexer(config.indexer),
308
- asp: new Asp(config.asp),
309
- }));
336
+ const chains = chainsOf(opts.network);
310
337
  const code = await chains[0]?.chain.client.getCode({ address });
311
338
  // an EIP-7702 delegated account is still an EOA that signs
312
339
  if (code && code !== '0x' && !code.startsWith('0xef0100'))
@@ -327,39 +354,99 @@ export class Wallet {
327
354
  const restoring =
328
355
  !saved?.length && opts.birthday === undefined && !!(await storage.registration(hexToBytes(address)));
329
356
  const view = await registeredViewKey(storage, seed, signer, hexToBytes(address), publicKey);
330
- const key = stateKey(seed);
357
+ const wallet = await Wallet.#begin(
358
+ {
359
+ network: opts.network,
360
+ seed,
361
+ owners: new Map(),
362
+ address,
363
+ account,
364
+ walletClient: opts.walletClient ?? null,
365
+ storage,
366
+ view,
367
+ chains,
368
+ },
369
+ { persist: opts.persist, saved, key: stateKey(seed), from: restoring ? 0n : opts.birthday },
370
+ typeof opts.seed === 'function' ? opts.seed : undefined,
371
+ );
372
+ wallet.#release = release;
373
+ wallet.#referrer = opts.referrer;
374
+ return wallet;
375
+ }
376
+
377
+ /** Opens an account to follow by its view key (`viewKey()`): its balances, coins and history as its own wallet shows
378
+ * them, less the private payments to its receive codes, whose keys only the seed derives. It spends and writes
379
+ * nothing: a call that would fails with `read-only`. The key must be the account's current one on this network. */
380
+ static async watch(opts: WatchOptions): Promise<Wallet> {
381
+ checkNetwork(opts.network);
382
+ await initCore();
383
+ const { view, owner } = decodeViewKey(opts.viewKey) ?? fail('invalid', 'not a view key');
384
+ const address = getAddress(bytesToHex(view.address));
385
+ const release = await lockWallet(address, 'view');
386
+ try {
387
+ const storage = await CanisterClient.connect(opts.network.storage.host, opts.network.storage.canisterId);
388
+ // what the account signed: a nullifying key that is not the account's would show spent coins as unspent
389
+ const r = await lookupRecipient(storage, view.address);
390
+ if (!r || bytesToHex(r.publicKey) !== owner.publicKey || bytesToHex(r.notifyKey) !== bytesToHex(notifyKey(view)))
391
+ fail('invalid', `not ${address}'s view key on ${opts.network.name}, or one it has replaced`);
392
+ const wallet = await Wallet.#begin(
393
+ {
394
+ network: opts.network,
395
+ seed: null,
396
+ owners: new Map([['', owner]]),
397
+ address,
398
+ // signs nothing: every call that would send is refused first
399
+ account: toAccount(address),
400
+ walletClient: null,
401
+ storage,
402
+ view,
403
+ chains: chainsOf(opts.network),
404
+ },
405
+ // registered, so its past is looked for
406
+ {
407
+ persist: opts.persist,
408
+ saved: await opts.persist.load(),
409
+ key: stateKey(view.seed),
410
+ from: opts.birthday ?? 0n,
411
+ },
412
+ );
413
+ wallet.#release = release;
414
+ wallet.#readOnly = true;
415
+ return wallet;
416
+ } catch (e) {
417
+ release();
418
+ throw e;
419
+ }
420
+ }
421
+
422
+ /** The wallet over `base`, its state opened from what `persist` saved, with `key`, or started `from` a block or date. */
423
+ static async #begin(
424
+ base: Omit<Ctx, 'state' | 'save' | 'lastFullRefreshMs'>,
425
+ {
426
+ persist,
427
+ saved,
428
+ key,
429
+ from,
430
+ }: { persist: OpenOptions['persist']; saved: Uint8Array | null | undefined; key: Uint8Array; from?: bigint | Date },
431
+ unlock?: () => Promise<Uint8Array>,
432
+ ): Promise<Wallet> {
331
433
  const loaded = saved?.length ? openState(key, saved) : null;
332
434
  // never started over in place: the first save would overwrite what it held, operations in flight included
333
435
  if (saved?.length && !loaded) {
334
436
  fail(
335
437
  'invalid',
336
- 'the saved state does not open with this seed (another seed, a newer version, or damaged): clear it to start over, which finds what it held again',
438
+ 'the saved state does not open with this key (another seed or view key, a newer version, or damaged): clear it to start over, which finds what it held again',
337
439
  );
338
440
  }
339
- if (loaded && loaded.address.toLowerCase() !== address.toLowerCase())
441
+ if (loaded && loaded.address.toLowerCase() !== base.address.toLowerCase())
340
442
  fail('invalid', `the saved state is ${loaded.address}'s: keep one per address`);
341
- const state: WalletState = loaded ?? emptyWalletState(address);
342
- const ctx: Ctx = {
343
- network: opts.network,
344
- seed,
345
- owners: new Map(),
346
- address,
347
- account,
348
- walletClient: opts.walletClient ?? null,
349
- storage,
350
- view,
351
- chains,
352
- state,
353
- save: () => opts.persist.save(sealState(key, state)),
354
- lastFullRefreshMs: 0,
355
- };
356
- if (!loaded) await start(ctx, restoring ? 0n : opts.birthday);
357
- const wallet = new Wallet(ctx, typeof opts.seed === 'function' ? opts.seed : undefined);
358
- wallet.#release = release;
359
- wallet.#referrer = opts.referrer;
443
+ const state: WalletState = loaded ?? emptyWalletState(base.address);
444
+ const ctx: Ctx = { ...base, state, save: () => persist.save(sealState(key, state)), lastFullRefreshMs: 0 };
445
+ if (!loaded) await start(ctx, from);
446
+ const wallet = new Wallet(ctx, unlock);
360
447
  // a saved state names its tokens by id, and its coins stand by the chains' roots: read both now, so balances, coins
361
448
  // and history work before the first sync
362
- const [tokens] = await Promise.all([Promise.all(chains.map((c) => c.chain.tokens())), wallet.#readRoots()]);
449
+ const [tokens] = await Promise.all([Promise.all(ctx.chains.map((c) => c.chain.tokens())), wallet.#readRoots()]);
363
450
  wallet.#tokens = tokens.flat();
364
451
  keyAll(ctx, wallet.#tokens);
365
452
  return wallet;
@@ -391,10 +478,16 @@ export class Wallet {
391
478
  * anything else (a browser shows a passkey prompt only in the click that asked), and queued in the step that took it,
392
479
  * so that a sync ending meanwhile does not let it go. */
393
480
  async #seeded<T>(f: () => Promise<T>, needs = true): Promise<T> {
481
+ this.#writable();
394
482
  if (needs && !this.#ctx.seed) this.#hold(await this.#unlock!());
395
483
  return this.#exclusive(f);
396
484
  }
397
485
 
486
+ /** Refuses a call that spends or writes to storage, in a wallet that watches. */
487
+ #writable(): void {
488
+ if (this.#readOnly) fail('read-only', 'this wallet watches its account by its view key: open it with its seed');
489
+ }
490
+
398
491
  /** Runs `f` after everything queued before it: one state, changed by one task at a time. */
399
492
  #exclusive<T>(f: () => Promise<T>): Promise<T> {
400
493
  this.#busy++;
@@ -435,8 +528,9 @@ export class Wallet {
435
528
  return new Promise((resolve, reject) => {
436
529
  this.#exclusive(async () => {
437
530
  await this.#catchUp().then(resolve, reject);
438
- // still this task's: a deposit queued meanwhile finds the one recorded ahead, rather than writing at handout
439
- await topUpBurns(this.#ctx, this.#closed.signal).catch(() => {}); // tried again after the next sync
531
+ // still this task's: a deposit queued meanwhile finds the one recorded ahead, rather than writing at handout;
532
+ // tried again after the next sync. A wallet that watches records none, as it writes nothing.
533
+ if (!this.#readOnly) await topUpBurns(this.#ctx, this.#closed.signal).catch(() => {});
440
534
  this.#dropSeed();
441
535
  }).catch(reject); // closed before it ran
442
536
  });
@@ -818,6 +912,7 @@ export class Wallet {
818
912
  * addresses. */
819
913
  posKey(): Promise<Hex> {
820
914
  return this.#exclusive(async () => {
915
+ this.#writable();
821
916
  const ctx = this.#ctx;
822
917
  const tokens = await tokenIds(ctx);
823
918
  let n = 0;
@@ -951,6 +1046,7 @@ export class Wallet {
951
1046
  from?: WalletClient;
952
1047
  onStep?: (step: DepositStep) => void;
953
1048
  }): Promise<Operation> {
1049
+ this.#writable();
954
1050
  if (opts.amount <= 0n) fail('invalid', 'the amount must be positive');
955
1051
  const payer = opts.from ? (opts.from.account ?? fail('invalid', 'the wallet client has no account')) : null;
956
1052
  const step = opts.onStep ?? (() => {});
@@ -1091,6 +1187,7 @@ export class Wallet {
1091
1187
  const step = opts.onStep ?? (() => {});
1092
1188
  if (this.#busy > 0) step('queued');
1093
1189
  return this.#exclusive(async () => {
1190
+ this.#writable();
1094
1191
  const c = chainCtx(this.#ctx, token.chainId);
1095
1192
  const record = await this.#burnAddress(token, null, () => step('burn-address'));
1096
1193
  const { depositAddress, fee } = await registerDeposit(
@@ -1315,15 +1412,25 @@ export class Wallet {
1315
1412
  }
1316
1413
  }
1317
1414
 
1415
+ /** Each chain of `network`, with its servers. */
1416
+ const chainsOf = (network: Network): ChainCtx[] =>
1417
+ network.chains.map((config) => ({
1418
+ config,
1419
+ chain: new Chain(config),
1420
+ indexer: new Indexer(config.indexer),
1421
+ asp: new Asp(config.asp),
1422
+ }));
1423
+
1318
1424
  /** Holds `address`'s wallet until the returned function is called: two tabs on one wallet would each overwrite the
1319
- * other's state and spend the same notes. Nothing where there are no Web Locks. */
1320
- export async function lockWallet(address: Address): Promise<() => void> {
1425
+ * other's state and spend the same notes. A wallet that watches it (`view`) is held apart, as its state is its own.
1426
+ * Nothing where there are no Web Locks. */
1427
+ export async function lockWallet(address: Address, kind: 'wallet' | 'view' = 'wallet'): Promise<() => void> {
1321
1428
  const locks = (globalThis as { navigator?: Navigator }).navigator?.locks;
1322
1429
  if (!locks) return () => {};
1323
1430
  let release = () => {};
1324
1431
  const held = new Promise<void>((r) => (release = r));
1325
1432
  const got = await new Promise<boolean>((resolve) => {
1326
- void locks.request(`zerc20-wallet:${address.toLowerCase()}`, { ifAvailable: true }, (lock) => {
1433
+ void locks.request(`zerc20-${kind}:${address.toLowerCase()}`, { ifAvailable: true }, (lock) => {
1327
1434
  resolve(lock !== null);
1328
1435
  return lock ? held : undefined;
1329
1436
  });
@@ -73,6 +73,12 @@ export function path(i: bigint, n: bigint, chunk_leaves: Uint8Array, page_chunks
73
73
  */
74
74
  export function pathRoot(leaf: string, i: bigint, siblings: string[]): string;
75
75
 
76
+ /**
77
+ * The public key a signing key's hash and a nullifying key make: how a view key's holder checks that the nullifying
78
+ * key it was given is the one the account's notes commit to.
79
+ */
80
+ export function publicKey(auth_key_hash: string, nullifying_key: string): string;
81
+
76
82
  /**
77
83
  * Seals a merge witness (JSON) to the merge prover's key, padded with spaces to `pad_to` bytes. Take the key from
78
84
  * `verifyAttestation`, as for the attester's.
@@ -138,6 +144,7 @@ export interface InitOutput {
138
144
  readonly nullifier: (a: bigint, b: bigint, c: number, d: number) => [number, number, number, number];
139
145
  readonly path: (a: bigint, b: bigint, c: number, d: number, e: number, f: number, g: number, h: number, i: number, j: number, k: number, l: number) => [number, number, number, number];
140
146
  readonly pathRoot: (a: number, b: number, c: bigint, d: number, e: number) => [number, number, number, number];
147
+ readonly publicKey: (a: number, b: number, c: number, d: number) => [number, number, number, number];
141
148
  readonly sealMergeWitness: (a: number, b: number, c: number, d: number, e: number) => [number, number, number, number];
142
149
  readonly sealWitness: (a: number, b: number, c: number, d: number, e: number) => [number, number, number, number];
143
150
  readonly signMerge: (a: number, b: number, c: number, d: number) => [number, number, number, number];
@@ -324,6 +324,36 @@ export function pathRoot(leaf, i, siblings) {
324
324
  }
325
325
  }
326
326
 
327
+ /**
328
+ * The public key a signing key's hash and a nullifying key make: how a view key's holder checks that the nullifying
329
+ * key it was given is the one the account's notes commit to.
330
+ * @param {string} auth_key_hash
331
+ * @param {string} nullifying_key
332
+ * @returns {string}
333
+ */
334
+ export function publicKey(auth_key_hash, nullifying_key) {
335
+ let deferred4_0;
336
+ let deferred4_1;
337
+ try {
338
+ const ptr0 = passStringToWasm0(auth_key_hash, wasm.__wbindgen_malloc, wasm.__wbindgen_realloc);
339
+ const len0 = WASM_VECTOR_LEN;
340
+ const ptr1 = passStringToWasm0(nullifying_key, wasm.__wbindgen_malloc, wasm.__wbindgen_realloc);
341
+ const len1 = WASM_VECTOR_LEN;
342
+ const ret = wasm.publicKey(ptr0, len0, ptr1, len1);
343
+ var ptr3 = ret[0];
344
+ var len3 = ret[1];
345
+ if (ret[3]) {
346
+ ptr3 = 0; len3 = 0;
347
+ throw takeFromExternrefTable0(ret[2]);
348
+ }
349
+ deferred4_0 = ptr3;
350
+ deferred4_1 = len3;
351
+ return getStringFromWasm0(ptr3, len3);
352
+ } finally {
353
+ wasm.__wbindgen_free(deferred4_0, deferred4_1, 1);
354
+ }
355
+ }
356
+
327
357
  /**
328
358
  * Seals a merge witness (JSON) to the merge prover's key, padded with spaces to `pad_to` bytes. Take the key from
329
359
  * `verifyAttestation`, as for the attester's.
Binary file
@@ -14,6 +14,7 @@ export const noteTag: (a: number, b: number, c: number) => [number, number, numb
14
14
  export const nullifier: (a: bigint, b: bigint, c: number, d: number) => [number, number, number, number];
15
15
  export const path: (a: bigint, b: bigint, c: number, d: number, e: number, f: number, g: number, h: number, i: number, j: number, k: number, l: number) => [number, number, number, number];
16
16
  export const pathRoot: (a: number, b: number, c: bigint, d: number, e: number) => [number, number, number, number];
17
+ export const publicKey: (a: number, b: number, c: number, d: number) => [number, number, number, number];
17
18
  export const sealMergeWitness: (a: number, b: number, c: number, d: number, e: number) => [number, number, number, number];
18
19
  export const sealWitness: (a: number, b: number, c: number, d: number, e: number) => [number, number, number, number];
19
20
  export const signMerge: (a: number, b: number, c: number, d: number) => [number, number, number, number];