@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.
- package/LICENSE +17 -20
- package/README.md +2 -2
- package/dist/accountId.d.ts +5 -0
- package/dist/accountId.js +19 -0
- package/dist/accountId.js.map +1 -0
- package/dist/chain.d.ts +86 -9
- package/dist/chain.js +61 -33
- package/dist/chain.js.map +1 -1
- package/dist/context.d.ts +8 -2
- package/dist/context.js +15 -2
- package/dist/context.js.map +1 -1
- package/dist/core.d.ts +35 -5
- package/dist/core.js +98 -25
- 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/index.d.ts +4 -2
- package/dist/index.js +4 -2
- package/dist/index.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 +10 -1
- package/dist/pos.js +219 -55
- 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/rageExit.d.ts +27 -4
- package/dist/rageExit.js +160 -49
- package/dist/rageExit.js.map +1 -1
- package/dist/seed.d.ts +4 -0
- package/dist/seed.js +13 -1
- package/dist/seed.js.map +1 -1
- package/dist/servers.d.ts +5 -0
- package/dist/servers.js +2 -0
- package/dist/servers.js.map +1 -1
- package/dist/services.d.ts +14 -6
- package/dist/services.js +35 -14
- package/dist/services.js.map +1 -1
- package/dist/spend.d.ts +53 -11
- package/dist/spend.js +171 -65
- package/dist/spend.js.map +1 -1
- package/dist/state.d.ts +21 -0
- package/dist/state.js.map +1 -1
- package/dist/sync.d.ts +10 -5
- package/dist/sync.js +49 -18
- package/dist/sync.js.map +1 -1
- package/dist/wallet.d.ts +132 -21
- package/dist/wallet.js +342 -91
- package/dist/wallet.js.map +1 -1
- package/package.json +3 -3
- package/src/accountId.ts +22 -0
- package/src/chain.ts +101 -49
- package/src/context.ts +27 -3
- package/src/core.ts +112 -26
- package/src/errors.ts +15 -2
- package/src/index.ts +15 -2
- package/src/network.ts +6 -3
- package/src/pos.ts +247 -61
- package/src/prove.ts +10 -0
- package/src/rageExit.ts +237 -61
- package/src/seed.ts +24 -1
- package/src/servers.ts +9 -1
- package/src/services.ts +36 -22
- package/src/spend.ts +188 -75
- package/src/state.ts +25 -1
- package/src/sync.ts +54 -19
- package/src/wallet.ts +437 -109
- package/wasm/zerc20_wallet_core.d.ts +1 -1
- package/wasm/zerc20_wallet_core.js +1 -1
- package/wasm/zerc20_wallet_core_bg.wasm +0 -0
package/src/wallet.ts
CHANGED
|
@@ -27,10 +27,12 @@ import {
|
|
|
27
27
|
getAddress,
|
|
28
28
|
hexToBytes,
|
|
29
29
|
isAddress,
|
|
30
|
+
isAddressEqual,
|
|
30
31
|
numberToHex,
|
|
31
32
|
zeroAddress,
|
|
32
33
|
} from 'viem';
|
|
33
34
|
import { toAccount } from 'viem/accounts';
|
|
35
|
+
import { idAccount } from './accountId.js';
|
|
34
36
|
import { Chain, MAX_REFERRAL_BPS, NATIVE, type Referrer, type Token, erc20Abi, tokenAbi } from './chain.js';
|
|
35
37
|
import {
|
|
36
38
|
type ChainCtx,
|
|
@@ -39,6 +41,7 @@ import {
|
|
|
39
41
|
chainCtx,
|
|
40
42
|
chainState,
|
|
41
43
|
owner,
|
|
44
|
+
refundSigner,
|
|
42
45
|
switched,
|
|
43
46
|
walletOn,
|
|
44
47
|
walletSees,
|
|
@@ -46,15 +49,18 @@ import {
|
|
|
46
49
|
import { burnAddressFor, core, initCore } from './core.js';
|
|
47
50
|
import { type ErrorCode, WalletError, fail, guard } from './errors.js';
|
|
48
51
|
import { type Network, checkNetwork } from './network.js';
|
|
52
|
+
import { preload } from './prove.js';
|
|
49
53
|
import { Asp, Indexer, type IndexerStatus, PAGE } from './servers.js';
|
|
50
|
-
import { rageExit } from './rageExit.js';
|
|
51
|
-
import { registerDeposit, relayerFees } from './services.js';
|
|
54
|
+
import { type Route, flushDepositAddress, rageExit, refundDepositAddress } from './rageExit.js';
|
|
55
|
+
import { Relaying, registerDeposit, relayerFees, rewatchDeposit } from './services.js';
|
|
52
56
|
import {
|
|
53
57
|
type Destination,
|
|
54
58
|
type MergeStep,
|
|
55
59
|
type SpendData,
|
|
56
60
|
type Step,
|
|
57
61
|
advanceSpend,
|
|
62
|
+
bpsOf,
|
|
63
|
+
cancellable,
|
|
58
64
|
chosenSettling,
|
|
59
65
|
coins,
|
|
60
66
|
grossed,
|
|
@@ -70,7 +76,9 @@ import {
|
|
|
70
76
|
withdrawalOf,
|
|
71
77
|
} from './spend.js';
|
|
72
78
|
import {
|
|
79
|
+
type DepositAddressReceipt,
|
|
73
80
|
type DepositOperation,
|
|
81
|
+
type DepositRecord,
|
|
74
82
|
type OperationRecord,
|
|
75
83
|
type WalletState,
|
|
76
84
|
emptyWalletState,
|
|
@@ -118,6 +126,9 @@ export interface OpenOptions {
|
|
|
118
126
|
birthday?: bigint | Date;
|
|
119
127
|
/** The app opening the wallet, when it takes a share of what reaches the deposit addresses it hands out. */
|
|
120
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;
|
|
121
132
|
}
|
|
122
133
|
|
|
123
134
|
export interface WatchOptions extends Pick<OpenOptions, 'network' | 'persist' | 'birthday'> {
|
|
@@ -170,7 +181,8 @@ export interface Activity {
|
|
|
170
181
|
* paid none. */
|
|
171
182
|
fee: bigint | null;
|
|
172
183
|
/** The other side, where known: a payer's claimed address (unverified), where a payment or withdrawal went, or whom a
|
|
173
|
-
* returned deposit went back to.
|
|
184
|
+
* returned deposit went back to. A payment's other side, `sent`'s or that of a `received` with no `burnAddress`, is a
|
|
185
|
+
* zERC20 account, which an app shows by its zERC20 ID (`accountId`); any other is a public address. */
|
|
174
186
|
counterparty: Address | null;
|
|
175
187
|
/** What the counterparty alone does not say: the receive code a payment went to (it has no counterparty), or the
|
|
176
188
|
* deposit address a deposit came through (its counterparty, which forwarded what was sent to it). */
|
|
@@ -204,8 +216,34 @@ export interface CoinInfo {
|
|
|
204
216
|
state: 'spendable' | Exclude<PendingReason, 'incoming'> | 'spending' | 'blocked';
|
|
205
217
|
/** A note's payer (as claimed, unverified) or a deposit's sender, where known. */
|
|
206
218
|
from: Address | null;
|
|
207
|
-
/**
|
|
208
|
-
|
|
219
|
+
/** The deposit address of this wallet's that a deposit came through (its `from`), as `Activity.via` names it: its
|
|
220
|
+
* `rageExit` goes where `to` says. */
|
|
221
|
+
via: { depositAddress: Address } | null;
|
|
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;
|
|
226
|
+
/** When it landed: its block's time, null until read. */
|
|
227
|
+
at: Date | null;
|
|
228
|
+
}
|
|
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;
|
|
209
247
|
}
|
|
210
248
|
|
|
211
249
|
export interface DepositInfo {
|
|
@@ -221,12 +259,25 @@ export interface DepositInfo {
|
|
|
221
259
|
state: 'screening' | 'approved' | 'rejected' | 'spent';
|
|
222
260
|
}
|
|
223
261
|
|
|
262
|
+
export interface DepositAddressInfo {
|
|
263
|
+
address: Address;
|
|
264
|
+
token: Token;
|
|
265
|
+
/** What the relayer takes out of each payment through it. Null where its receipt in storage does not say what it was
|
|
266
|
+
* registered with: `flushDepositAddress` needs that, so only the relayer flushes such an address. */
|
|
267
|
+
fee: bigint | null;
|
|
268
|
+
/** When it was handed out, or watched again (`rewatchDepositAddress`), from which the deposit relayer watches it for
|
|
269
|
+
* 7 days: when storage took its receipt. */
|
|
270
|
+
at: Date;
|
|
271
|
+
}
|
|
272
|
+
|
|
224
273
|
/** A send's or withdrawal's recipient gets all of its amount, and the private balance pays `fee` on top. */
|
|
225
274
|
export interface FeeQuote {
|
|
226
275
|
/** To the relayer. */
|
|
227
276
|
relayer: bigint;
|
|
228
277
|
/** The protocol's cut of the public withdrawal: a withdrawal's, or the one that pays the relayer. */
|
|
229
278
|
protocol: bigint;
|
|
279
|
+
/** `protocol` as a rate of that withdrawal, in basis points (35 is 0.35%): lower in tiers as the withdrawal grows. */
|
|
280
|
+
protocolBps: number;
|
|
230
281
|
/** `relayer + protocol`: the `maxFee` this quote passes. */
|
|
231
282
|
fee: bigint;
|
|
232
283
|
/** Transactions needed: one JoinSplit for a send or a withdrawal, a consolidation's merges (each pays the relayer's
|
|
@@ -234,6 +285,16 @@ export interface FeeQuote {
|
|
|
234
285
|
steps: number;
|
|
235
286
|
}
|
|
236
287
|
|
|
288
|
+
/** The most a send or withdrawal can pay now, and what its fees keep back of what is spendable. */
|
|
289
|
+
export interface MaxAmount {
|
|
290
|
+
/** What the recipient would get: 0 where the fees take everything. */
|
|
291
|
+
amount: bigint;
|
|
292
|
+
/** The fees: the payment's, and those of the merges it needs first. Where they take everything, what they would be. */
|
|
293
|
+
fee: bigint;
|
|
294
|
+
/** Merges first, when one JoinSplit cannot spend all of it: each pays the relayer again. */
|
|
295
|
+
merges: number;
|
|
296
|
+
}
|
|
297
|
+
|
|
237
298
|
export type OperationKind = OperationRecord['kind'];
|
|
238
299
|
export type OperationStatus = OperationRecord['status'];
|
|
239
300
|
export type CoinState = CoinInfo['state'];
|
|
@@ -256,10 +317,18 @@ export interface Operation {
|
|
|
256
317
|
/** How far a send, withdrawal or consolidation round has come, for an app to show; null for a deposit, or between
|
|
257
318
|
* rounds. It moves during a sync: read it while one runs. */
|
|
258
319
|
readonly stage: Step['stage'] | MergeStep['stage'] | null;
|
|
259
|
-
/** Whom a deposit pays, when
|
|
320
|
+
/** Whom a deposit pays, when `deposit({ to })` named an address; null otherwise. One to a receive code shows its code
|
|
321
|
+
* as `via` in `activity()`. */
|
|
260
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;
|
|
261
326
|
/** Syncs until the operation is done; throws if it fails. */
|
|
262
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>;
|
|
263
332
|
}
|
|
264
333
|
|
|
265
334
|
export class Wallet {
|
|
@@ -269,23 +338,37 @@ export class Wallet {
|
|
|
269
338
|
#queue: Promise<unknown> = Promise.resolve();
|
|
270
339
|
/** Calls running or queued. */
|
|
271
340
|
#busy = 0;
|
|
272
|
-
#
|
|
341
|
+
readonly #lock: Hold;
|
|
273
342
|
#referrer: Referrer | undefined;
|
|
274
343
|
readonly #unlock: (() => Promise<Uint8Array>) | undefined;
|
|
275
344
|
#readOnly = false;
|
|
276
345
|
#closed = new AbortController();
|
|
277
346
|
|
|
278
|
-
|
|
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
|
+
) {
|
|
279
353
|
this.#ctx = ctx;
|
|
280
354
|
this.#unlock = unlock;
|
|
355
|
+
this.#lock = lock;
|
|
356
|
+
lock.taken.addEventListener('abort', () => this.#closed.abort(lock.taken.reason));
|
|
281
357
|
}
|
|
282
358
|
|
|
283
|
-
/** Lets this wallet
|
|
359
|
+
/** Lets this wallet go, for another tab or another account here. This object is done with: what is
|
|
284
360
|
* queued fails with `wallet-closed`, a burn address search stops, and the wallet is let go once the task under way
|
|
285
361
|
* ends, so that two never write its state at once. */
|
|
286
362
|
close(): Promise<void> {
|
|
287
363
|
this.#closed.abort(new WalletError('wallet-closed', 'this wallet was closed: open it again'));
|
|
288
|
-
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;
|
|
289
372
|
}
|
|
290
373
|
|
|
291
374
|
get address(): Address {
|
|
@@ -322,16 +405,16 @@ export class Wallet {
|
|
|
322
405
|
const seed = typeof opts.seed === 'function' ? await opts.seed() : opts.seed;
|
|
323
406
|
if (seed.length !== 32) fail('invalid', 'the seed is 32 bytes');
|
|
324
407
|
await initCore();
|
|
325
|
-
const
|
|
408
|
+
const hold = await lockWallet(account.address, 'wallet', opts.take);
|
|
326
409
|
try {
|
|
327
|
-
return await Wallet.#open(opts, seed, account,
|
|
410
|
+
return await Wallet.#open(opts, seed, account, hold);
|
|
328
411
|
} catch (e) {
|
|
329
|
-
release();
|
|
412
|
+
hold.release();
|
|
330
413
|
throw e;
|
|
331
414
|
}
|
|
332
415
|
}
|
|
333
416
|
|
|
334
|
-
static async #open(opts: OpenOptions, seed: Uint8Array, account: Account,
|
|
417
|
+
static async #open(opts: OpenOptions, seed: Uint8Array, account: Account, hold: Hold): Promise<Wallet> {
|
|
335
418
|
const address = account.address;
|
|
336
419
|
const chains = chainsOf(opts.network);
|
|
337
420
|
const code = await chains[0]?.chain.client.getCode({ address });
|
|
@@ -348,7 +431,7 @@ export class Wallet {
|
|
|
348
431
|
);
|
|
349
432
|
const storage = await CanisterClient.connect(opts.network.storage.host, opts.network.storage.canisterId);
|
|
350
433
|
const publicKey = hexToBytes((core.spendKey(seed) as SpendKey).publicKey);
|
|
351
|
-
const saved = await opts.persist.load();
|
|
434
|
+
const saved = await hold.guard(() => opts.persist.load());
|
|
352
435
|
// registered already: used before, here or on another device, so its past is looked for
|
|
353
436
|
// ponytail: from genesis (~4 MB per million commitments); ask for a birthday once the chain has many
|
|
354
437
|
const restoring =
|
|
@@ -359,6 +442,7 @@ export class Wallet {
|
|
|
359
442
|
network: opts.network,
|
|
360
443
|
seed,
|
|
361
444
|
owners: new Map(),
|
|
445
|
+
refundSigners: new Map(),
|
|
362
446
|
address,
|
|
363
447
|
account,
|
|
364
448
|
walletClient: opts.walletClient ?? null,
|
|
@@ -366,10 +450,9 @@ export class Wallet {
|
|
|
366
450
|
view,
|
|
367
451
|
chains,
|
|
368
452
|
},
|
|
369
|
-
{ 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 },
|
|
370
454
|
typeof opts.seed === 'function' ? opts.seed : undefined,
|
|
371
455
|
);
|
|
372
|
-
wallet.#release = release;
|
|
373
456
|
wallet.#referrer = opts.referrer;
|
|
374
457
|
return wallet;
|
|
375
458
|
}
|
|
@@ -382,7 +465,7 @@ export class Wallet {
|
|
|
382
465
|
await initCore();
|
|
383
466
|
const { view, owner } = decodeViewKey(opts.viewKey) ?? fail('invalid', 'not a view key');
|
|
384
467
|
const address = getAddress(bytesToHex(view.address));
|
|
385
|
-
const
|
|
468
|
+
const hold = await lockWallet(address, 'view');
|
|
386
469
|
try {
|
|
387
470
|
const storage = await CanisterClient.connect(opts.network.storage.host, opts.network.storage.canisterId);
|
|
388
471
|
// what the account signed: a nullifying key that is not the account's would show spent coins as unspent
|
|
@@ -394,6 +477,7 @@ export class Wallet {
|
|
|
394
477
|
network: opts.network,
|
|
395
478
|
seed: null,
|
|
396
479
|
owners: new Map([['', owner]]),
|
|
480
|
+
refundSigners: new Map(),
|
|
397
481
|
address,
|
|
398
482
|
// signs nothing: every call that would send is refused first
|
|
399
483
|
account: toAccount(address),
|
|
@@ -405,21 +489,22 @@ export class Wallet {
|
|
|
405
489
|
// registered, so its past is looked for
|
|
406
490
|
{
|
|
407
491
|
persist: opts.persist,
|
|
408
|
-
saved: await opts.persist.load(),
|
|
492
|
+
saved: await hold.guard(() => opts.persist.load()),
|
|
409
493
|
key: stateKey(view.seed),
|
|
410
494
|
from: opts.birthday ?? 0n,
|
|
495
|
+
hold,
|
|
411
496
|
},
|
|
412
497
|
);
|
|
413
|
-
wallet.#release = release;
|
|
414
498
|
wallet.#readOnly = true;
|
|
415
499
|
return wallet;
|
|
416
500
|
} catch (e) {
|
|
417
|
-
release();
|
|
501
|
+
hold.release();
|
|
418
502
|
throw e;
|
|
419
503
|
}
|
|
420
504
|
}
|
|
421
505
|
|
|
422
|
-
/** 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. */
|
|
423
508
|
static async #begin(
|
|
424
509
|
base: Omit<Ctx, 'state' | 'save' | 'lastFullRefreshMs'>,
|
|
425
510
|
{
|
|
@@ -427,7 +512,14 @@ export class Wallet {
|
|
|
427
512
|
saved,
|
|
428
513
|
key,
|
|
429
514
|
from,
|
|
430
|
-
|
|
515
|
+
hold,
|
|
516
|
+
}: {
|
|
517
|
+
persist: OpenOptions['persist'];
|
|
518
|
+
saved: Uint8Array | null | undefined;
|
|
519
|
+
key: Uint8Array;
|
|
520
|
+
from?: bigint | Date;
|
|
521
|
+
hold: Hold;
|
|
522
|
+
},
|
|
431
523
|
unlock?: () => Promise<Uint8Array>,
|
|
432
524
|
): Promise<Wallet> {
|
|
433
525
|
const loaded = saved?.length ? openState(key, saved) : null;
|
|
@@ -441,14 +533,17 @@ export class Wallet {
|
|
|
441
533
|
if (loaded && loaded.address.toLowerCase() !== base.address.toLowerCase())
|
|
442
534
|
fail('invalid', `the saved state is ${loaded.address}'s: keep one per address`);
|
|
443
535
|
const state: WalletState = loaded ?? emptyWalletState(base.address);
|
|
444
|
-
const
|
|
536
|
+
const save = () => hold.guard(() => persist.save(sealState(key, state)));
|
|
537
|
+
const ctx: Ctx = { ...base, state, save, lastFullRefreshMs: 0 };
|
|
445
538
|
if (!loaded) await start(ctx, from);
|
|
446
|
-
const wallet = new Wallet(ctx, unlock);
|
|
539
|
+
const wallet = new Wallet(ctx, unlock, hold);
|
|
447
540
|
// a saved state names its tokens by id, and its coins stand by the chains' roots: read both now, so balances, coins
|
|
448
541
|
// and history work before the first sync
|
|
449
542
|
const [tokens] = await Promise.all([Promise.all(ctx.chains.map((c) => c.chain.tokens())), wallet.#readRoots()]);
|
|
450
543
|
wallet.#tokens = tokens.flat();
|
|
451
544
|
keyAll(ctx, wallet.#tokens);
|
|
545
|
+
// taken while it opened, before it listened
|
|
546
|
+
hold.taken.throwIfAborted();
|
|
452
547
|
return wallet;
|
|
453
548
|
}
|
|
454
549
|
|
|
@@ -539,14 +634,8 @@ export class Wallet {
|
|
|
539
634
|
async #catchUp(): Promise<void> {
|
|
540
635
|
const errors = await syncAll(this.#ctx);
|
|
541
636
|
errors.push(...(await this.#readRoots()));
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
c.chain
|
|
545
|
-
.tokens()
|
|
546
|
-
.catch((e: unknown) => (errors.push(e), this.#tokens.filter((t) => t.chainId === c.config.chainId))),
|
|
547
|
-
),
|
|
548
|
-
);
|
|
549
|
-
this.#tokens = listed.flat();
|
|
637
|
+
// as syncAll read them: a chain it could not read keeps those read before
|
|
638
|
+
this.#tokens = this.#ctx.chains.flatMap((c) => c.chain.known);
|
|
550
639
|
for (const op of this.#ctx.state.ops.filter((o) => o.status === 'pending')) {
|
|
551
640
|
try {
|
|
552
641
|
if (op.kind === 'send' || op.kind === 'withdraw' || op.kind === 'consolidate')
|
|
@@ -554,6 +643,9 @@ export class Wallet {
|
|
|
554
643
|
if (op.kind === 'deposit') await settleDeposit(this.#ctx, op);
|
|
555
644
|
delete op.retrying;
|
|
556
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;
|
|
557
649
|
// only what trying again cannot change fails an operation: a server or an RPC that does not answer (any
|
|
558
650
|
// error that is not the wallet's own), or answers what the chain does not show yet, is tried next sync. It is
|
|
559
651
|
// kept on the operation, so that one that never gets past it does not look merely slow.
|
|
@@ -644,6 +736,12 @@ export class Wallet {
|
|
|
644
736
|
coins(): CoinInfo[] {
|
|
645
737
|
const size = (chainId: bigint) => this.#status.get(chainId)?.latestRoot?.size ?? 0;
|
|
646
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));
|
|
647
745
|
const spending = new Set(this.#ctx.state.ops.flatMap((o) => inputsOf(o).map((c) => `${o.chainId}:${c.index}`)));
|
|
648
746
|
// spent by a JoinSplit in a block, which waits only to be final: gone, as activity() counts it. Sync sees it by its
|
|
649
747
|
// nullifier (`unspent`); this device's own is gone already from its relay on (a reorg that drops it brings them
|
|
@@ -670,7 +768,9 @@ export class Wallet {
|
|
|
670
768
|
amount: n.value,
|
|
671
769
|
state,
|
|
672
770
|
from: n.peer,
|
|
673
|
-
|
|
771
|
+
via: null,
|
|
772
|
+
readyBy: state === 'awaiting-root' ? date(covered(n.chainId, f.atMs)) : null,
|
|
773
|
+
at: f.atMs === undefined ? null : new Date(f.atMs),
|
|
674
774
|
});
|
|
675
775
|
}
|
|
676
776
|
}
|
|
@@ -689,7 +789,14 @@ export class Wallet {
|
|
|
689
789
|
: f.index >= size(d.chainId)
|
|
690
790
|
? unproved(d.chainId)
|
|
691
791
|
: 'spendable';
|
|
692
|
-
|
|
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;
|
|
693
800
|
out.push({
|
|
694
801
|
id,
|
|
695
802
|
kind: 'deposit',
|
|
@@ -697,7 +804,9 @@ export class Wallet {
|
|
|
697
804
|
amount: f.amount!,
|
|
698
805
|
state,
|
|
699
806
|
from: f.sender,
|
|
700
|
-
|
|
807
|
+
via: this.#via(d, f.sender),
|
|
808
|
+
readyBy,
|
|
809
|
+
at: f.atMs === undefined ? null : new Date(f.atMs),
|
|
701
810
|
});
|
|
702
811
|
}
|
|
703
812
|
}
|
|
@@ -771,27 +880,17 @@ export class Wallet {
|
|
|
771
880
|
}
|
|
772
881
|
}
|
|
773
882
|
for (const d of s.deposits) {
|
|
774
|
-
// registered on this device, or on another, which says so in storage
|
|
775
|
-
const registered =
|
|
776
|
-
d.depositAddress ??
|
|
777
|
-
s.receipts.flatMap((r) =>
|
|
778
|
-
r.kind === 'deposit-address' && r.chainId === d.chainId && r.burnAddress === d.address.toLowerCase()
|
|
779
|
-
? [r.depositAddress]
|
|
780
|
-
: [],
|
|
781
|
-
)[0];
|
|
782
883
|
// on chain once found; shown once its commitment is read, which names the amount
|
|
783
884
|
for (const f of d.found.filter((f) => f.amount !== null)) {
|
|
784
|
-
const forwarded = registered && f.sender?.toLowerCase() === registered.toLowerCase();
|
|
785
885
|
// into a burn address a payer chose: theirs to this wallet, as a payment is
|
|
786
886
|
const kind = d.managed ? 'sale' : d.payer ? 'received' : 'deposited';
|
|
787
|
-
const via = forwarded ? { depositAddress: registered } : null;
|
|
788
887
|
const entry = {
|
|
789
888
|
...at(kind, d.chainId, f, d.createdMs),
|
|
790
889
|
token: this.#token(d.chainId, f.tokenId),
|
|
791
890
|
amount: f.amount!,
|
|
792
891
|
burnAddress: d.address,
|
|
793
892
|
};
|
|
794
|
-
out.push({ ...entry, fee: null, counterparty: f.sender, via, txHash: null });
|
|
893
|
+
out.push({ ...entry, fee: null, counterparty: f.sender, via: this.#via(d, f.sender), txHash: null });
|
|
795
894
|
}
|
|
796
895
|
}
|
|
797
896
|
// a deposit that paid someone (`deposit({ to })`), once its transfer is found
|
|
@@ -836,8 +935,16 @@ export class Wallet {
|
|
|
836
935
|
x.address === op.data.burnAddress.toLowerCase() &&
|
|
837
936
|
x.found.some((f) => f.amount !== null),
|
|
838
937
|
);
|
|
839
|
-
|
|
840
|
-
|
|
938
|
+
const { to, code, txHash } = op.data;
|
|
939
|
+
if ((!to && !code) || !txHash || op.status === 'pending' || found) continue;
|
|
940
|
+
out.push({
|
|
941
|
+
...base,
|
|
942
|
+
kind: 'sent',
|
|
943
|
+
fee: null,
|
|
944
|
+
counterparty: to ?? null,
|
|
945
|
+
via: code ? { code } : null,
|
|
946
|
+
txHash,
|
|
947
|
+
});
|
|
841
948
|
continue;
|
|
842
949
|
}
|
|
843
950
|
// under way (in operations()), or shown by its change
|
|
@@ -856,6 +963,21 @@ export class Wallet {
|
|
|
856
963
|
return out.sort((a, b) => (b.at?.getTime() ?? 0) - (a.at?.getTime() ?? 0));
|
|
857
964
|
}
|
|
858
965
|
|
|
966
|
+
/** The deposit address `d` registered (here, or on another device, which says so in storage) if `sender` is it: what
|
|
967
|
+
* a deposit from `sender` came through. */
|
|
968
|
+
#via(d: DepositRecord, sender: Hex | null): { depositAddress: Address } | null {
|
|
969
|
+
const registered =
|
|
970
|
+
d.depositAddress ??
|
|
971
|
+
this.#ctx.state.receipts.flatMap((r) =>
|
|
972
|
+
r.kind === 'deposit-address' && r.chainId === d.chainId && r.burnAddress === d.address.toLowerCase()
|
|
973
|
+
? [r.depositAddress]
|
|
974
|
+
: [],
|
|
975
|
+
)[0];
|
|
976
|
+
return registered && sender && isAddressEqual(sender, registered)
|
|
977
|
+
? { depositAddress: getAddress(registered) }
|
|
978
|
+
: null;
|
|
979
|
+
}
|
|
980
|
+
|
|
859
981
|
/** The text of a receive code this wallet paid, by its burn address, from storage's record of paying it. */
|
|
860
982
|
#paidCode(chainId: bigint, burnAddress: Hex): string | null {
|
|
861
983
|
for (const c of this.#ctx.state.storage?.outgoing.lanes ?? []) {
|
|
@@ -900,6 +1022,31 @@ export class Wallet {
|
|
|
900
1022
|
);
|
|
901
1023
|
}
|
|
902
1024
|
|
|
1025
|
+
/** The deposit addresses this wallet handed out (`depositAddress`), on any of its devices, newest first: for an app to
|
|
1026
|
+
* show each while what is sent to it is on its way, which `activity()` shows once deposited. Each is dated, and its
|
|
1027
|
+
* fee told, by its receipt in storage, alike on every device. */
|
|
1028
|
+
depositAddresses(): DepositAddressInfo[] {
|
|
1029
|
+
return this.#ctx.state.receipts
|
|
1030
|
+
.flatMap((r) => {
|
|
1031
|
+
if (r.kind !== 'deposit-address') return [];
|
|
1032
|
+
const token = this.#token(r.chainId, r.tokenId);
|
|
1033
|
+
return [{ address: getAddress(r.depositAddress), token, fee: r.args?.fee ?? null, at: new Date(r.atMs) }];
|
|
1034
|
+
})
|
|
1035
|
+
.sort((a, b) => b.at.getTime() - a.at.getTime());
|
|
1036
|
+
}
|
|
1037
|
+
|
|
1038
|
+
/** What `address` is to this wallet on `chainId`: one of its own burn addresses (`deposit`), one it paid someone
|
|
1039
|
+
* into (`payment`, `deposit({ to })` to an address), or neither (null), as is a receive code's that it paid. On chain
|
|
1040
|
+
* either is a plain transfer to an address that names no one: this tells an app listing the account's public
|
|
1041
|
+
* transfers which they were. Both are recorded before the transfer is sent. */
|
|
1042
|
+
burnAddressOf(chainId: bigint, address: Address): { kind: 'deposit' } | { kind: 'payment'; to: Address } | null {
|
|
1043
|
+
const s = this.#ctx.state;
|
|
1044
|
+
const a = address.toLowerCase();
|
|
1045
|
+
if (s.deposits.some((d) => d.chainId === chainId && d.address.toLowerCase() === a)) return { kind: 'deposit' };
|
|
1046
|
+
const paid = s.sent.find((x) => x.chainId === chainId && x.address.toLowerCase() === a);
|
|
1047
|
+
return paid ? { kind: 'payment', to: paid.to } : null;
|
|
1048
|
+
}
|
|
1049
|
+
|
|
903
1050
|
/** The payments to the burn addresses this wallet's points of sale handed out. Spent ones are, normally, in a
|
|
904
1051
|
* merged note. */
|
|
905
1052
|
payments(): DepositInfo[] {
|
|
@@ -925,8 +1072,12 @@ export class Wallet {
|
|
|
925
1072
|
}
|
|
926
1073
|
|
|
927
1074
|
/** Refunds deposit `id` to its sender without the ASP, the attester or a proved root: for a deposit the ASP
|
|
928
|
-
* rejected, or when those services stop. The account's wallet sends the transactions and pays their gas
|
|
929
|
-
|
|
1075
|
+
* rejected, or when those services stop. The account's wallet sends the transactions and pays their gas, or with
|
|
1076
|
+
* `from` another wallet of the user's does, as `deposit({ from })` pays. One through a deposit address of this
|
|
1077
|
+
* wallet's (`CoinInfo.via`) goes to `to`, by default `from`'s account, else this account; any other goes back to its
|
|
1078
|
+
* sender, and one through a point of sale's deposit address to the refund address its payer named. */
|
|
1079
|
+
rageExit(id: string, opts: { from?: WalletClient; to?: Address } = {}): Promise<Hex[]> {
|
|
1080
|
+
const route = this.#route(opts);
|
|
930
1081
|
return this.#seeded(async () => {
|
|
931
1082
|
const [chain, index] = id.split(':');
|
|
932
1083
|
const record =
|
|
@@ -937,10 +1088,39 @@ export class Wallet {
|
|
|
937
1088
|
(o) => `${o.chainId}` === chain && inputsOf(o).some((c) => `${c.index}` === index),
|
|
938
1089
|
);
|
|
939
1090
|
if (op) fail('invalid', `deposit ${id} is an input of operation ${op.id}, which is in flight`);
|
|
940
|
-
return rageExit(this.#ctx, record, Number(index));
|
|
1091
|
+
return rageExit(this.#ctx, record, Number(index), route);
|
|
941
1092
|
});
|
|
942
1093
|
}
|
|
943
1094
|
|
|
1095
|
+
/** Sends what deposit address `address` of this wallet's holds of `asset` (NATIVE for the chain's own currency) to
|
|
1096
|
+
* `to`: another token sent to it, which it cannot wrap. What it can wrap is wrapped instead, as a payment to it is.
|
|
1097
|
+
* It deploys on its first flush, and refunds nothing before. `from` and `to` as for `rageExit`. */
|
|
1098
|
+
refundDepositAddress(
|
|
1099
|
+
address: Address,
|
|
1100
|
+
asset: Address,
|
|
1101
|
+
opts: { from?: WalletClient; to?: Address } = {},
|
|
1102
|
+
): Promise<Hex> {
|
|
1103
|
+
if (!isAddress(asset, { strict: false }) || asset.toLowerCase() === zeroAddress)
|
|
1104
|
+
fail('invalid', `${asset} is not an asset's address`);
|
|
1105
|
+
const route = this.#route(opts);
|
|
1106
|
+
return this.#seeded(async () => {
|
|
1107
|
+
const record =
|
|
1108
|
+
this.#ctx.state.deposits.find((d) => !d.managed && this.#via(d, address)) ??
|
|
1109
|
+
fail('invalid', `${address} is not a deposit address of this wallet's`);
|
|
1110
|
+
return refundDepositAddress(this.#ctx, record, getAddress(address), getAddress(asset), route);
|
|
1111
|
+
});
|
|
1112
|
+
}
|
|
1113
|
+
|
|
1114
|
+
/** Who sends a refund and where it goes (`rageExit`), checked before the seed is asked for. */
|
|
1115
|
+
#route(opts: { from?: WalletClient; to?: Address }): Route {
|
|
1116
|
+
const from = opts.from ?? null;
|
|
1117
|
+
if (from && !from.account) fail('invalid', 'the wallet client has no account');
|
|
1118
|
+
const to = opts.to ?? from?.account?.address ?? this.address;
|
|
1119
|
+
if (!isAddress(to, { strict: false }) || to.toLowerCase() === zeroAddress)
|
|
1120
|
+
fail('invalid', `${to} is not an address to refund to`);
|
|
1121
|
+
return { from, to: getAddress(to) };
|
|
1122
|
+
}
|
|
1123
|
+
|
|
944
1124
|
operations(): Operation[] {
|
|
945
1125
|
return this.#ctx.state.ops.map((op) => this.#operation(op));
|
|
946
1126
|
}
|
|
@@ -970,6 +1150,9 @@ export class Wallet {
|
|
|
970
1150
|
return op.kind === 'deposit' ? null : (op.data.step?.stage ?? null);
|
|
971
1151
|
},
|
|
972
1152
|
to: op.kind === 'deposit' ? (op.data.to ?? null) : null,
|
|
1153
|
+
get cancellable() {
|
|
1154
|
+
return cancellable(op);
|
|
1155
|
+
},
|
|
973
1156
|
wait: async () => {
|
|
974
1157
|
for (;;) {
|
|
975
1158
|
if (op.status === 'done') return;
|
|
@@ -978,6 +1161,31 @@ export class Wallet {
|
|
|
978
1161
|
if (op.status === 'pending') await new Promise((r) => setTimeout(r, 15_000));
|
|
979
1162
|
}
|
|
980
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
|
+
},
|
|
981
1189
|
};
|
|
982
1190
|
}
|
|
983
1191
|
|
|
@@ -1010,7 +1218,9 @@ export class Wallet {
|
|
|
1010
1218
|
let record = unusedBurn(ctx, token.chainId, token.tokenId);
|
|
1011
1219
|
if (!record) {
|
|
1012
1220
|
recording();
|
|
1013
|
-
record = await recordBurn(ctx, token.chainId, token.tokenId, await tokenIds(ctx),
|
|
1221
|
+
record = await recordBurn(ctx, token.chainId, token.tokenId, await tokenIds(ctx), {
|
|
1222
|
+
signal: this.#closed.signal,
|
|
1223
|
+
});
|
|
1014
1224
|
}
|
|
1015
1225
|
const next = chainState(ctx, token.chainId).burnNext;
|
|
1016
1226
|
// recording may have read another device's later ones
|
|
@@ -1025,10 +1235,12 @@ export class Wallet {
|
|
|
1025
1235
|
* burn address (approving it first if needed). The deposit is spendable once the ASP has screened it. More than the
|
|
1026
1236
|
* account holds fails at once (`insufficient-funds`), before anything is asked of the wallet.
|
|
1027
1237
|
*
|
|
1028
|
-
* With `to`, an
|
|
1029
|
-
* under the public key they registered, told to them in storage first as a note is
|
|
1030
|
-
* receive it as a deposit of their own, screened like any; the burn address names no one. It
|
|
1031
|
-
* as sent. Finding the address takes about 2^16 hashes (`burn-address`).
|
|
1238
|
+
* With `to`, an account that registered, by its address or its zERC20 ID, it pays them instead, privately: into a
|
|
1239
|
+
* fresh burn address of theirs, under the public key they registered, told to them in storage first as a note is
|
|
1240
|
+
* (`planDeposit`). They receive it as a deposit of their own, screened like any; the burn address names no one. It
|
|
1241
|
+
* shows in `activity()` as sent. Finding the address takes about 2^16 hashes (`burn-address`). Or `to` is a receive
|
|
1242
|
+
* code (`receiveCode`), paid into its burn address, which its owner already watches: storage is told nothing, so
|
|
1243
|
+
* only this device's `activity()` shows it as sent.
|
|
1032
1244
|
*
|
|
1033
1245
|
* With `wrapped`, it pays from the account's zERC20 itself, as a withdrawal pays out, rather than its underlying
|
|
1034
1246
|
* asset: a plain transfer into the burn address, with no wrap and no approval.
|
|
@@ -1041,7 +1253,7 @@ export class Wallet {
|
|
|
1041
1253
|
async deposit(opts: {
|
|
1042
1254
|
token: Token;
|
|
1043
1255
|
amount: bigint;
|
|
1044
|
-
to?:
|
|
1256
|
+
to?: string;
|
|
1045
1257
|
wrapped?: boolean;
|
|
1046
1258
|
from?: WalletClient;
|
|
1047
1259
|
onStep?: (step: DepositStep) => void;
|
|
@@ -1049,6 +1261,7 @@ export class Wallet {
|
|
|
1049
1261
|
this.#writable();
|
|
1050
1262
|
if (opts.amount <= 0n) fail('invalid', 'the amount must be positive');
|
|
1051
1263
|
const payer = opts.from ? (opts.from.account ?? fail('invalid', 'the wallet client has no account')) : null;
|
|
1264
|
+
const to = opts.to === undefined ? null : recipientOf(opts.to, opts.token);
|
|
1052
1265
|
const step = opts.onStep ?? (() => {});
|
|
1053
1266
|
if (this.#busy > 0) step('queued');
|
|
1054
1267
|
return this.#exclusive(async () => {
|
|
@@ -1073,14 +1286,21 @@ export class Wallet {
|
|
|
1073
1286
|
`${payer ? 'the wallet' : 'the account'} holds ${formatUnits(held, token.decimals)} of ${of}, less than ${formatUnits(amount, token.decimals)}`,
|
|
1074
1287
|
);
|
|
1075
1288
|
}
|
|
1076
|
-
const address =
|
|
1077
|
-
|
|
1078
|
-
|
|
1289
|
+
const address =
|
|
1290
|
+
to?.kind === 'code'
|
|
1291
|
+
? to.burnAddress
|
|
1292
|
+
: to
|
|
1293
|
+
? await this.#burnFor(token, amount, to.address, () => step('burn-address'))
|
|
1294
|
+
: (await this.#burnAddress(token, null, () => step('burn-address'))).address;
|
|
1079
1295
|
const op = this.#newOp(
|
|
1080
1296
|
'deposit',
|
|
1081
1297
|
token,
|
|
1082
1298
|
amount,
|
|
1083
|
-
|
|
1299
|
+
to?.kind === 'code'
|
|
1300
|
+
? { burnAddress: address, code: to.code }
|
|
1301
|
+
: to
|
|
1302
|
+
? { burnAddress: address, to: to.address }
|
|
1303
|
+
: { burnAddress: address },
|
|
1084
1304
|
);
|
|
1085
1305
|
await this.#ctx.save();
|
|
1086
1306
|
try {
|
|
@@ -1153,11 +1373,7 @@ export class Wallet {
|
|
|
1153
1373
|
async #burnFor(token: Token, amount: bigint, to: Address, searching: () => void): Promise<Hex> {
|
|
1154
1374
|
const ctx = this.#ctx;
|
|
1155
1375
|
const recipient = await lookupRecipient(ctx.storage, hexToBytes(to));
|
|
1156
|
-
if (!recipient)
|
|
1157
|
-
fail(
|
|
1158
|
-
'recipient-not-registered',
|
|
1159
|
-
`${to} cannot be paid privately: withdraw to it publicly, or ask for a receive code`,
|
|
1160
|
-
);
|
|
1376
|
+
if (!recipient) fail('recipient-not-registered', `${to} never opened a zERC20 wallet: ask for a receive code`);
|
|
1161
1377
|
searching();
|
|
1162
1378
|
const burn = await burnAddressFor(token.chainId, bytesToHex(recipient!.publicKey), this.#closed.signal);
|
|
1163
1379
|
// with the address, so that every device of this wallet finds the transfer it then sends
|
|
@@ -1179,26 +1395,35 @@ export class Wallet {
|
|
|
1179
1395
|
/** An address that takes `token`'s underlying asset from anywhere (an exchange withdrawal, another wallet) and
|
|
1180
1396
|
* deposits it privately, less `fee` (in the underlying's base units) to the deposit relayer each time: what arrives
|
|
1181
1397
|
* must exceed it. A wallet opened with a `referrer` also pays it its `bps` of the rest. Watched for 7 days by the
|
|
1182
|
-
* deposit relayer
|
|
1398
|
+
* deposit relayer. It names no account: its refunds go where a key of its own signs for (`rageExit`'s `to`,
|
|
1399
|
+
* `refundDepositAddress`). `depositAddresses()` lists it. `onStep` hears what it waits on (`queued`,
|
|
1400
|
+
* `burn-address`). */
|
|
1183
1401
|
depositAddress(
|
|
1184
1402
|
token: Token,
|
|
1185
1403
|
opts: { onStep?: (step: DepositStep) => void } = {},
|
|
1186
1404
|
): Promise<{ address: Address; fee: bigint }> {
|
|
1187
1405
|
const step = opts.onStep ?? (() => {});
|
|
1406
|
+
const ctx = this.#ctx;
|
|
1407
|
+
const code = (index: number) => ({ chainId: token.chainId, tokenId: token.tokenId, index });
|
|
1408
|
+
// the seed is asked for only past the refund signers derived ahead
|
|
1409
|
+
const derived = !!refundSigner(ctx, code(nextBurn(ctx, token.chainId, token.tokenId)));
|
|
1188
1410
|
if (this.#busy > 0) step('queued');
|
|
1189
|
-
return this.#
|
|
1190
|
-
|
|
1191
|
-
const c = chainCtx(this.#ctx, token.chainId);
|
|
1411
|
+
return this.#seeded(async () => {
|
|
1412
|
+
const c = chainCtx(ctx, token.chainId);
|
|
1192
1413
|
const record = await this.#burnAddress(token, null, () => step('burn-address'));
|
|
1193
|
-
|
|
1414
|
+
if (!refundSigner(ctx, code(record.burnIndex))) await this.#seed(); // calls queued before took the next ones
|
|
1415
|
+
const signer = refundSigner(ctx, code(record.burnIndex))!;
|
|
1416
|
+
const referrer = this.#referrer ?? { address: zeroAddress, bps: 0 };
|
|
1417
|
+
const { depositAddress, feeRecipient, fee } = await registerDeposit(
|
|
1194
1418
|
c.chain,
|
|
1195
1419
|
token.address,
|
|
1196
1420
|
record.address,
|
|
1197
|
-
|
|
1198
|
-
|
|
1421
|
+
{ signer },
|
|
1422
|
+
referrer,
|
|
1199
1423
|
);
|
|
1200
1424
|
record.depositAddress = depositAddress;
|
|
1201
|
-
// for the wallet's other devices, to show what comes through it
|
|
1425
|
+
// for the wallet's other devices, to show what comes through it and what it takes, and for a flush without the
|
|
1426
|
+
// relayer: written by the next sync
|
|
1202
1427
|
const burnAddress = record.address.toLowerCase() as Hex;
|
|
1203
1428
|
this.#ctx.state.receipts.push({
|
|
1204
1429
|
kind: 'deposit-address',
|
|
@@ -1206,12 +1431,48 @@ export class Wallet {
|
|
|
1206
1431
|
tokenId: token.tokenId,
|
|
1207
1432
|
burnAddress,
|
|
1208
1433
|
depositAddress,
|
|
1434
|
+
args: { fee, feeRecipient, refundSigner: signer, referrer: referrer.address, referralBps: referrer.bps },
|
|
1209
1435
|
atMs: Date.now(),
|
|
1210
1436
|
unwritten: true,
|
|
1211
1437
|
});
|
|
1212
1438
|
await this.#ctx.save();
|
|
1213
1439
|
return { address: depositAddress, fee };
|
|
1214
|
-
});
|
|
1440
|
+
}, !derived);
|
|
1441
|
+
}
|
|
1442
|
+
|
|
1443
|
+
/** Has the deposit relayer watch deposit address `address` of this wallet's for another 7 days, at the fee it was
|
|
1444
|
+
* handed out with: what reached it after its 7 days is deposited then, and so is what reaches it within the next.
|
|
1445
|
+
* `depositAddresses()` dates it from now, on every device. */
|
|
1446
|
+
rewatchDepositAddress(address: Address): Promise<{ address: Address; fee: bigint }> {
|
|
1447
|
+
return this.#seeded(async () => {
|
|
1448
|
+
const receipt = this.#depositReceipt(address);
|
|
1449
|
+
const fee = await rewatchDeposit(chainCtx(this.#ctx, receipt.chainId).chain, getAddress(address));
|
|
1450
|
+
// for every device of the wallet, written by the next sync: storage dates it anew
|
|
1451
|
+
receipt.atMs = Date.now();
|
|
1452
|
+
receipt.unwritten = true;
|
|
1453
|
+
await this.#ctx.save();
|
|
1454
|
+
return { address: getAddress(address), fee };
|
|
1455
|
+
}, false);
|
|
1456
|
+
}
|
|
1457
|
+
|
|
1458
|
+
/** Deposits what deposit address `address` of this wallet's holds, as the deposit relayer would, for when it does
|
|
1459
|
+
* not: down, or no longer watching the address. Its receipt says all the address was registered with, wherever its
|
|
1460
|
+
* `fee` is known (`depositAddresses()`), so no relayer is asked. `from`, another wallet of the user's, sends the
|
|
1461
|
+
* flush and pays its gas, and shows on chain as the depositor: never the account's own, which the address does not
|
|
1462
|
+
* name. What it holds must exceed the fee (`insufficient-funds`). It shows in `activity()` as any deposit through
|
|
1463
|
+
* the address. */
|
|
1464
|
+
flushDepositAddress(address: Address, opts: { from: WalletClient }): Promise<Hex> {
|
|
1465
|
+
if (!opts.from.account) fail('invalid', 'the wallet client has no account');
|
|
1466
|
+
return this.#seeded(() => flushDepositAddress(this.#ctx, this.#depositReceipt(address), opts.from), false);
|
|
1467
|
+
}
|
|
1468
|
+
|
|
1469
|
+
/** The receipt of deposit address `address` of this wallet's. */
|
|
1470
|
+
#depositReceipt(address: Address): DepositAddressReceipt {
|
|
1471
|
+
return (
|
|
1472
|
+
this.#ctx.state.receipts.find(
|
|
1473
|
+
(r): r is DepositAddressReceipt => r.kind === 'deposit-address' && isAddressEqual(r.depositAddress, address),
|
|
1474
|
+
) ?? fail('invalid', `${address} is not a deposit address of this wallet's`)
|
|
1475
|
+
);
|
|
1215
1476
|
}
|
|
1216
1477
|
|
|
1217
1478
|
/** A receive code for `token`, to hand to one payer: it pays any amount, as often as it likes, publicly by a transfer
|
|
@@ -1257,18 +1518,29 @@ export class Wallet {
|
|
|
1257
1518
|
if (opts.kind === 'consolidate') {
|
|
1258
1519
|
const chosen = opts.coins && this.#chosen(token, opts.coins);
|
|
1259
1520
|
if (chosen && chosenSettling(this.#ctx, token.chainId, chosen, all)) {
|
|
1260
|
-
|
|
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(
|
|
1261
1526
|
'funds-settling',
|
|
1262
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,
|
|
1263
1529
|
);
|
|
1264
1530
|
}
|
|
1265
1531
|
const steps = BigInt(roundsToMerge(mergePool(all, chosen)));
|
|
1266
1532
|
// a merge pays the relayer publicly, so the protocol takes its cut of that, grossed up as a payment's is
|
|
1267
1533
|
const cut = steps === 0n ? 0n : (await grossed(this.#ctx, token.chainId, token.tokenId, relayer)) - relayer;
|
|
1268
|
-
return {
|
|
1534
|
+
return {
|
|
1535
|
+
relayer: relayer * steps,
|
|
1536
|
+
protocol: cut * steps,
|
|
1537
|
+
protocolBps: bpsOf(cut, relayer + cut),
|
|
1538
|
+
fee: (relayer + cut) * steps,
|
|
1539
|
+
steps: Number(steps),
|
|
1540
|
+
};
|
|
1269
1541
|
}
|
|
1270
1542
|
// as the payment's plan reckons it, so that `fee` is a `maxFee` it passes
|
|
1271
|
-
const { protocol } = await withdrawalOf(
|
|
1543
|
+
const { withdrawn, protocol } = await withdrawalOf(
|
|
1272
1544
|
this.#ctx,
|
|
1273
1545
|
token.chainId,
|
|
1274
1546
|
token.tokenId,
|
|
@@ -1279,13 +1551,27 @@ export class Wallet {
|
|
|
1279
1551
|
const onTheWay =
|
|
1280
1552
|
this.#ctx.state.ops.some((o) => o.status === 'pending') ||
|
|
1281
1553
|
settling(this.#ctx, token.chainId, token.tokenId, size);
|
|
1282
|
-
|
|
1283
|
-
|
|
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
|
+
}
|
|
1561
|
+
return { relayer, protocol, protocolBps: bpsOf(protocol, withdrawn), fee: relayer + protocol, steps: 1 };
|
|
1562
|
+
}
|
|
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')));
|
|
1284
1570
|
}
|
|
1285
1571
|
|
|
1286
1572
|
/** The most a send or withdrawal of `token` can pay: what is spendable, less the fees `fees` quotes on top of it, and
|
|
1287
1573
|
* the merges first when one JoinSplit cannot spend it all, which `fees` then asks for (`needs-consolidation`). */
|
|
1288
|
-
async maxAmount(opts: { kind: 'send' | 'withdraw'; token: Token }): Promise<
|
|
1574
|
+
async maxAmount(opts: { kind: 'send' | 'withdraw'; token: Token }): Promise<MaxAmount> {
|
|
1289
1575
|
const { token } = opts;
|
|
1290
1576
|
const c = chainCtx(this.#ctx, token.chainId);
|
|
1291
1577
|
const relayer =
|
|
@@ -1314,9 +1600,16 @@ export class Wallet {
|
|
|
1314
1600
|
return merge && merge.map((m) => `${token.chainId}:${m.index}`);
|
|
1315
1601
|
}
|
|
1316
1602
|
|
|
1317
|
-
/**
|
|
1318
|
-
*
|
|
1319
|
-
|
|
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
|
+
|
|
1609
|
+
/** Pays `amount` privately: to an account that has registered (any wallet that ever opened), by its address or its
|
|
1610
|
+
* zERC20 ID (`accountId`), or to a receive code (`receiveCode`). An account that never registered fails with
|
|
1611
|
+
* `recipient-not-registered`. One JoinSplit from what is spendable now, or it fails here: `needs-consolidation`,
|
|
1612
|
+
* `funds-settling` or `insufficient-funds`.
|
|
1320
1613
|
* `amount` is what the recipient gets, and `maxFee` caps what it pays on top, `fees({ kind: 'send' }).fee`: the
|
|
1321
1614
|
* relayer names its fee, so a quote the user saw is the cap. */
|
|
1322
1615
|
async send(opts: { to: string; token: Token; amount: bigint; maxFee: bigint }): Promise<Operation> {
|
|
@@ -1324,22 +1617,14 @@ export class Wallet {
|
|
|
1324
1617
|
if (amount <= 0n) fail('invalid', 'the amount must be positive');
|
|
1325
1618
|
// the JoinSplit is proved by a later sync, which holds the seed until the payment is done
|
|
1326
1619
|
return this.#seeded(async () => {
|
|
1620
|
+
const r = recipientOf(opts.to, token);
|
|
1327
1621
|
let to: Destination;
|
|
1328
|
-
if (
|
|
1329
|
-
const recipient = await lookupRecipient(this.#ctx.storage, hexToBytes(
|
|
1622
|
+
if (r.kind === 'address') {
|
|
1623
|
+
const recipient = await lookupRecipient(this.#ctx.storage, hexToBytes(r.address));
|
|
1330
1624
|
if (!recipient)
|
|
1331
|
-
fail(
|
|
1332
|
-
'recipient-not-registered',
|
|
1333
|
-
`${opts.to} cannot be paid privately: withdraw to it publicly, or ask for a receive code`,
|
|
1334
|
-
);
|
|
1625
|
+
fail('recipient-not-registered', `${opts.to.trim()} never opened a zERC20 wallet: ask for a receive code`);
|
|
1335
1626
|
to = { kind: 'address', recipient: recipient! };
|
|
1336
|
-
} else {
|
|
1337
|
-
const code = decodeCode(opts.to);
|
|
1338
|
-
if (!code) return fail('invalid', 'not an address or a receive code');
|
|
1339
|
-
if (code.chainId !== token.chainId || code.tokenId !== token.tokenId)
|
|
1340
|
-
fail('invalid', 'the receive code is for another token');
|
|
1341
|
-
to = { kind: 'code', code: opts.to.trim() };
|
|
1342
|
-
}
|
|
1627
|
+
} else to = { kind: 'code', code: r.code };
|
|
1343
1628
|
return this.#newPayment('send', token, amount, { to, maxFee: opts.maxFee, step: null, paid: 0n });
|
|
1344
1629
|
});
|
|
1345
1630
|
}
|
|
@@ -1412,6 +1697,21 @@ export class Wallet {
|
|
|
1412
1697
|
}
|
|
1413
1698
|
}
|
|
1414
1699
|
|
|
1700
|
+
/** Whom `to` names, as `send` and `deposit` take it: an account, by its address or its zERC20 ID, or a receive code of
|
|
1701
|
+
* `token`'s. */
|
|
1702
|
+
function recipientOf(
|
|
1703
|
+
to: string,
|
|
1704
|
+
token: Token,
|
|
1705
|
+
): { kind: 'address'; address: Address } | { kind: 'code'; code: string; burnAddress: Hex } {
|
|
1706
|
+
const address = isAddress(to) ? to : idAccount(to);
|
|
1707
|
+
if (address) return { kind: 'address', address };
|
|
1708
|
+
const code = decodeCode(to);
|
|
1709
|
+
if (!code) return fail('invalid', 'not an address, a zERC20 ID or a receive code');
|
|
1710
|
+
if (code.chainId !== token.chainId || code.tokenId !== token.tokenId)
|
|
1711
|
+
fail('invalid', 'the receive code is for another token');
|
|
1712
|
+
return { kind: 'code', code: to.trim(), burnAddress: bytesToHex(code.burnAddress) };
|
|
1713
|
+
}
|
|
1714
|
+
|
|
1415
1715
|
/** Each chain of `network`, with its servers. */
|
|
1416
1716
|
const chainsOf = (network: Network): ChainCtx[] =>
|
|
1417
1717
|
network.chains.map((config) => ({
|
|
@@ -1421,22 +1721,50 @@ const chainsOf = (network: Network): ChainCtx[] =>
|
|
|
1421
1721
|
asp: new Asp(config.asp),
|
|
1422
1722
|
}));
|
|
1423
1723
|
|
|
1424
|
-
/**
|
|
1425
|
-
*
|
|
1426
|
-
|
|
1427
|
-
|
|
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();
|
|
1428
1740
|
const locks = (globalThis as { navigator?: Navigator }).navigator?.locks;
|
|
1429
|
-
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'));
|
|
1430
1744
|
let release = () => {};
|
|
1431
1745
|
const held = new Promise<void>((r) => (release = r));
|
|
1432
|
-
|
|
1433
|
-
|
|
1434
|
-
|
|
1435
|
-
|
|
1436
|
-
|
|
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)));
|
|
1437
1756
|
});
|
|
1438
1757
|
if (!got) fail('wallet-in-use', 'this wallet is open in another tab or window: close it there first');
|
|
1439
|
-
|
|
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 };
|
|
1440
1768
|
}
|
|
1441
1769
|
|
|
1442
1770
|
/** A deposit `deposit()` did not see through (a reload, a lost connection): settled by its transaction's receipt, or
|