@zerc20/wallet-sdk 0.5.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.
package/dist/wallet.js CHANGED
@@ -8,15 +8,33 @@ import { chainCtx, chainState, owner, refundSigner, switched, walletOn, walletSe
8
8
  import { burnAddressFor, core, initCore } from './core.js';
9
9
  import { WalletError, fail, guard } from './errors.js';
10
10
  import { checkNetwork } from './network.js';
11
+ import { preload } from './prove.js';
11
12
  import { Asp, Indexer, PAGE } from './servers.js';
12
13
  import { flushDepositAddress, rageExit, refundDepositAddress } from './rageExit.js';
13
- import { registerDeposit, relayerFees, rewatchDeposit } from './services.js';
14
- import { advanceSpend, bpsOf, chosenSettling, coins, grossed, inputsOf, landed, maxPayment, mergePool, mergeRound, payWith, planStep, roundsToMerge, settling, withdrawalOf, } from './spend.js';
14
+ import { Relaying, registerDeposit, relayerFees, rewatchDeposit } from './services.js';
15
+ import { advanceSpend, bpsOf, cancellable, chosenSettling, coins, grossed, inputsOf, landed, maxPayment, mergePool, mergeRound, payWith, planStep, roundsToMerge, settling, withdrawalOf, } from './spend.js';
15
16
  import { emptyWalletState, openState, sealState, stateKey, unspent, } from './state.js';
16
17
  import { WAIT_MS, commitmentTx, keyAll, nextBurn, recordBurn, storageState, syncAll, takeRefresh, tokenIds, topUpBurns, unusedBurn, } from './sync.js';
17
18
  import { decodeViewKey, encodeViewKey } from './viewKey.js';
18
19
  /** The ledger's bound outside an emergency. */
19
20
  const NO_BOUND = 2n ** 64n - 1n;
21
+ /** A coin on its way to spendable by itself: screening or settling, which `readyBy` has an estimate for. */
22
+ const onItsWay = (c) => c.state === 'screening' || c.state === 'awaiting-root';
23
+ const total = (coins) => coins.reduce((s, c) => s + c.amount, 0n);
24
+ /** Of `coins`, the one whose turning spendable makes them `need`, the soonest first; null where they never do, or the
25
+ * one it takes has no estimate. */
26
+ export function waitsOn(coins, need) {
27
+ const at = (c) => c.readyBy?.getTime() ?? Infinity;
28
+ // two coins with no estimate compare as equal, not as NaN
29
+ const soonest = [...coins].sort((a, b) => Math.sign(at(a) - at(b)) || 0);
30
+ let left = need;
31
+ for (const c of soonest) {
32
+ left -= c.amount;
33
+ if (left <= 0n)
34
+ return c.readyBy ? c : null;
35
+ }
36
+ return null;
37
+ }
20
38
  export class Wallet {
21
39
  #ctx;
22
40
  #tokens = [];
@@ -24,21 +42,30 @@ export class Wallet {
24
42
  #queue = Promise.resolve();
25
43
  /** Calls running or queued. */
26
44
  #busy = 0;
27
- #release = () => { };
45
+ #lock;
28
46
  #referrer;
29
47
  #unlock;
30
48
  #readOnly = false;
31
49
  #closed = new AbortController();
32
- constructor(ctx, unlock) {
50
+ /** A wallet over `ctx`, held by `lock`: by default held nowhere, as where there are no Web Locks. */
51
+ constructor(ctx, unlock, lock = { release: () => { }, taken: new AbortController().signal, guard: (f) => f() }) {
33
52
  this.#ctx = ctx;
34
53
  this.#unlock = unlock;
54
+ this.#lock = lock;
55
+ lock.taken.addEventListener('abort', () => this.#closed.abort(lock.taken.reason));
35
56
  }
36
- /** Lets this wallet open again: in another tab, or here for another account. This object is done with: what is
57
+ /** Lets this wallet go, for another tab or another account here. This object is done with: what is
37
58
  * queued fails with `wallet-closed`, a burn address search stops, and the wallet is let go once the task under way
38
59
  * ends, so that two never write its state at once. */
39
60
  close() {
40
61
  this.#closed.abort(new WalletError('wallet-closed', 'this wallet was closed: open it again'));
41
- return this.#queue.then(() => this.#release());
62
+ return this.#queue.then(() => this.#lock.release());
63
+ }
64
+ /** Aborts once another tab or window takes this wallet (`take`), which closes it here: what is queued fails with
65
+ * `wallet-taken`, a burn address search stops, and the task under way saves nothing more, as the other tab reads the
66
+ * state and writes it now. */
67
+ get taken() {
68
+ return this.#lock.taken;
42
69
  }
43
70
  get address() {
44
71
  return this.#ctx.address;
@@ -73,16 +100,16 @@ export class Wallet {
73
100
  if (seed.length !== 32)
74
101
  fail('invalid', 'the seed is 32 bytes');
75
102
  await initCore();
76
- const release = await lockWallet(account.address);
103
+ const hold = await lockWallet(account.address, 'wallet', opts.take);
77
104
  try {
78
- return await Wallet.#open(opts, seed, account, release);
105
+ return await Wallet.#open(opts, seed, account, hold);
79
106
  }
80
107
  catch (e) {
81
- release();
108
+ hold.release();
82
109
  throw e;
83
110
  }
84
111
  }
85
- static async #open(opts, seed, account, release) {
112
+ static async #open(opts, seed, account, hold) {
86
113
  const address = account.address;
87
114
  const chains = chainsOf(opts.network);
88
115
  const code = await chains[0]?.chain.client.getCode({ address });
@@ -94,7 +121,7 @@ export class Wallet {
94
121
  : opts.walletClient.signMessage({ account, message })));
95
122
  const storage = await CanisterClient.connect(opts.network.storage.host, opts.network.storage.canisterId);
96
123
  const publicKey = hexToBytes(core.spendKey(seed).publicKey);
97
- const saved = await opts.persist.load();
124
+ const saved = await hold.guard(() => opts.persist.load());
98
125
  // registered already: used before, here or on another device, so its past is looked for
99
126
  // ponytail: from genesis (~4 MB per million commitments); ask for a birthday once the chain has many
100
127
  const restoring = !saved?.length && opts.birthday === undefined && !!(await storage.registration(hexToBytes(address)));
@@ -110,8 +137,7 @@ export class Wallet {
110
137
  storage,
111
138
  view,
112
139
  chains,
113
- }, { persist: opts.persist, saved, key: stateKey(seed), from: restoring ? 0n : opts.birthday }, typeof opts.seed === 'function' ? opts.seed : undefined);
114
- wallet.#release = release;
140
+ }, { persist: opts.persist, saved, key: stateKey(seed), from: restoring ? 0n : opts.birthday, hold }, typeof opts.seed === 'function' ? opts.seed : undefined);
115
141
  wallet.#referrer = opts.referrer;
116
142
  return wallet;
117
143
  }
@@ -123,7 +149,7 @@ export class Wallet {
123
149
  await initCore();
124
150
  const { view, owner } = decodeViewKey(opts.viewKey) ?? fail('invalid', 'not a view key');
125
151
  const address = getAddress(bytesToHex(view.address));
126
- const release = await lockWallet(address, 'view');
152
+ const hold = await lockWallet(address, 'view');
127
153
  try {
128
154
  const storage = await CanisterClient.connect(opts.network.storage.host, opts.network.storage.canisterId);
129
155
  // what the account signed: a nullifying key that is not the account's would show spent coins as unspent
@@ -146,21 +172,22 @@ export class Wallet {
146
172
  // registered, so its past is looked for
147
173
  {
148
174
  persist: opts.persist,
149
- saved: await opts.persist.load(),
175
+ saved: await hold.guard(() => opts.persist.load()),
150
176
  key: stateKey(view.seed),
151
177
  from: opts.birthday ?? 0n,
178
+ hold,
152
179
  });
153
- wallet.#release = release;
154
180
  wallet.#readOnly = true;
155
181
  return wallet;
156
182
  }
157
183
  catch (e) {
158
- release();
184
+ hold.release();
159
185
  throw e;
160
186
  }
161
187
  }
162
- /** The wallet over `base`, its state opened from what `persist` saved, with `key`, or started `from` a block or date. */
163
- static async #begin(base, { persist, saved, key, from, }, unlock) {
188
+ /** The wallet over `base`, its state opened from what `persist` saved, with `key`, or started `from` a block or date,
189
+ * as long as `hold` holds it. */
190
+ static async #begin(base, { persist, saved, key, from, hold, }, unlock) {
164
191
  const loaded = saved?.length ? openState(key, saved) : null;
165
192
  // never started over in place: the first save would overwrite what it held, operations in flight included
166
193
  if (saved?.length && !loaded) {
@@ -169,15 +196,18 @@ export class Wallet {
169
196
  if (loaded && loaded.address.toLowerCase() !== base.address.toLowerCase())
170
197
  fail('invalid', `the saved state is ${loaded.address}'s: keep one per address`);
171
198
  const state = loaded ?? emptyWalletState(base.address);
172
- const ctx = { ...base, state, save: () => persist.save(sealState(key, state)), lastFullRefreshMs: 0 };
199
+ const save = () => hold.guard(() => persist.save(sealState(key, state)));
200
+ const ctx = { ...base, state, save, lastFullRefreshMs: 0 };
173
201
  if (!loaded)
174
202
  await start(ctx, from);
175
- const wallet = new Wallet(ctx, unlock);
203
+ const wallet = new Wallet(ctx, unlock, hold);
176
204
  // a saved state names its tokens by id, and its coins stand by the chains' roots: read both now, so balances, coins
177
205
  // and history work before the first sync
178
206
  const [tokens] = await Promise.all([Promise.all(ctx.chains.map((c) => c.chain.tokens())), wallet.#readRoots()]);
179
207
  wallet.#tokens = tokens.flat();
180
208
  keyAll(ctx, wallet.#tokens);
209
+ // taken while it opened, before it listened
210
+ hold.taken.throwIfAborted();
181
211
  return wallet;
182
212
  }
183
213
  /** Holds `seed`, asked of `#unlock`, once it is checked to be this wallet's. */
@@ -275,6 +305,10 @@ export class Wallet {
275
305
  delete op.retrying;
276
306
  }
277
307
  catch (e) {
308
+ // cancelled while it ran: it stays so. And a relayer relaying its request already answers the request that
309
+ // started it, so whether a service keeps failing stays as the tries before found it.
310
+ if (op.status !== 'pending' || e instanceof Relaying)
311
+ continue;
278
312
  // only what trying again cannot change fails an operation: a server or an RPC that does not answer (any
279
313
  // error that is not the wallet's own), or answers what the chain does not show yet, is tried next sync. It is
280
314
  // kept on the operation, so that one that never gets past it does not look merely slow.
@@ -358,6 +392,12 @@ export class Wallet {
358
392
  coins() {
359
393
  const size = (chainId) => this.#status.get(chainId)?.latestRoot?.size ?? 0;
360
394
  const unproved = (chainId) => (this.#status.get(chainId)?.halted ? 'roots-halted' : 'awaiting-root');
395
+ // when a root should cover a coin that landed at `atMs`
396
+ const covered = (chainId, atMs) => {
397
+ const secs = this.#status.get(chainId)?.settleSecs ?? null;
398
+ return atMs === undefined || secs === null ? null : atMs + secs * 1000;
399
+ };
400
+ const date = (ms) => (ms === null ? null : new Date(ms));
361
401
  const spending = new Set(this.#ctx.state.ops.flatMap((o) => inputsOf(o).map((c) => `${o.chainId}:${c.index}`)));
362
402
  // spent by a JoinSplit in a block, which waits only to be final: gone, as activity() counts it. Sync sees it by its
363
403
  // nullifier (`unspent`); this device's own is gone already from its relay on (a reorg that drops it brings them
@@ -382,7 +422,7 @@ export class Wallet {
382
422
  state,
383
423
  from: n.peer,
384
424
  via: null,
385
- screenedBy: null,
425
+ readyBy: state === 'awaiting-root' ? date(covered(n.chainId, f.atMs)) : null,
386
426
  at: f.atMs === undefined ? null : new Date(f.atMs),
387
427
  });
388
428
  }
@@ -402,7 +442,13 @@ export class Wallet {
402
442
  : f.index >= size(d.chainId)
403
443
  ? unproved(d.chainId)
404
444
  : 'spendable';
405
- const screenedBy = state === 'screening' && f.decidedByMs !== undefined ? new Date(f.decidedByMs) : null;
445
+ // a root covers a deposit while it is screened, most often before its check ends
446
+ const settles = covered(d.chainId, f.atMs);
447
+ const readyBy = state === 'screening'
448
+ ? date(f.decidedByMs === undefined ? null : Math.max(f.decidedByMs, settles ?? 0))
449
+ : state === 'awaiting-root'
450
+ ? date(settles)
451
+ : null;
406
452
  out.push({
407
453
  id,
408
454
  kind: 'deposit',
@@ -411,7 +457,7 @@ export class Wallet {
411
457
  state,
412
458
  from: f.sender,
413
459
  via: this.#via(d, f.sender),
414
- screenedBy,
460
+ readyBy,
415
461
  at: f.atMs === undefined ? null : new Date(f.atMs),
416
462
  });
417
463
  }
@@ -732,6 +778,9 @@ export class Wallet {
732
778
  return op.kind === 'deposit' ? null : (op.data.step?.stage ?? null);
733
779
  },
734
780
  to: op.kind === 'deposit' ? (op.data.to ?? null) : null,
781
+ get cancellable() {
782
+ return cancellable(op);
783
+ },
735
784
  wait: async () => {
736
785
  for (;;) {
737
786
  if (op.status === 'done')
@@ -743,6 +792,31 @@ export class Wallet {
743
792
  await new Promise((r) => setTimeout(r, 15_000));
744
793
  }
745
794
  },
795
+ // at once, outside the queue: a sync under way may wait long on a service that does not answer, and a page closed
796
+ // before a queued save would find the operation pending and relay it. That sync starts no further stage of it.
797
+ cancel: async () => {
798
+ if (op.errorCode === 'cancelled')
799
+ return;
800
+ if (!cancellable(op))
801
+ fail('invalid', op.status === 'pending'
802
+ ? 'it has a request out to the relayer, or has paid: it finishes or fails on its own'
803
+ : `it is ${op.status}`);
804
+ const ops = this.#ctx.state.ops;
805
+ const at = ops.indexOf(op);
806
+ if (at < 0)
807
+ fail('invalid', 'this wallet no longer holds it');
808
+ ops.splice(at, 1);
809
+ Object.assign(op, { status: 'failed', errorCode: 'cancelled', error: 'cancelled' });
810
+ try {
811
+ await this.#ctx.save();
812
+ }
813
+ catch (e) {
814
+ // not saved, so not cancelled: the saved state would resume it
815
+ Object.assign(op, { status: 'pending', errorCode: undefined, error: undefined });
816
+ ops.splice(at, 0, op);
817
+ throw e;
818
+ }
819
+ },
746
820
  };
747
821
  }
748
822
  #newOp(kind, token, amount, data) {
@@ -1028,7 +1102,11 @@ export class Wallet {
1028
1102
  if (opts.kind === 'consolidate') {
1029
1103
  const chosen = opts.coins && this.#chosen(token, opts.coins);
1030
1104
  if (chosen && chosenSettling(this.#ctx, token.chainId, chosen, all)) {
1031
- fail('funds-settling', 'a chosen note or deposit is still settling: quote again once coins() shows it spendable');
1105
+ // all of them must be spendable: the last one on its way, where each is a coin with an estimate
1106
+ const ids = new Set(opts.coins);
1107
+ const picked = this.coins().filter((c) => ids.has(c.id));
1108
+ const waiting = picked.filter((c) => c.state !== 'spendable');
1109
+ throw new WalletError('funds-settling', 'a chosen note or deposit is still settling: quote again once coins() shows it spendable', picked.length === ids.size && waiting.every(onItsWay) ? waitsOn(waiting, total(waiting)) : null);
1032
1110
  }
1033
1111
  const steps = BigInt(roundsToMerge(mergePool(all, chosen)));
1034
1112
  // a merge pays the relayer publicly, so the protocol takes its cut of that, grossed up as a payment's is
@@ -1045,9 +1123,25 @@ export class Wallet {
1045
1123
  const { withdrawn, protocol } = await withdrawalOf(this.#ctx, token.chainId, token.tokenId, opts.kind === 'withdraw', amount, relayer);
1046
1124
  const onTheWay = this.#ctx.state.ops.some((o) => o.status === 'pending') ||
1047
1125
  settling(this.#ctx, token.chainId, token.tokenId, size);
1048
- payWith(all, amount + relayer + protocol, relayer, onTheWay);
1126
+ const need = amount + relayer + protocol;
1127
+ try {
1128
+ payWith(all, need, relayer, onTheWay);
1129
+ }
1130
+ catch (e) {
1131
+ if (!(e instanceof WalletError) || e.code !== 'funds-settling')
1132
+ throw e;
1133
+ throw new WalletError(e.code, e.message, this.#waitsFor(token, need));
1134
+ }
1049
1135
  return { relayer, protocol, protocolBps: bpsOf(protocol, withdrawn), fee: relayer + protocol, steps: 1 };
1050
1136
  }
1137
+ /** Of `token`'s coins on their way, the one whose turning spendable lets a payment of `need` go, as
1138
+ * `WalletError.waitsOn` tells. None while an operation is in flight, as its change is not a coin yet. */
1139
+ #waitsFor(token, need) {
1140
+ if (this.#ctx.state.ops.some((o) => o.status === 'pending'))
1141
+ return null;
1142
+ const mine = this.coins().filter((c) => c.token.chainId === token.chainId && c.token.tokenId === token.tokenId);
1143
+ return waitsOn(mine.filter(onItsWay), need - total(mine.filter((c) => c.state === 'spendable')));
1144
+ }
1051
1145
  /** The most a send or withdrawal of `token` can pay: what is spendable, less the fees `fees` quotes on top of it, and
1052
1146
  * the merges first when one JoinSplit cannot spend it all, which `fees` then asks for (`needs-consolidation`). */
1053
1147
  async maxAmount(opts) {
@@ -1068,6 +1162,11 @@ export class Wallet {
1068
1162
  const merge = mergeRound(coins(this.#ctx, token.chainId, token.tokenId, size), fee);
1069
1163
  return merge && merge.map((m) => `${token.chainId}:${m.index}`);
1070
1164
  }
1165
+ /** Fetches what a send or withdrawal proves with, some 25 MB, once a page: an app calls it as a payment form opens,
1166
+ * so that the payment's proof does not wait for the download. Outside the queue of calls: a sync goes on meanwhile. */
1167
+ preloadProver() {
1168
+ return preload(this.#ctx.network.circuits.joinsplit);
1169
+ }
1071
1170
  /** Pays `amount` privately: to an account that has registered (any wallet that ever opened), by its address or its
1072
1171
  * zERC20 ID (`accountId`), or to a receive code (`receiveCode`). An account that never registered fails with
1073
1172
  * `recipient-not-registered`. One JoinSplit from what is spendable now, or it fails here: `needs-consolidation`,
@@ -1178,24 +1277,41 @@ const chainsOf = (network) => network.chains.map((config) => ({
1178
1277
  indexer: new Indexer(config.indexer),
1179
1278
  asp: new Asp(config.asp),
1180
1279
  }));
1181
- /** Holds `address`'s wallet until the returned function is called: two tabs on one wallet would each overwrite the
1182
- * other's state and spend the same notes. A wallet that watches it (`view`) is held apart, as its state is its own.
1183
- * Nothing where there are no Web Locks. */
1184
- export async function lockWallet(address, kind = 'wallet') {
1280
+ /** Holds `address`'s wallet: two tabs on one wallet would each overwrite the other's state and spend the same notes. It
1281
+ * fails with `wallet-in-use` while another tab or window holds it, unless it `take`s it from that one. A wallet that
1282
+ * watches it (`view`) is held apart, as its state is its own. Neither where there are no Web Locks. */
1283
+ export async function lockWallet(address, kind = 'wallet', take = false) {
1284
+ const taken = new AbortController();
1185
1285
  const locks = globalThis.navigator?.locks;
1186
1286
  if (!locks)
1187
- return () => { };
1287
+ return { release: () => { }, taken: taken.signal, guard: (f) => f() };
1288
+ const name = `zerc20-${kind}:${address.toLowerCase()}`;
1289
+ const lose = () => taken.abort(new WalletError('wallet-taken', 'this wallet was opened in another tab or window'));
1188
1290
  let release = () => { };
1189
1291
  const held = new Promise((r) => (release = r));
1190
- const got = await new Promise((resolve) => {
1191
- void locks.request(`zerc20-${kind}:${address.toLowerCase()}`, { ifAvailable: true }, (lock) => {
1192
- resolve(lock !== null);
1193
- return lock ? held : undefined;
1194
- });
1292
+ let got = false;
1293
+ await new Promise((resolve, reject) => {
1294
+ locks
1295
+ .request(name, take ? { steal: true } : { ifAvailable: true }, (lock) => {
1296
+ got = lock !== null;
1297
+ resolve();
1298
+ return got ? held : undefined;
1299
+ })
1300
+ // a held lock's request rejects as another takes it
1301
+ .catch((e) => (got ? lose() : reject(e)));
1195
1302
  });
1196
1303
  if (!got)
1197
1304
  fail('wallet-in-use', 'this wallet is open in another tab or window: close it there first');
1198
- return release;
1305
+ const state = `${name}/state`;
1306
+ const guard = (f) => locks.request(state, async () => {
1307
+ const holders = (await locks.query()).held ?? [];
1308
+ const holder = (n) => holders.find((l) => l.name === n)?.clientId;
1309
+ if (holder(name) !== holder(state))
1310
+ lose();
1311
+ taken.signal.throwIfAborted();
1312
+ return f();
1313
+ });
1314
+ return { release, taken: taken.signal, guard };
1199
1315
  }
1200
1316
  /** A deposit `deposit()` did not see through (a reload, a lost connection): settled by its transaction's receipt, or
1201
1317
  * failed if it never sent one. Runs only between tasks, so no `deposit()` is still on its way to sending it. */