@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/LICENSE +17 -20
- package/README.md +1 -1
- package/dist/chain.d.ts +6 -5
- package/dist/chain.js +8 -7
- package/dist/chain.js.map +1 -1
- package/dist/context.js +1 -1
- package/dist/context.js.map +1 -1
- package/dist/core.js +3 -2
- package/dist/core.js.map +1 -1
- package/dist/errors.d.ts +20 -3
- package/dist/errors.js +7 -1
- package/dist/errors.js.map +1 -1
- package/dist/network.d.ts +4 -2
- package/dist/network.js +3 -1
- package/dist/network.js.map +1 -1
- package/dist/pos.d.ts +3 -0
- package/dist/pos.js +6 -0
- package/dist/pos.js.map +1 -1
- package/dist/prove.d.ts +3 -0
- package/dist/prove.js +9 -0
- package/dist/prove.js.map +1 -1
- package/dist/servers.d.ts +3 -0
- package/dist/servers.js +1 -0
- package/dist/servers.js.map +1 -1
- package/dist/services.d.ts +3 -2
- package/dist/services.js +9 -7
- package/dist/services.js.map +1 -1
- package/dist/spend.d.ts +30 -9
- package/dist/spend.js +135 -58
- package/dist/spend.js.map +1 -1
- package/dist/wallet.d.ts +40 -7
- package/dist/wallet.js +153 -37
- package/dist/wallet.js.map +1 -1
- package/package.json +3 -3
- package/src/chain.ts +12 -6
- package/src/context.ts +1 -1
- package/src/core.ts +3 -2
- package/src/errors.ts +15 -2
- package/src/network.ts +6 -3
- package/src/pos.ts +7 -0
- package/src/prove.ts +10 -0
- package/src/servers.ts +5 -0
- package/src/services.ts +8 -7
- package/src/spend.ts +146 -68
- package/src/wallet.ts +192 -38
package/src/wallet.ts
CHANGED
|
@@ -49,9 +49,10 @@ import {
|
|
|
49
49
|
import { burnAddressFor, core, initCore } from './core.js';
|
|
50
50
|
import { type ErrorCode, WalletError, fail, guard } from './errors.js';
|
|
51
51
|
import { type Network, checkNetwork } from './network.js';
|
|
52
|
+
import { preload } from './prove.js';
|
|
52
53
|
import { Asp, Indexer, type IndexerStatus, PAGE } from './servers.js';
|
|
53
54
|
import { type Route, flushDepositAddress, rageExit, refundDepositAddress } from './rageExit.js';
|
|
54
|
-
import { registerDeposit, relayerFees, rewatchDeposit } from './services.js';
|
|
55
|
+
import { Relaying, registerDeposit, relayerFees, rewatchDeposit } from './services.js';
|
|
55
56
|
import {
|
|
56
57
|
type Destination,
|
|
57
58
|
type MergeStep,
|
|
@@ -59,6 +60,7 @@ import {
|
|
|
59
60
|
type Step,
|
|
60
61
|
advanceSpend,
|
|
61
62
|
bpsOf,
|
|
63
|
+
cancellable,
|
|
62
64
|
chosenSettling,
|
|
63
65
|
coins,
|
|
64
66
|
grossed,
|
|
@@ -124,6 +126,9 @@ export interface OpenOptions {
|
|
|
124
126
|
birthday?: bigint | Date;
|
|
125
127
|
/** The app opening the wallet, when it takes a share of what reaches the deposit addresses it hands out. */
|
|
126
128
|
referrer?: Referrer;
|
|
129
|
+
/** Takes the wallet from another tab or window that holds it open, whose `taken` aborts, rather than failing with
|
|
130
|
+
* `wallet-in-use`: for a user who opens it here, where the other may be a tab the browser keeps in the background. */
|
|
131
|
+
take?: boolean;
|
|
127
132
|
}
|
|
128
133
|
|
|
129
134
|
export interface WatchOptions extends Pick<OpenOptions, 'network' | 'persist' | 'birthday'> {
|
|
@@ -214,12 +219,33 @@ export interface CoinInfo {
|
|
|
214
219
|
/** The deposit address of this wallet's that a deposit came through (its `from`), as `Activity.via` names it: its
|
|
215
220
|
* `rageExit` goes where `to` says. */
|
|
216
221
|
via: { depositAddress: Address } | null;
|
|
217
|
-
/**
|
|
218
|
-
|
|
222
|
+
/** While `screening` or `awaiting-root`, when it should be spendable: the later of its AML check's deadline and when a
|
|
223
|
+
* root should cover it, by how long commitments on its chain usually wait for one. An estimate: null until there is
|
|
224
|
+
* one, and in other states. */
|
|
225
|
+
readyBy: Date | null;
|
|
219
226
|
/** When it landed: its block's time, null until read. */
|
|
220
227
|
at: Date | null;
|
|
221
228
|
}
|
|
222
229
|
|
|
230
|
+
/** A coin on its way to spendable by itself: screening or settling, which `readyBy` has an estimate for. */
|
|
231
|
+
const onItsWay = (c: CoinInfo) => c.state === 'screening' || c.state === 'awaiting-root';
|
|
232
|
+
|
|
233
|
+
const total = (coins: CoinInfo[]) => coins.reduce((s, c) => s + c.amount, 0n);
|
|
234
|
+
|
|
235
|
+
/** Of `coins`, the one whose turning spendable makes them `need`, the soonest first; null where they never do, or the
|
|
236
|
+
* one it takes has no estimate. */
|
|
237
|
+
export function waitsOn(coins: CoinInfo[], need: bigint): CoinInfo | null {
|
|
238
|
+
const at = (c: CoinInfo) => c.readyBy?.getTime() ?? Infinity;
|
|
239
|
+
// two coins with no estimate compare as equal, not as NaN
|
|
240
|
+
const soonest = [...coins].sort((a, b) => Math.sign(at(a) - at(b)) || 0);
|
|
241
|
+
let left = need;
|
|
242
|
+
for (const c of soonest) {
|
|
243
|
+
left -= c.amount;
|
|
244
|
+
if (left <= 0n) return c.readyBy ? c : null;
|
|
245
|
+
}
|
|
246
|
+
return null;
|
|
247
|
+
}
|
|
248
|
+
|
|
223
249
|
export interface DepositInfo {
|
|
224
250
|
id: string;
|
|
225
251
|
/** The burn address it was sent to. */
|
|
@@ -294,8 +320,15 @@ export interface Operation {
|
|
|
294
320
|
/** Whom a deposit pays, when `deposit({ to })` named an address; null otherwise. One to a receive code shows its code
|
|
295
321
|
* as `via` in `activity()`. */
|
|
296
322
|
readonly to: Address | null;
|
|
323
|
+
/** Whether `cancel()` can still end it: a send, withdrawal or consolidation that has paid nothing and has no request
|
|
324
|
+
* out to the relayer. It moves during a sync: read it while one runs. */
|
|
325
|
+
readonly cancellable: boolean;
|
|
297
326
|
/** Syncs until the operation is done; throws if it fails. */
|
|
298
327
|
wait(): Promise<void>;
|
|
328
|
+
/** Ends it while `cancellable`, and does nothing once it is cancelled: it paid nothing, its notes and deposits are free
|
|
329
|
+
* again, it leaves `operations()` and `activity()`, and `wait()` throws `cancelled`. Throws otherwise, and when the
|
|
330
|
+
* wallet cannot save it, which leaves it under way. */
|
|
331
|
+
cancel(): Promise<void>;
|
|
299
332
|
}
|
|
300
333
|
|
|
301
334
|
export class Wallet {
|
|
@@ -305,23 +338,37 @@ export class Wallet {
|
|
|
305
338
|
#queue: Promise<unknown> = Promise.resolve();
|
|
306
339
|
/** Calls running or queued. */
|
|
307
340
|
#busy = 0;
|
|
308
|
-
#
|
|
341
|
+
readonly #lock: Hold;
|
|
309
342
|
#referrer: Referrer | undefined;
|
|
310
343
|
readonly #unlock: (() => Promise<Uint8Array>) | undefined;
|
|
311
344
|
#readOnly = false;
|
|
312
345
|
#closed = new AbortController();
|
|
313
346
|
|
|
314
|
-
|
|
347
|
+
/** A wallet over `ctx`, held by `lock`: by default held nowhere, as where there are no Web Locks. */
|
|
348
|
+
private constructor(
|
|
349
|
+
ctx: Ctx,
|
|
350
|
+
unlock?: () => Promise<Uint8Array>,
|
|
351
|
+
lock: Hold = { release: () => {}, taken: new AbortController().signal, guard: (f) => f() },
|
|
352
|
+
) {
|
|
315
353
|
this.#ctx = ctx;
|
|
316
354
|
this.#unlock = unlock;
|
|
355
|
+
this.#lock = lock;
|
|
356
|
+
lock.taken.addEventListener('abort', () => this.#closed.abort(lock.taken.reason));
|
|
317
357
|
}
|
|
318
358
|
|
|
319
|
-
/** Lets this wallet
|
|
359
|
+
/** Lets this wallet go, for another tab or another account here. This object is done with: what is
|
|
320
360
|
* queued fails with `wallet-closed`, a burn address search stops, and the wallet is let go once the task under way
|
|
321
361
|
* ends, so that two never write its state at once. */
|
|
322
362
|
close(): Promise<void> {
|
|
323
363
|
this.#closed.abort(new WalletError('wallet-closed', 'this wallet was closed: open it again'));
|
|
324
|
-
return this.#queue.then(() => this.#release());
|
|
364
|
+
return this.#queue.then(() => this.#lock.release());
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
/** Aborts once another tab or window takes this wallet (`take`), which closes it here: what is queued fails with
|
|
368
|
+
* `wallet-taken`, a burn address search stops, and the task under way saves nothing more, as the other tab reads the
|
|
369
|
+
* state and writes it now. */
|
|
370
|
+
get taken(): AbortSignal {
|
|
371
|
+
return this.#lock.taken;
|
|
325
372
|
}
|
|
326
373
|
|
|
327
374
|
get address(): Address {
|
|
@@ -358,16 +405,16 @@ export class Wallet {
|
|
|
358
405
|
const seed = typeof opts.seed === 'function' ? await opts.seed() : opts.seed;
|
|
359
406
|
if (seed.length !== 32) fail('invalid', 'the seed is 32 bytes');
|
|
360
407
|
await initCore();
|
|
361
|
-
const
|
|
408
|
+
const hold = await lockWallet(account.address, 'wallet', opts.take);
|
|
362
409
|
try {
|
|
363
|
-
return await Wallet.#open(opts, seed, account,
|
|
410
|
+
return await Wallet.#open(opts, seed, account, hold);
|
|
364
411
|
} catch (e) {
|
|
365
|
-
release();
|
|
412
|
+
hold.release();
|
|
366
413
|
throw e;
|
|
367
414
|
}
|
|
368
415
|
}
|
|
369
416
|
|
|
370
|
-
static async #open(opts: OpenOptions, seed: Uint8Array, account: Account,
|
|
417
|
+
static async #open(opts: OpenOptions, seed: Uint8Array, account: Account, hold: Hold): Promise<Wallet> {
|
|
371
418
|
const address = account.address;
|
|
372
419
|
const chains = chainsOf(opts.network);
|
|
373
420
|
const code = await chains[0]?.chain.client.getCode({ address });
|
|
@@ -384,7 +431,7 @@ export class Wallet {
|
|
|
384
431
|
);
|
|
385
432
|
const storage = await CanisterClient.connect(opts.network.storage.host, opts.network.storage.canisterId);
|
|
386
433
|
const publicKey = hexToBytes((core.spendKey(seed) as SpendKey).publicKey);
|
|
387
|
-
const saved = await opts.persist.load();
|
|
434
|
+
const saved = await hold.guard(() => opts.persist.load());
|
|
388
435
|
// registered already: used before, here or on another device, so its past is looked for
|
|
389
436
|
// ponytail: from genesis (~4 MB per million commitments); ask for a birthday once the chain has many
|
|
390
437
|
const restoring =
|
|
@@ -403,10 +450,9 @@ export class Wallet {
|
|
|
403
450
|
view,
|
|
404
451
|
chains,
|
|
405
452
|
},
|
|
406
|
-
{ persist: opts.persist, saved, key: stateKey(seed), from: restoring ? 0n : opts.birthday },
|
|
453
|
+
{ persist: opts.persist, saved, key: stateKey(seed), from: restoring ? 0n : opts.birthday, hold },
|
|
407
454
|
typeof opts.seed === 'function' ? opts.seed : undefined,
|
|
408
455
|
);
|
|
409
|
-
wallet.#release = release;
|
|
410
456
|
wallet.#referrer = opts.referrer;
|
|
411
457
|
return wallet;
|
|
412
458
|
}
|
|
@@ -419,7 +465,7 @@ export class Wallet {
|
|
|
419
465
|
await initCore();
|
|
420
466
|
const { view, owner } = decodeViewKey(opts.viewKey) ?? fail('invalid', 'not a view key');
|
|
421
467
|
const address = getAddress(bytesToHex(view.address));
|
|
422
|
-
const
|
|
468
|
+
const hold = await lockWallet(address, 'view');
|
|
423
469
|
try {
|
|
424
470
|
const storage = await CanisterClient.connect(opts.network.storage.host, opts.network.storage.canisterId);
|
|
425
471
|
// what the account signed: a nullifying key that is not the account's would show spent coins as unspent
|
|
@@ -443,21 +489,22 @@ export class Wallet {
|
|
|
443
489
|
// registered, so its past is looked for
|
|
444
490
|
{
|
|
445
491
|
persist: opts.persist,
|
|
446
|
-
saved: await opts.persist.load(),
|
|
492
|
+
saved: await hold.guard(() => opts.persist.load()),
|
|
447
493
|
key: stateKey(view.seed),
|
|
448
494
|
from: opts.birthday ?? 0n,
|
|
495
|
+
hold,
|
|
449
496
|
},
|
|
450
497
|
);
|
|
451
|
-
wallet.#release = release;
|
|
452
498
|
wallet.#readOnly = true;
|
|
453
499
|
return wallet;
|
|
454
500
|
} catch (e) {
|
|
455
|
-
release();
|
|
501
|
+
hold.release();
|
|
456
502
|
throw e;
|
|
457
503
|
}
|
|
458
504
|
}
|
|
459
505
|
|
|
460
|
-
/** The wallet over `base`, its state opened from what `persist` saved, with `key`, or started `from` a block or date
|
|
506
|
+
/** The wallet over `base`, its state opened from what `persist` saved, with `key`, or started `from` a block or date,
|
|
507
|
+
* as long as `hold` holds it. */
|
|
461
508
|
static async #begin(
|
|
462
509
|
base: Omit<Ctx, 'state' | 'save' | 'lastFullRefreshMs'>,
|
|
463
510
|
{
|
|
@@ -465,7 +512,14 @@ export class Wallet {
|
|
|
465
512
|
saved,
|
|
466
513
|
key,
|
|
467
514
|
from,
|
|
468
|
-
|
|
515
|
+
hold,
|
|
516
|
+
}: {
|
|
517
|
+
persist: OpenOptions['persist'];
|
|
518
|
+
saved: Uint8Array | null | undefined;
|
|
519
|
+
key: Uint8Array;
|
|
520
|
+
from?: bigint | Date;
|
|
521
|
+
hold: Hold;
|
|
522
|
+
},
|
|
469
523
|
unlock?: () => Promise<Uint8Array>,
|
|
470
524
|
): Promise<Wallet> {
|
|
471
525
|
const loaded = saved?.length ? openState(key, saved) : null;
|
|
@@ -479,14 +533,17 @@ export class Wallet {
|
|
|
479
533
|
if (loaded && loaded.address.toLowerCase() !== base.address.toLowerCase())
|
|
480
534
|
fail('invalid', `the saved state is ${loaded.address}'s: keep one per address`);
|
|
481
535
|
const state: WalletState = loaded ?? emptyWalletState(base.address);
|
|
482
|
-
const
|
|
536
|
+
const save = () => hold.guard(() => persist.save(sealState(key, state)));
|
|
537
|
+
const ctx: Ctx = { ...base, state, save, lastFullRefreshMs: 0 };
|
|
483
538
|
if (!loaded) await start(ctx, from);
|
|
484
|
-
const wallet = new Wallet(ctx, unlock);
|
|
539
|
+
const wallet = new Wallet(ctx, unlock, hold);
|
|
485
540
|
// a saved state names its tokens by id, and its coins stand by the chains' roots: read both now, so balances, coins
|
|
486
541
|
// and history work before the first sync
|
|
487
542
|
const [tokens] = await Promise.all([Promise.all(ctx.chains.map((c) => c.chain.tokens())), wallet.#readRoots()]);
|
|
488
543
|
wallet.#tokens = tokens.flat();
|
|
489
544
|
keyAll(ctx, wallet.#tokens);
|
|
545
|
+
// taken while it opened, before it listened
|
|
546
|
+
hold.taken.throwIfAborted();
|
|
490
547
|
return wallet;
|
|
491
548
|
}
|
|
492
549
|
|
|
@@ -586,6 +643,9 @@ export class Wallet {
|
|
|
586
643
|
if (op.kind === 'deposit') await settleDeposit(this.#ctx, op);
|
|
587
644
|
delete op.retrying;
|
|
588
645
|
} catch (e) {
|
|
646
|
+
// cancelled while it ran: it stays so. And a relayer relaying its request already answers the request that
|
|
647
|
+
// started it, so whether a service keeps failing stays as the tries before found it.
|
|
648
|
+
if (op.status !== 'pending' || e instanceof Relaying) continue;
|
|
589
649
|
// only what trying again cannot change fails an operation: a server or an RPC that does not answer (any
|
|
590
650
|
// error that is not the wallet's own), or answers what the chain does not show yet, is tried next sync. It is
|
|
591
651
|
// kept on the operation, so that one that never gets past it does not look merely slow.
|
|
@@ -676,6 +736,12 @@ export class Wallet {
|
|
|
676
736
|
coins(): CoinInfo[] {
|
|
677
737
|
const size = (chainId: bigint) => this.#status.get(chainId)?.latestRoot?.size ?? 0;
|
|
678
738
|
const unproved = (chainId: bigint) => (this.#status.get(chainId)?.halted ? 'roots-halted' : 'awaiting-root');
|
|
739
|
+
// when a root should cover a coin that landed at `atMs`
|
|
740
|
+
const covered = (chainId: bigint, atMs: number | undefined) => {
|
|
741
|
+
const secs = this.#status.get(chainId)?.settleSecs ?? null;
|
|
742
|
+
return atMs === undefined || secs === null ? null : atMs + secs * 1000;
|
|
743
|
+
};
|
|
744
|
+
const date = (ms: number | null) => (ms === null ? null : new Date(ms));
|
|
679
745
|
const spending = new Set(this.#ctx.state.ops.flatMap((o) => inputsOf(o).map((c) => `${o.chainId}:${c.index}`)));
|
|
680
746
|
// spent by a JoinSplit in a block, which waits only to be final: gone, as activity() counts it. Sync sees it by its
|
|
681
747
|
// nullifier (`unspent`); this device's own is gone already from its relay on (a reorg that drops it brings them
|
|
@@ -703,7 +769,7 @@ export class Wallet {
|
|
|
703
769
|
state,
|
|
704
770
|
from: n.peer,
|
|
705
771
|
via: null,
|
|
706
|
-
|
|
772
|
+
readyBy: state === 'awaiting-root' ? date(covered(n.chainId, f.atMs)) : null,
|
|
707
773
|
at: f.atMs === undefined ? null : new Date(f.atMs),
|
|
708
774
|
});
|
|
709
775
|
}
|
|
@@ -723,7 +789,14 @@ export class Wallet {
|
|
|
723
789
|
: f.index >= size(d.chainId)
|
|
724
790
|
? unproved(d.chainId)
|
|
725
791
|
: 'spendable';
|
|
726
|
-
|
|
792
|
+
// a root covers a deposit while it is screened, most often before its check ends
|
|
793
|
+
const settles = covered(d.chainId, f.atMs);
|
|
794
|
+
const readyBy =
|
|
795
|
+
state === 'screening'
|
|
796
|
+
? date(f.decidedByMs === undefined ? null : Math.max(f.decidedByMs, settles ?? 0))
|
|
797
|
+
: state === 'awaiting-root'
|
|
798
|
+
? date(settles)
|
|
799
|
+
: null;
|
|
727
800
|
out.push({
|
|
728
801
|
id,
|
|
729
802
|
kind: 'deposit',
|
|
@@ -732,7 +805,7 @@ export class Wallet {
|
|
|
732
805
|
state,
|
|
733
806
|
from: f.sender,
|
|
734
807
|
via: this.#via(d, f.sender),
|
|
735
|
-
|
|
808
|
+
readyBy,
|
|
736
809
|
at: f.atMs === undefined ? null : new Date(f.atMs),
|
|
737
810
|
});
|
|
738
811
|
}
|
|
@@ -1077,6 +1150,9 @@ export class Wallet {
|
|
|
1077
1150
|
return op.kind === 'deposit' ? null : (op.data.step?.stage ?? null);
|
|
1078
1151
|
},
|
|
1079
1152
|
to: op.kind === 'deposit' ? (op.data.to ?? null) : null,
|
|
1153
|
+
get cancellable() {
|
|
1154
|
+
return cancellable(op);
|
|
1155
|
+
},
|
|
1080
1156
|
wait: async () => {
|
|
1081
1157
|
for (;;) {
|
|
1082
1158
|
if (op.status === 'done') return;
|
|
@@ -1085,6 +1161,31 @@ export class Wallet {
|
|
|
1085
1161
|
if (op.status === 'pending') await new Promise((r) => setTimeout(r, 15_000));
|
|
1086
1162
|
}
|
|
1087
1163
|
},
|
|
1164
|
+
// at once, outside the queue: a sync under way may wait long on a service that does not answer, and a page closed
|
|
1165
|
+
// before a queued save would find the operation pending and relay it. That sync starts no further stage of it.
|
|
1166
|
+
cancel: async () => {
|
|
1167
|
+
if (op.errorCode === 'cancelled') return;
|
|
1168
|
+
if (!cancellable(op))
|
|
1169
|
+
fail(
|
|
1170
|
+
'invalid',
|
|
1171
|
+
op.status === 'pending'
|
|
1172
|
+
? 'it has a request out to the relayer, or has paid: it finishes or fails on its own'
|
|
1173
|
+
: `it is ${op.status}`,
|
|
1174
|
+
);
|
|
1175
|
+
const ops = this.#ctx.state.ops;
|
|
1176
|
+
const at = ops.indexOf(op);
|
|
1177
|
+
if (at < 0) fail('invalid', 'this wallet no longer holds it');
|
|
1178
|
+
ops.splice(at, 1);
|
|
1179
|
+
Object.assign(op, { status: 'failed', errorCode: 'cancelled', error: 'cancelled' });
|
|
1180
|
+
try {
|
|
1181
|
+
await this.#ctx.save();
|
|
1182
|
+
} catch (e) {
|
|
1183
|
+
// not saved, so not cancelled: the saved state would resume it
|
|
1184
|
+
Object.assign(op, { status: 'pending', errorCode: undefined, error: undefined });
|
|
1185
|
+
ops.splice(at, 0, op);
|
|
1186
|
+
throw e;
|
|
1187
|
+
}
|
|
1188
|
+
},
|
|
1088
1189
|
};
|
|
1089
1190
|
}
|
|
1090
1191
|
|
|
@@ -1417,9 +1518,14 @@ export class Wallet {
|
|
|
1417
1518
|
if (opts.kind === 'consolidate') {
|
|
1418
1519
|
const chosen = opts.coins && this.#chosen(token, opts.coins);
|
|
1419
1520
|
if (chosen && chosenSettling(this.#ctx, token.chainId, chosen, all)) {
|
|
1420
|
-
|
|
1521
|
+
// all of them must be spendable: the last one on its way, where each is a coin with an estimate
|
|
1522
|
+
const ids = new Set(opts.coins);
|
|
1523
|
+
const picked = this.coins().filter((c) => ids.has(c.id));
|
|
1524
|
+
const waiting = picked.filter((c) => c.state !== 'spendable');
|
|
1525
|
+
throw new WalletError(
|
|
1421
1526
|
'funds-settling',
|
|
1422
1527
|
'a chosen note or deposit is still settling: quote again once coins() shows it spendable',
|
|
1528
|
+
picked.length === ids.size && waiting.every(onItsWay) ? waitsOn(waiting, total(waiting)) : null,
|
|
1423
1529
|
);
|
|
1424
1530
|
}
|
|
1425
1531
|
const steps = BigInt(roundsToMerge(mergePool(all, chosen)));
|
|
@@ -1445,10 +1551,24 @@ export class Wallet {
|
|
|
1445
1551
|
const onTheWay =
|
|
1446
1552
|
this.#ctx.state.ops.some((o) => o.status === 'pending') ||
|
|
1447
1553
|
settling(this.#ctx, token.chainId, token.tokenId, size);
|
|
1448
|
-
|
|
1554
|
+
const need = amount + relayer + protocol;
|
|
1555
|
+
try {
|
|
1556
|
+
payWith(all, need, relayer, onTheWay);
|
|
1557
|
+
} catch (e) {
|
|
1558
|
+
if (!(e instanceof WalletError) || e.code !== 'funds-settling') throw e;
|
|
1559
|
+
throw new WalletError(e.code, e.message, this.#waitsFor(token, need));
|
|
1560
|
+
}
|
|
1449
1561
|
return { relayer, protocol, protocolBps: bpsOf(protocol, withdrawn), fee: relayer + protocol, steps: 1 };
|
|
1450
1562
|
}
|
|
1451
1563
|
|
|
1564
|
+
/** Of `token`'s coins on their way, the one whose turning spendable lets a payment of `need` go, as
|
|
1565
|
+
* `WalletError.waitsOn` tells. None while an operation is in flight, as its change is not a coin yet. */
|
|
1566
|
+
#waitsFor(token: Token, need: bigint): CoinInfo | null {
|
|
1567
|
+
if (this.#ctx.state.ops.some((o) => o.status === 'pending')) return null;
|
|
1568
|
+
const mine = this.coins().filter((c) => c.token.chainId === token.chainId && c.token.tokenId === token.tokenId);
|
|
1569
|
+
return waitsOn(mine.filter(onItsWay), need - total(mine.filter((c) => c.state === 'spendable')));
|
|
1570
|
+
}
|
|
1571
|
+
|
|
1452
1572
|
/** The most a send or withdrawal of `token` can pay: what is spendable, less the fees `fees` quotes on top of it, and
|
|
1453
1573
|
* the merges first when one JoinSplit cannot spend it all, which `fees` then asks for (`needs-consolidation`). */
|
|
1454
1574
|
async maxAmount(opts: { kind: 'send' | 'withdraw'; token: Token }): Promise<MaxAmount> {
|
|
@@ -1480,6 +1600,12 @@ export class Wallet {
|
|
|
1480
1600
|
return merge && merge.map((m) => `${token.chainId}:${m.index}`);
|
|
1481
1601
|
}
|
|
1482
1602
|
|
|
1603
|
+
/** Fetches what a send or withdrawal proves with, some 25 MB, once a page: an app calls it as a payment form opens,
|
|
1604
|
+
* so that the payment's proof does not wait for the download. Outside the queue of calls: a sync goes on meanwhile. */
|
|
1605
|
+
preloadProver(): Promise<void> {
|
|
1606
|
+
return preload(this.#ctx.network.circuits.joinsplit);
|
|
1607
|
+
}
|
|
1608
|
+
|
|
1483
1609
|
/** Pays `amount` privately: to an account that has registered (any wallet that ever opened), by its address or its
|
|
1484
1610
|
* zERC20 ID (`accountId`), or to a receive code (`receiveCode`). An account that never registered fails with
|
|
1485
1611
|
* `recipient-not-registered`. One JoinSplit from what is spendable now, or it fails here: `needs-consolidation`,
|
|
@@ -1595,22 +1721,50 @@ const chainsOf = (network: Network): ChainCtx[] =>
|
|
|
1595
1721
|
asp: new Asp(config.asp),
|
|
1596
1722
|
}));
|
|
1597
1723
|
|
|
1598
|
-
/**
|
|
1599
|
-
*
|
|
1600
|
-
|
|
1601
|
-
|
|
1724
|
+
/** A wallet held open here (`lockWallet`): until `release`, or until another tab or window takes it, which aborts
|
|
1725
|
+
* `taken`. */
|
|
1726
|
+
export interface Hold {
|
|
1727
|
+
release: () => void;
|
|
1728
|
+
taken: AbortSignal;
|
|
1729
|
+
/** Runs `f`, a read or a write of the wallet's saved state, with no other tab in one, and only while this one holds the
|
|
1730
|
+
* wallet: asked of the browser's lock manager, which knows of a take before this tab is told, so that a write lands
|
|
1731
|
+
* either before the tab that took the wallet reads the state, or not at all. */
|
|
1732
|
+
guard: <T>(f: () => Promise<T>) => Promise<T>;
|
|
1733
|
+
}
|
|
1734
|
+
|
|
1735
|
+
/** Holds `address`'s wallet: two tabs on one wallet would each overwrite the other's state and spend the same notes. It
|
|
1736
|
+
* fails with `wallet-in-use` while another tab or window holds it, unless it `take`s it from that one. A wallet that
|
|
1737
|
+
* watches it (`view`) is held apart, as its state is its own. Neither where there are no Web Locks. */
|
|
1738
|
+
export async function lockWallet(address: Address, kind: 'wallet' | 'view' = 'wallet', take = false): Promise<Hold> {
|
|
1739
|
+
const taken = new AbortController();
|
|
1602
1740
|
const locks = (globalThis as { navigator?: Navigator }).navigator?.locks;
|
|
1603
|
-
if (!locks) return () => {};
|
|
1741
|
+
if (!locks) return { release: () => {}, taken: taken.signal, guard: (f) => f() };
|
|
1742
|
+
const name = `zerc20-${kind}:${address.toLowerCase()}`;
|
|
1743
|
+
const lose = () => taken.abort(new WalletError('wallet-taken', 'this wallet was opened in another tab or window'));
|
|
1604
1744
|
let release = () => {};
|
|
1605
1745
|
const held = new Promise<void>((r) => (release = r));
|
|
1606
|
-
|
|
1607
|
-
|
|
1608
|
-
|
|
1609
|
-
|
|
1610
|
-
|
|
1746
|
+
let got = false;
|
|
1747
|
+
await new Promise<void>((resolve, reject) => {
|
|
1748
|
+
locks
|
|
1749
|
+
.request(name, take ? { steal: true } : { ifAvailable: true }, (lock) => {
|
|
1750
|
+
got = lock !== null;
|
|
1751
|
+
resolve();
|
|
1752
|
+
return got ? held : undefined;
|
|
1753
|
+
})
|
|
1754
|
+
// a held lock's request rejects as another takes it
|
|
1755
|
+
.catch((e: unknown) => (got ? lose() : reject(e)));
|
|
1611
1756
|
});
|
|
1612
1757
|
if (!got) fail('wallet-in-use', 'this wallet is open in another tab or window: close it there first');
|
|
1613
|
-
|
|
1758
|
+
const state = `${name}/state`;
|
|
1759
|
+
const guard = <T>(f: () => Promise<T>) =>
|
|
1760
|
+
locks.request(state, async () => {
|
|
1761
|
+
const holders = (await locks.query()).held ?? [];
|
|
1762
|
+
const holder = (n: string) => holders.find((l) => l.name === n)?.clientId;
|
|
1763
|
+
if (holder(name) !== holder(state)) lose();
|
|
1764
|
+
taken.signal.throwIfAborted();
|
|
1765
|
+
return f();
|
|
1766
|
+
});
|
|
1767
|
+
return { release, taken: taken.signal, guard };
|
|
1614
1768
|
}
|
|
1615
1769
|
|
|
1616
1770
|
/** A deposit `deposit()` did not see through (a reload, a lost connection): settled by its transaction's receipt, or
|