@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/dist/wallet.js
CHANGED
|
@@ -1,21 +1,40 @@
|
|
|
1
1
|
// The wallet an app works with. Amounts are bigints in a token's base units.
|
|
2
2
|
import { CanisterClient, codeKey, commit, decodeCode, encodeCode, lookupRecipient, nextPosLane, notifyKey, planDeposit, planPosLane, posKey, registeredViewKey, } from '@zerc20/storage-sdk';
|
|
3
|
-
import { bytesToHex, formatUnits, getAddress, hexToBytes, isAddress, numberToHex, zeroAddress, } from 'viem';
|
|
3
|
+
import { bytesToHex, formatUnits, getAddress, hexToBytes, isAddress, isAddressEqual, numberToHex, zeroAddress, } from 'viem';
|
|
4
4
|
import { toAccount } from 'viem/accounts';
|
|
5
|
+
import { idAccount } from './accountId.js';
|
|
5
6
|
import { Chain, MAX_REFERRAL_BPS, NATIVE, erc20Abi, tokenAbi } from './chain.js';
|
|
6
|
-
import { chainCtx, chainState, owner, switched, walletOn, walletSees, } from './context.js';
|
|
7
|
+
import { chainCtx, chainState, owner, refundSigner, switched, walletOn, walletSees, } from './context.js';
|
|
7
8
|
import { burnAddressFor, core, initCore } from './core.js';
|
|
8
9
|
import { WalletError, fail, guard } from './errors.js';
|
|
9
10
|
import { checkNetwork } from './network.js';
|
|
11
|
+
import { preload } from './prove.js';
|
|
10
12
|
import { Asp, Indexer, PAGE } from './servers.js';
|
|
11
|
-
import { rageExit } from './rageExit.js';
|
|
12
|
-
import { registerDeposit, relayerFees } from './services.js';
|
|
13
|
-
import { advanceSpend, chosenSettling, coins, grossed, inputsOf, landed, maxPayment, mergePool, mergeRound, payWith, planStep, roundsToMerge, settling, withdrawalOf, } from './spend.js';
|
|
13
|
+
import { flushDepositAddress, rageExit, refundDepositAddress } from './rageExit.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';
|
|
14
16
|
import { emptyWalletState, openState, sealState, stateKey, unspent, } from './state.js';
|
|
15
17
|
import { WAIT_MS, commitmentTx, keyAll, nextBurn, recordBurn, storageState, syncAll, takeRefresh, tokenIds, topUpBurns, unusedBurn, } from './sync.js';
|
|
16
18
|
import { decodeViewKey, encodeViewKey } from './viewKey.js';
|
|
17
19
|
/** The ledger's bound outside an emergency. */
|
|
18
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
|
+
}
|
|
19
38
|
export class Wallet {
|
|
20
39
|
#ctx;
|
|
21
40
|
#tokens = [];
|
|
@@ -23,21 +42,30 @@ export class Wallet {
|
|
|
23
42
|
#queue = Promise.resolve();
|
|
24
43
|
/** Calls running or queued. */
|
|
25
44
|
#busy = 0;
|
|
26
|
-
#
|
|
45
|
+
#lock;
|
|
27
46
|
#referrer;
|
|
28
47
|
#unlock;
|
|
29
48
|
#readOnly = false;
|
|
30
49
|
#closed = new AbortController();
|
|
31
|
-
|
|
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() }) {
|
|
32
52
|
this.#ctx = ctx;
|
|
33
53
|
this.#unlock = unlock;
|
|
54
|
+
this.#lock = lock;
|
|
55
|
+
lock.taken.addEventListener('abort', () => this.#closed.abort(lock.taken.reason));
|
|
34
56
|
}
|
|
35
|
-
/** Lets this wallet
|
|
57
|
+
/** Lets this wallet go, for another tab or another account here. This object is done with: what is
|
|
36
58
|
* queued fails with `wallet-closed`, a burn address search stops, and the wallet is let go once the task under way
|
|
37
59
|
* ends, so that two never write its state at once. */
|
|
38
60
|
close() {
|
|
39
61
|
this.#closed.abort(new WalletError('wallet-closed', 'this wallet was closed: open it again'));
|
|
40
|
-
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;
|
|
41
69
|
}
|
|
42
70
|
get address() {
|
|
43
71
|
return this.#ctx.address;
|
|
@@ -72,16 +100,16 @@ export class Wallet {
|
|
|
72
100
|
if (seed.length !== 32)
|
|
73
101
|
fail('invalid', 'the seed is 32 bytes');
|
|
74
102
|
await initCore();
|
|
75
|
-
const
|
|
103
|
+
const hold = await lockWallet(account.address, 'wallet', opts.take);
|
|
76
104
|
try {
|
|
77
|
-
return await Wallet.#open(opts, seed, account,
|
|
105
|
+
return await Wallet.#open(opts, seed, account, hold);
|
|
78
106
|
}
|
|
79
107
|
catch (e) {
|
|
80
|
-
release();
|
|
108
|
+
hold.release();
|
|
81
109
|
throw e;
|
|
82
110
|
}
|
|
83
111
|
}
|
|
84
|
-
static async #open(opts, seed, account,
|
|
112
|
+
static async #open(opts, seed, account, hold) {
|
|
85
113
|
const address = account.address;
|
|
86
114
|
const chains = chainsOf(opts.network);
|
|
87
115
|
const code = await chains[0]?.chain.client.getCode({ address });
|
|
@@ -93,7 +121,7 @@ export class Wallet {
|
|
|
93
121
|
: opts.walletClient.signMessage({ account, message })));
|
|
94
122
|
const storage = await CanisterClient.connect(opts.network.storage.host, opts.network.storage.canisterId);
|
|
95
123
|
const publicKey = hexToBytes(core.spendKey(seed).publicKey);
|
|
96
|
-
const saved = await opts.persist.load();
|
|
124
|
+
const saved = await hold.guard(() => opts.persist.load());
|
|
97
125
|
// registered already: used before, here or on another device, so its past is looked for
|
|
98
126
|
// ponytail: from genesis (~4 MB per million commitments); ask for a birthday once the chain has many
|
|
99
127
|
const restoring = !saved?.length && opts.birthday === undefined && !!(await storage.registration(hexToBytes(address)));
|
|
@@ -102,14 +130,14 @@ export class Wallet {
|
|
|
102
130
|
network: opts.network,
|
|
103
131
|
seed,
|
|
104
132
|
owners: new Map(),
|
|
133
|
+
refundSigners: new Map(),
|
|
105
134
|
address,
|
|
106
135
|
account,
|
|
107
136
|
walletClient: opts.walletClient ?? null,
|
|
108
137
|
storage,
|
|
109
138
|
view,
|
|
110
139
|
chains,
|
|
111
|
-
}, { persist: opts.persist, saved, key: stateKey(seed), from: restoring ? 0n : opts.birthday }, typeof opts.seed === 'function' ? opts.seed : undefined);
|
|
112
|
-
wallet.#release = release;
|
|
140
|
+
}, { persist: opts.persist, saved, key: stateKey(seed), from: restoring ? 0n : opts.birthday, hold }, typeof opts.seed === 'function' ? opts.seed : undefined);
|
|
113
141
|
wallet.#referrer = opts.referrer;
|
|
114
142
|
return wallet;
|
|
115
143
|
}
|
|
@@ -121,7 +149,7 @@ export class Wallet {
|
|
|
121
149
|
await initCore();
|
|
122
150
|
const { view, owner } = decodeViewKey(opts.viewKey) ?? fail('invalid', 'not a view key');
|
|
123
151
|
const address = getAddress(bytesToHex(view.address));
|
|
124
|
-
const
|
|
152
|
+
const hold = await lockWallet(address, 'view');
|
|
125
153
|
try {
|
|
126
154
|
const storage = await CanisterClient.connect(opts.network.storage.host, opts.network.storage.canisterId);
|
|
127
155
|
// what the account signed: a nullifying key that is not the account's would show spent coins as unspent
|
|
@@ -132,6 +160,7 @@ export class Wallet {
|
|
|
132
160
|
network: opts.network,
|
|
133
161
|
seed: null,
|
|
134
162
|
owners: new Map([['', owner]]),
|
|
163
|
+
refundSigners: new Map(),
|
|
135
164
|
address,
|
|
136
165
|
// signs nothing: every call that would send is refused first
|
|
137
166
|
account: toAccount(address),
|
|
@@ -143,21 +172,22 @@ export class Wallet {
|
|
|
143
172
|
// registered, so its past is looked for
|
|
144
173
|
{
|
|
145
174
|
persist: opts.persist,
|
|
146
|
-
saved: await opts.persist.load(),
|
|
175
|
+
saved: await hold.guard(() => opts.persist.load()),
|
|
147
176
|
key: stateKey(view.seed),
|
|
148
177
|
from: opts.birthday ?? 0n,
|
|
178
|
+
hold,
|
|
149
179
|
});
|
|
150
|
-
wallet.#release = release;
|
|
151
180
|
wallet.#readOnly = true;
|
|
152
181
|
return wallet;
|
|
153
182
|
}
|
|
154
183
|
catch (e) {
|
|
155
|
-
release();
|
|
184
|
+
hold.release();
|
|
156
185
|
throw e;
|
|
157
186
|
}
|
|
158
187
|
}
|
|
159
|
-
/** The wallet over `base`, its state opened from what `persist` saved, with `key`, or started `from` a block or date
|
|
160
|
-
|
|
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) {
|
|
161
191
|
const loaded = saved?.length ? openState(key, saved) : null;
|
|
162
192
|
// never started over in place: the first save would overwrite what it held, operations in flight included
|
|
163
193
|
if (saved?.length && !loaded) {
|
|
@@ -166,15 +196,18 @@ export class Wallet {
|
|
|
166
196
|
if (loaded && loaded.address.toLowerCase() !== base.address.toLowerCase())
|
|
167
197
|
fail('invalid', `the saved state is ${loaded.address}'s: keep one per address`);
|
|
168
198
|
const state = loaded ?? emptyWalletState(base.address);
|
|
169
|
-
const
|
|
199
|
+
const save = () => hold.guard(() => persist.save(sealState(key, state)));
|
|
200
|
+
const ctx = { ...base, state, save, lastFullRefreshMs: 0 };
|
|
170
201
|
if (!loaded)
|
|
171
202
|
await start(ctx, from);
|
|
172
|
-
const wallet = new Wallet(ctx, unlock);
|
|
203
|
+
const wallet = new Wallet(ctx, unlock, hold);
|
|
173
204
|
// a saved state names its tokens by id, and its coins stand by the chains' roots: read both now, so balances, coins
|
|
174
205
|
// and history work before the first sync
|
|
175
206
|
const [tokens] = await Promise.all([Promise.all(ctx.chains.map((c) => c.chain.tokens())), wallet.#readRoots()]);
|
|
176
207
|
wallet.#tokens = tokens.flat();
|
|
177
208
|
keyAll(ctx, wallet.#tokens);
|
|
209
|
+
// taken while it opened, before it listened
|
|
210
|
+
hold.taken.throwIfAborted();
|
|
178
211
|
return wallet;
|
|
179
212
|
}
|
|
180
213
|
/** Holds `seed`, asked of `#unlock`, once it is checked to be this wallet's. */
|
|
@@ -261,10 +294,8 @@ export class Wallet {
|
|
|
261
294
|
async #catchUp() {
|
|
262
295
|
const errors = await syncAll(this.#ctx);
|
|
263
296
|
errors.push(...(await this.#readRoots()));
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
.catch((e) => (errors.push(e), this.#tokens.filter((t) => t.chainId === c.config.chainId)))));
|
|
267
|
-
this.#tokens = listed.flat();
|
|
297
|
+
// as syncAll read them: a chain it could not read keeps those read before
|
|
298
|
+
this.#tokens = this.#ctx.chains.flatMap((c) => c.chain.known);
|
|
268
299
|
for (const op of this.#ctx.state.ops.filter((o) => o.status === 'pending')) {
|
|
269
300
|
try {
|
|
270
301
|
if (op.kind === 'send' || op.kind === 'withdraw' || op.kind === 'consolidate')
|
|
@@ -274,6 +305,10 @@ export class Wallet {
|
|
|
274
305
|
delete op.retrying;
|
|
275
306
|
}
|
|
276
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;
|
|
277
312
|
// only what trying again cannot change fails an operation: a server or an RPC that does not answer (any
|
|
278
313
|
// error that is not the wallet's own), or answers what the chain does not show yet, is tried next sync. It is
|
|
279
314
|
// kept on the operation, so that one that never gets past it does not look merely slow.
|
|
@@ -357,6 +392,12 @@ export class Wallet {
|
|
|
357
392
|
coins() {
|
|
358
393
|
const size = (chainId) => this.#status.get(chainId)?.latestRoot?.size ?? 0;
|
|
359
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));
|
|
360
401
|
const spending = new Set(this.#ctx.state.ops.flatMap((o) => inputsOf(o).map((c) => `${o.chainId}:${c.index}`)));
|
|
361
402
|
// spent by a JoinSplit in a block, which waits only to be final: gone, as activity() counts it. Sync sees it by its
|
|
362
403
|
// nullifier (`unspent`); this device's own is gone already from its relay on (a reorg that drops it brings them
|
|
@@ -380,7 +421,9 @@ export class Wallet {
|
|
|
380
421
|
amount: n.value,
|
|
381
422
|
state,
|
|
382
423
|
from: n.peer,
|
|
383
|
-
|
|
424
|
+
via: null,
|
|
425
|
+
readyBy: state === 'awaiting-root' ? date(covered(n.chainId, f.atMs)) : null,
|
|
426
|
+
at: f.atMs === undefined ? null : new Date(f.atMs),
|
|
384
427
|
});
|
|
385
428
|
}
|
|
386
429
|
}
|
|
@@ -399,7 +442,13 @@ export class Wallet {
|
|
|
399
442
|
: f.index >= size(d.chainId)
|
|
400
443
|
? unproved(d.chainId)
|
|
401
444
|
: 'spendable';
|
|
402
|
-
|
|
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;
|
|
403
452
|
out.push({
|
|
404
453
|
id,
|
|
405
454
|
kind: 'deposit',
|
|
@@ -407,7 +456,9 @@ export class Wallet {
|
|
|
407
456
|
amount: f.amount,
|
|
408
457
|
state,
|
|
409
458
|
from: f.sender,
|
|
410
|
-
|
|
459
|
+
via: this.#via(d, f.sender),
|
|
460
|
+
readyBy,
|
|
461
|
+
at: f.atMs === undefined ? null : new Date(f.atMs),
|
|
411
462
|
});
|
|
412
463
|
}
|
|
413
464
|
}
|
|
@@ -478,24 +529,17 @@ export class Wallet {
|
|
|
478
529
|
}
|
|
479
530
|
}
|
|
480
531
|
for (const d of s.deposits) {
|
|
481
|
-
// registered on this device, or on another, which says so in storage
|
|
482
|
-
const registered = d.depositAddress ??
|
|
483
|
-
s.receipts.flatMap((r) => r.kind === 'deposit-address' && r.chainId === d.chainId && r.burnAddress === d.address.toLowerCase()
|
|
484
|
-
? [r.depositAddress]
|
|
485
|
-
: [])[0];
|
|
486
532
|
// on chain once found; shown once its commitment is read, which names the amount
|
|
487
533
|
for (const f of d.found.filter((f) => f.amount !== null)) {
|
|
488
|
-
const forwarded = registered && f.sender?.toLowerCase() === registered.toLowerCase();
|
|
489
534
|
// into a burn address a payer chose: theirs to this wallet, as a payment is
|
|
490
535
|
const kind = d.managed ? 'sale' : d.payer ? 'received' : 'deposited';
|
|
491
|
-
const via = forwarded ? { depositAddress: registered } : null;
|
|
492
536
|
const entry = {
|
|
493
537
|
...at(kind, d.chainId, f, d.createdMs),
|
|
494
538
|
token: this.#token(d.chainId, f.tokenId),
|
|
495
539
|
amount: f.amount,
|
|
496
540
|
burnAddress: d.address,
|
|
497
541
|
};
|
|
498
|
-
out.push({ ...entry, fee: null, counterparty: f.sender, via, txHash: null });
|
|
542
|
+
out.push({ ...entry, fee: null, counterparty: f.sender, via: this.#via(d, f.sender), txHash: null });
|
|
499
543
|
}
|
|
500
544
|
}
|
|
501
545
|
// a deposit that paid someone (`deposit({ to })`), once its transfer is found
|
|
@@ -538,9 +582,17 @@ export class Wallet {
|
|
|
538
582
|
const found = s.sent.some((x) => x.chainId === op.chainId &&
|
|
539
583
|
x.address === op.data.burnAddress.toLowerCase() &&
|
|
540
584
|
x.found.some((f) => f.amount !== null));
|
|
541
|
-
|
|
585
|
+
const { to, code, txHash } = op.data;
|
|
586
|
+
if ((!to && !code) || !txHash || op.status === 'pending' || found)
|
|
542
587
|
continue;
|
|
543
|
-
out.push({
|
|
588
|
+
out.push({
|
|
589
|
+
...base,
|
|
590
|
+
kind: 'sent',
|
|
591
|
+
fee: null,
|
|
592
|
+
counterparty: to ?? null,
|
|
593
|
+
via: code ? { code } : null,
|
|
594
|
+
txHash,
|
|
595
|
+
});
|
|
544
596
|
continue;
|
|
545
597
|
}
|
|
546
598
|
// under way (in operations()), or shown by its change
|
|
@@ -558,6 +610,17 @@ export class Wallet {
|
|
|
558
610
|
}
|
|
559
611
|
return out.sort((a, b) => (b.at?.getTime() ?? 0) - (a.at?.getTime() ?? 0));
|
|
560
612
|
}
|
|
613
|
+
/** The deposit address `d` registered (here, or on another device, which says so in storage) if `sender` is it: what
|
|
614
|
+
* a deposit from `sender` came through. */
|
|
615
|
+
#via(d, sender) {
|
|
616
|
+
const registered = d.depositAddress ??
|
|
617
|
+
this.#ctx.state.receipts.flatMap((r) => r.kind === 'deposit-address' && r.chainId === d.chainId && r.burnAddress === d.address.toLowerCase()
|
|
618
|
+
? [r.depositAddress]
|
|
619
|
+
: [])[0];
|
|
620
|
+
return registered && sender && isAddressEqual(sender, registered)
|
|
621
|
+
? { depositAddress: getAddress(registered) }
|
|
622
|
+
: null;
|
|
623
|
+
}
|
|
561
624
|
/** The text of a receive code this wallet paid, by its burn address, from storage's record of paying it. */
|
|
562
625
|
#paidCode(chainId, burnAddress) {
|
|
563
626
|
for (const c of this.#ctx.state.storage?.outgoing.lanes ?? []) {
|
|
@@ -599,6 +662,31 @@ export class Wallet {
|
|
|
599
662
|
state: f.spent ? 'spent' : f.asp === null ? 'screening' : f.asp,
|
|
600
663
|
})));
|
|
601
664
|
}
|
|
665
|
+
/** The deposit addresses this wallet handed out (`depositAddress`), on any of its devices, newest first: for an app to
|
|
666
|
+
* show each while what is sent to it is on its way, which `activity()` shows once deposited. Each is dated, and its
|
|
667
|
+
* fee told, by its receipt in storage, alike on every device. */
|
|
668
|
+
depositAddresses() {
|
|
669
|
+
return this.#ctx.state.receipts
|
|
670
|
+
.flatMap((r) => {
|
|
671
|
+
if (r.kind !== 'deposit-address')
|
|
672
|
+
return [];
|
|
673
|
+
const token = this.#token(r.chainId, r.tokenId);
|
|
674
|
+
return [{ address: getAddress(r.depositAddress), token, fee: r.args?.fee ?? null, at: new Date(r.atMs) }];
|
|
675
|
+
})
|
|
676
|
+
.sort((a, b) => b.at.getTime() - a.at.getTime());
|
|
677
|
+
}
|
|
678
|
+
/** What `address` is to this wallet on `chainId`: one of its own burn addresses (`deposit`), one it paid someone
|
|
679
|
+
* into (`payment`, `deposit({ to })` to an address), or neither (null), as is a receive code's that it paid. On chain
|
|
680
|
+
* either is a plain transfer to an address that names no one: this tells an app listing the account's public
|
|
681
|
+
* transfers which they were. Both are recorded before the transfer is sent. */
|
|
682
|
+
burnAddressOf(chainId, address) {
|
|
683
|
+
const s = this.#ctx.state;
|
|
684
|
+
const a = address.toLowerCase();
|
|
685
|
+
if (s.deposits.some((d) => d.chainId === chainId && d.address.toLowerCase() === a))
|
|
686
|
+
return { kind: 'deposit' };
|
|
687
|
+
const paid = s.sent.find((x) => x.chainId === chainId && x.address.toLowerCase() === a);
|
|
688
|
+
return paid ? { kind: 'payment', to: paid.to } : null;
|
|
689
|
+
}
|
|
602
690
|
/** The payments to the burn addresses this wallet's points of sale handed out. Spent ones are, normally, in a
|
|
603
691
|
* merged note. */
|
|
604
692
|
payments() {
|
|
@@ -622,8 +710,12 @@ export class Wallet {
|
|
|
622
710
|
});
|
|
623
711
|
}
|
|
624
712
|
/** Refunds deposit `id` to its sender without the ASP, the attester or a proved root: for a deposit the ASP
|
|
625
|
-
* rejected, or when those services stop. The account's wallet sends the transactions and pays their gas
|
|
626
|
-
|
|
713
|
+
* rejected, or when those services stop. The account's wallet sends the transactions and pays their gas, or with
|
|
714
|
+
* `from` another wallet of the user's does, as `deposit({ from })` pays. One through a deposit address of this
|
|
715
|
+
* wallet's (`CoinInfo.via`) goes to `to`, by default `from`'s account, else this account; any other goes back to its
|
|
716
|
+
* sender, and one through a point of sale's deposit address to the refund address its payer named. */
|
|
717
|
+
rageExit(id, opts = {}) {
|
|
718
|
+
const route = this.#route(opts);
|
|
627
719
|
return this.#seeded(async () => {
|
|
628
720
|
const [chain, index] = id.split(':');
|
|
629
721
|
const record = this.#ctx.state.deposits.find((d) => `${d.chainId}` === chain && d.found.some((f) => `${f.index}` === index)) ??
|
|
@@ -632,9 +724,32 @@ export class Wallet {
|
|
|
632
724
|
const op = this.#ctx.state.ops.find((o) => `${o.chainId}` === chain && inputsOf(o).some((c) => `${c.index}` === index));
|
|
633
725
|
if (op)
|
|
634
726
|
fail('invalid', `deposit ${id} is an input of operation ${op.id}, which is in flight`);
|
|
635
|
-
return rageExit(this.#ctx, record, Number(index));
|
|
727
|
+
return rageExit(this.#ctx, record, Number(index), route);
|
|
636
728
|
});
|
|
637
729
|
}
|
|
730
|
+
/** Sends what deposit address `address` of this wallet's holds of `asset` (NATIVE for the chain's own currency) to
|
|
731
|
+
* `to`: another token sent to it, which it cannot wrap. What it can wrap is wrapped instead, as a payment to it is.
|
|
732
|
+
* It deploys on its first flush, and refunds nothing before. `from` and `to` as for `rageExit`. */
|
|
733
|
+
refundDepositAddress(address, asset, opts = {}) {
|
|
734
|
+
if (!isAddress(asset, { strict: false }) || asset.toLowerCase() === zeroAddress)
|
|
735
|
+
fail('invalid', `${asset} is not an asset's address`);
|
|
736
|
+
const route = this.#route(opts);
|
|
737
|
+
return this.#seeded(async () => {
|
|
738
|
+
const record = this.#ctx.state.deposits.find((d) => !d.managed && this.#via(d, address)) ??
|
|
739
|
+
fail('invalid', `${address} is not a deposit address of this wallet's`);
|
|
740
|
+
return refundDepositAddress(this.#ctx, record, getAddress(address), getAddress(asset), route);
|
|
741
|
+
});
|
|
742
|
+
}
|
|
743
|
+
/** Who sends a refund and where it goes (`rageExit`), checked before the seed is asked for. */
|
|
744
|
+
#route(opts) {
|
|
745
|
+
const from = opts.from ?? null;
|
|
746
|
+
if (from && !from.account)
|
|
747
|
+
fail('invalid', 'the wallet client has no account');
|
|
748
|
+
const to = opts.to ?? from?.account?.address ?? this.address;
|
|
749
|
+
if (!isAddress(to, { strict: false }) || to.toLowerCase() === zeroAddress)
|
|
750
|
+
fail('invalid', `${to} is not an address to refund to`);
|
|
751
|
+
return { from, to: getAddress(to) };
|
|
752
|
+
}
|
|
638
753
|
operations() {
|
|
639
754
|
return this.#ctx.state.ops.map((op) => this.#operation(op));
|
|
640
755
|
}
|
|
@@ -663,6 +778,9 @@ export class Wallet {
|
|
|
663
778
|
return op.kind === 'deposit' ? null : (op.data.step?.stage ?? null);
|
|
664
779
|
},
|
|
665
780
|
to: op.kind === 'deposit' ? (op.data.to ?? null) : null,
|
|
781
|
+
get cancellable() {
|
|
782
|
+
return cancellable(op);
|
|
783
|
+
},
|
|
666
784
|
wait: async () => {
|
|
667
785
|
for (;;) {
|
|
668
786
|
if (op.status === 'done')
|
|
@@ -674,6 +792,31 @@ export class Wallet {
|
|
|
674
792
|
await new Promise((r) => setTimeout(r, 15_000));
|
|
675
793
|
}
|
|
676
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
|
+
},
|
|
677
820
|
};
|
|
678
821
|
}
|
|
679
822
|
#newOp(kind, token, amount, data) {
|
|
@@ -699,7 +842,9 @@ export class Wallet {
|
|
|
699
842
|
let record = unusedBurn(ctx, token.chainId, token.tokenId);
|
|
700
843
|
if (!record) {
|
|
701
844
|
recording();
|
|
702
|
-
record = await recordBurn(ctx, token.chainId, token.tokenId, await tokenIds(ctx),
|
|
845
|
+
record = await recordBurn(ctx, token.chainId, token.tokenId, await tokenIds(ctx), {
|
|
846
|
+
signal: this.#closed.signal,
|
|
847
|
+
});
|
|
703
848
|
}
|
|
704
849
|
const next = chainState(ctx, token.chainId).burnNext;
|
|
705
850
|
// recording may have read another device's later ones
|
|
@@ -713,10 +858,12 @@ export class Wallet {
|
|
|
713
858
|
* burn address (approving it first if needed). The deposit is spendable once the ASP has screened it. More than the
|
|
714
859
|
* account holds fails at once (`insufficient-funds`), before anything is asked of the wallet.
|
|
715
860
|
*
|
|
716
|
-
* With `to`, an
|
|
717
|
-
* under the public key they registered, told to them in storage first as a note is
|
|
718
|
-
* receive it as a deposit of their own, screened like any; the burn address names no one. It
|
|
719
|
-
* as sent. Finding the address takes about 2^16 hashes (`burn-address`).
|
|
861
|
+
* With `to`, an account that registered, by its address or its zERC20 ID, it pays them instead, privately: into a
|
|
862
|
+
* fresh burn address of theirs, under the public key they registered, told to them in storage first as a note is
|
|
863
|
+
* (`planDeposit`). They receive it as a deposit of their own, screened like any; the burn address names no one. It
|
|
864
|
+
* shows in `activity()` as sent. Finding the address takes about 2^16 hashes (`burn-address`). Or `to` is a receive
|
|
865
|
+
* code (`receiveCode`), paid into its burn address, which its owner already watches: storage is told nothing, so
|
|
866
|
+
* only this device's `activity()` shows it as sent.
|
|
720
867
|
*
|
|
721
868
|
* With `wrapped`, it pays from the account's zERC20 itself, as a withdrawal pays out, rather than its underlying
|
|
722
869
|
* asset: a plain transfer into the burn address, with no wrap and no approval.
|
|
@@ -731,6 +878,7 @@ export class Wallet {
|
|
|
731
878
|
if (opts.amount <= 0n)
|
|
732
879
|
fail('invalid', 'the amount must be positive');
|
|
733
880
|
const payer = opts.from ? (opts.from.account ?? fail('invalid', 'the wallet client has no account')) : null;
|
|
881
|
+
const to = opts.to === undefined ? null : recipientOf(opts.to, opts.token);
|
|
734
882
|
const step = opts.onStep ?? (() => { });
|
|
735
883
|
if (this.#busy > 0)
|
|
736
884
|
step('queued');
|
|
@@ -753,10 +901,16 @@ export class Wallet {
|
|
|
753
901
|
const of = wrapped ? token.symbol : `${token.symbol}'s underlying asset`;
|
|
754
902
|
fail('insufficient-funds', `${payer ? 'the wallet' : 'the account'} holds ${formatUnits(held, token.decimals)} of ${of}, less than ${formatUnits(amount, token.decimals)}`);
|
|
755
903
|
}
|
|
756
|
-
const address =
|
|
757
|
-
?
|
|
758
|
-
:
|
|
759
|
-
|
|
904
|
+
const address = to?.kind === 'code'
|
|
905
|
+
? to.burnAddress
|
|
906
|
+
: to
|
|
907
|
+
? await this.#burnFor(token, amount, to.address, () => step('burn-address'))
|
|
908
|
+
: (await this.#burnAddress(token, null, () => step('burn-address'))).address;
|
|
909
|
+
const op = this.#newOp('deposit', token, amount, to?.kind === 'code'
|
|
910
|
+
? { burnAddress: address, code: to.code }
|
|
911
|
+
: to
|
|
912
|
+
? { burnAddress: address, to: to.address }
|
|
913
|
+
: { burnAddress: address });
|
|
760
914
|
await this.#ctx.save();
|
|
761
915
|
try {
|
|
762
916
|
const wc = opts.from ? await switched(opts.from, token.chainId) : await walletOn(this.#ctx, token.chainId);
|
|
@@ -826,7 +980,7 @@ export class Wallet {
|
|
|
826
980
|
const ctx = this.#ctx;
|
|
827
981
|
const recipient = await lookupRecipient(ctx.storage, hexToBytes(to));
|
|
828
982
|
if (!recipient)
|
|
829
|
-
fail('recipient-not-registered', `${to}
|
|
983
|
+
fail('recipient-not-registered', `${to} never opened a zERC20 wallet: ask for a receive code`);
|
|
830
984
|
searching();
|
|
831
985
|
const burn = await burnAddressFor(token.chainId, bytesToHex(recipient.publicKey), this.#closed.signal);
|
|
832
986
|
// with the address, so that every device of this wallet finds the transfer it then sends
|
|
@@ -845,18 +999,28 @@ export class Wallet {
|
|
|
845
999
|
/** An address that takes `token`'s underlying asset from anywhere (an exchange withdrawal, another wallet) and
|
|
846
1000
|
* deposits it privately, less `fee` (in the underlying's base units) to the deposit relayer each time: what arrives
|
|
847
1001
|
* must exceed it. A wallet opened with a `referrer` also pays it its `bps` of the rest. Watched for 7 days by the
|
|
848
|
-
* deposit relayer
|
|
1002
|
+
* deposit relayer. It names no account: its refunds go where a key of its own signs for (`rageExit`'s `to`,
|
|
1003
|
+
* `refundDepositAddress`). `depositAddresses()` lists it. `onStep` hears what it waits on (`queued`,
|
|
1004
|
+
* `burn-address`). */
|
|
849
1005
|
depositAddress(token, opts = {}) {
|
|
850
1006
|
const step = opts.onStep ?? (() => { });
|
|
1007
|
+
const ctx = this.#ctx;
|
|
1008
|
+
const code = (index) => ({ chainId: token.chainId, tokenId: token.tokenId, index });
|
|
1009
|
+
// the seed is asked for only past the refund signers derived ahead
|
|
1010
|
+
const derived = !!refundSigner(ctx, code(nextBurn(ctx, token.chainId, token.tokenId)));
|
|
851
1011
|
if (this.#busy > 0)
|
|
852
1012
|
step('queued');
|
|
853
|
-
return this.#
|
|
854
|
-
|
|
855
|
-
const c = chainCtx(this.#ctx, token.chainId);
|
|
1013
|
+
return this.#seeded(async () => {
|
|
1014
|
+
const c = chainCtx(ctx, token.chainId);
|
|
856
1015
|
const record = await this.#burnAddress(token, null, () => step('burn-address'));
|
|
857
|
-
|
|
1016
|
+
if (!refundSigner(ctx, code(record.burnIndex)))
|
|
1017
|
+
await this.#seed(); // calls queued before took the next ones
|
|
1018
|
+
const signer = refundSigner(ctx, code(record.burnIndex));
|
|
1019
|
+
const referrer = this.#referrer ?? { address: zeroAddress, bps: 0 };
|
|
1020
|
+
const { depositAddress, feeRecipient, fee } = await registerDeposit(c.chain, token.address, record.address, { signer }, referrer);
|
|
858
1021
|
record.depositAddress = depositAddress;
|
|
859
|
-
// for the wallet's other devices, to show what comes through it
|
|
1022
|
+
// for the wallet's other devices, to show what comes through it and what it takes, and for a flush without the
|
|
1023
|
+
// relayer: written by the next sync
|
|
860
1024
|
const burnAddress = record.address.toLowerCase();
|
|
861
1025
|
this.#ctx.state.receipts.push({
|
|
862
1026
|
kind: 'deposit-address',
|
|
@@ -864,12 +1028,42 @@ export class Wallet {
|
|
|
864
1028
|
tokenId: token.tokenId,
|
|
865
1029
|
burnAddress,
|
|
866
1030
|
depositAddress,
|
|
1031
|
+
args: { fee, feeRecipient, refundSigner: signer, referrer: referrer.address, referralBps: referrer.bps },
|
|
867
1032
|
atMs: Date.now(),
|
|
868
1033
|
unwritten: true,
|
|
869
1034
|
});
|
|
870
1035
|
await this.#ctx.save();
|
|
871
1036
|
return { address: depositAddress, fee };
|
|
872
|
-
});
|
|
1037
|
+
}, !derived);
|
|
1038
|
+
}
|
|
1039
|
+
/** Has the deposit relayer watch deposit address `address` of this wallet's for another 7 days, at the fee it was
|
|
1040
|
+
* handed out with: what reached it after its 7 days is deposited then, and so is what reaches it within the next.
|
|
1041
|
+
* `depositAddresses()` dates it from now, on every device. */
|
|
1042
|
+
rewatchDepositAddress(address) {
|
|
1043
|
+
return this.#seeded(async () => {
|
|
1044
|
+
const receipt = this.#depositReceipt(address);
|
|
1045
|
+
const fee = await rewatchDeposit(chainCtx(this.#ctx, receipt.chainId).chain, getAddress(address));
|
|
1046
|
+
// for every device of the wallet, written by the next sync: storage dates it anew
|
|
1047
|
+
receipt.atMs = Date.now();
|
|
1048
|
+
receipt.unwritten = true;
|
|
1049
|
+
await this.#ctx.save();
|
|
1050
|
+
return { address: getAddress(address), fee };
|
|
1051
|
+
}, false);
|
|
1052
|
+
}
|
|
1053
|
+
/** Deposits what deposit address `address` of this wallet's holds, as the deposit relayer would, for when it does
|
|
1054
|
+
* not: down, or no longer watching the address. Its receipt says all the address was registered with, wherever its
|
|
1055
|
+
* `fee` is known (`depositAddresses()`), so no relayer is asked. `from`, another wallet of the user's, sends the
|
|
1056
|
+
* flush and pays its gas, and shows on chain as the depositor: never the account's own, which the address does not
|
|
1057
|
+
* name. What it holds must exceed the fee (`insufficient-funds`). It shows in `activity()` as any deposit through
|
|
1058
|
+
* the address. */
|
|
1059
|
+
flushDepositAddress(address, opts) {
|
|
1060
|
+
if (!opts.from.account)
|
|
1061
|
+
fail('invalid', 'the wallet client has no account');
|
|
1062
|
+
return this.#seeded(() => flushDepositAddress(this.#ctx, this.#depositReceipt(address), opts.from), false);
|
|
1063
|
+
}
|
|
1064
|
+
/** The receipt of deposit address `address` of this wallet's. */
|
|
1065
|
+
#depositReceipt(address) {
|
|
1066
|
+
return (this.#ctx.state.receipts.find((r) => r.kind === 'deposit-address' && isAddressEqual(r.depositAddress, address)) ?? fail('invalid', `${address} is not a deposit address of this wallet's`));
|
|
873
1067
|
}
|
|
874
1068
|
/** A receive code for `token`, to hand to one payer: it pays any amount, as often as it likes, publicly by a transfer
|
|
875
1069
|
* of the token to the code's burn address (a deposit, like a point of sale's payment addresses), or privately by
|
|
@@ -908,19 +1102,45 @@ export class Wallet {
|
|
|
908
1102
|
if (opts.kind === 'consolidate') {
|
|
909
1103
|
const chosen = opts.coins && this.#chosen(token, opts.coins);
|
|
910
1104
|
if (chosen && chosenSettling(this.#ctx, token.chainId, chosen, all)) {
|
|
911
|
-
|
|
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);
|
|
912
1110
|
}
|
|
913
1111
|
const steps = BigInt(roundsToMerge(mergePool(all, chosen)));
|
|
914
1112
|
// a merge pays the relayer publicly, so the protocol takes its cut of that, grossed up as a payment's is
|
|
915
1113
|
const cut = steps === 0n ? 0n : (await grossed(this.#ctx, token.chainId, token.tokenId, relayer)) - relayer;
|
|
916
|
-
return {
|
|
1114
|
+
return {
|
|
1115
|
+
relayer: relayer * steps,
|
|
1116
|
+
protocol: cut * steps,
|
|
1117
|
+
protocolBps: bpsOf(cut, relayer + cut),
|
|
1118
|
+
fee: (relayer + cut) * steps,
|
|
1119
|
+
steps: Number(steps),
|
|
1120
|
+
};
|
|
917
1121
|
}
|
|
918
1122
|
// as the payment's plan reckons it, so that `fee` is a `maxFee` it passes
|
|
919
|
-
const { protocol } = await withdrawalOf(this.#ctx, token.chainId, token.tokenId, opts.kind === 'withdraw', amount, relayer);
|
|
1123
|
+
const { withdrawn, protocol } = await withdrawalOf(this.#ctx, token.chainId, token.tokenId, opts.kind === 'withdraw', amount, relayer);
|
|
920
1124
|
const onTheWay = this.#ctx.state.ops.some((o) => o.status === 'pending') ||
|
|
921
1125
|
settling(this.#ctx, token.chainId, token.tokenId, size);
|
|
922
|
-
|
|
923
|
-
|
|
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
|
+
}
|
|
1135
|
+
return { relayer, protocol, protocolBps: bpsOf(protocol, withdrawn), fee: relayer + protocol, steps: 1 };
|
|
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')));
|
|
924
1144
|
}
|
|
925
1145
|
/** The most a send or withdrawal of `token` can pay: what is spendable, less the fees `fees` quotes on top of it, and
|
|
926
1146
|
* the merges first when one JoinSplit cannot spend it all, which `fees` then asks for (`needs-consolidation`). */
|
|
@@ -942,9 +1162,15 @@ export class Wallet {
|
|
|
942
1162
|
const merge = mergeRound(coins(this.#ctx, token.chainId, token.tokenId, size), fee);
|
|
943
1163
|
return merge && merge.map((m) => `${token.chainId}:${m.index}`);
|
|
944
1164
|
}
|
|
945
|
-
/**
|
|
946
|
-
*
|
|
947
|
-
|
|
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
|
+
}
|
|
1170
|
+
/** Pays `amount` privately: to an account that has registered (any wallet that ever opened), by its address or its
|
|
1171
|
+
* zERC20 ID (`accountId`), or to a receive code (`receiveCode`). An account that never registered fails with
|
|
1172
|
+
* `recipient-not-registered`. One JoinSplit from what is spendable now, or it fails here: `needs-consolidation`,
|
|
1173
|
+
* `funds-settling` or `insufficient-funds`.
|
|
948
1174
|
* `amount` is what the recipient gets, and `maxFee` caps what it pays on top, `fees({ kind: 'send' }).fee`: the
|
|
949
1175
|
* relayer names its fee, so a quote the user saw is the cap. */
|
|
950
1176
|
async send(opts) {
|
|
@@ -953,21 +1179,16 @@ export class Wallet {
|
|
|
953
1179
|
fail('invalid', 'the amount must be positive');
|
|
954
1180
|
// the JoinSplit is proved by a later sync, which holds the seed until the payment is done
|
|
955
1181
|
return this.#seeded(async () => {
|
|
1182
|
+
const r = recipientOf(opts.to, token);
|
|
956
1183
|
let to;
|
|
957
|
-
if (
|
|
958
|
-
const recipient = await lookupRecipient(this.#ctx.storage, hexToBytes(
|
|
1184
|
+
if (r.kind === 'address') {
|
|
1185
|
+
const recipient = await lookupRecipient(this.#ctx.storage, hexToBytes(r.address));
|
|
959
1186
|
if (!recipient)
|
|
960
|
-
fail('recipient-not-registered', `${opts.to}
|
|
1187
|
+
fail('recipient-not-registered', `${opts.to.trim()} never opened a zERC20 wallet: ask for a receive code`);
|
|
961
1188
|
to = { kind: 'address', recipient: recipient };
|
|
962
1189
|
}
|
|
963
|
-
else
|
|
964
|
-
|
|
965
|
-
if (!code)
|
|
966
|
-
return fail('invalid', 'not an address or a receive code');
|
|
967
|
-
if (code.chainId !== token.chainId || code.tokenId !== token.tokenId)
|
|
968
|
-
fail('invalid', 'the receive code is for another token');
|
|
969
|
-
to = { kind: 'code', code: opts.to.trim() };
|
|
970
|
-
}
|
|
1190
|
+
else
|
|
1191
|
+
to = { kind: 'code', code: r.code };
|
|
971
1192
|
return this.#newPayment('send', token, amount, { to, maxFee: opts.maxFee, step: null, paid: 0n });
|
|
972
1193
|
});
|
|
973
1194
|
}
|
|
@@ -1036,6 +1257,19 @@ export class Wallet {
|
|
|
1036
1257
|
return this.#operation(op);
|
|
1037
1258
|
}
|
|
1038
1259
|
}
|
|
1260
|
+
/** Whom `to` names, as `send` and `deposit` take it: an account, by its address or its zERC20 ID, or a receive code of
|
|
1261
|
+
* `token`'s. */
|
|
1262
|
+
function recipientOf(to, token) {
|
|
1263
|
+
const address = isAddress(to) ? to : idAccount(to);
|
|
1264
|
+
if (address)
|
|
1265
|
+
return { kind: 'address', address };
|
|
1266
|
+
const code = decodeCode(to);
|
|
1267
|
+
if (!code)
|
|
1268
|
+
return fail('invalid', 'not an address, a zERC20 ID or a receive code');
|
|
1269
|
+
if (code.chainId !== token.chainId || code.tokenId !== token.tokenId)
|
|
1270
|
+
fail('invalid', 'the receive code is for another token');
|
|
1271
|
+
return { kind: 'code', code: to.trim(), burnAddress: bytesToHex(code.burnAddress) };
|
|
1272
|
+
}
|
|
1039
1273
|
/** Each chain of `network`, with its servers. */
|
|
1040
1274
|
const chainsOf = (network) => network.chains.map((config) => ({
|
|
1041
1275
|
config,
|
|
@@ -1043,24 +1277,41 @@ const chainsOf = (network) => network.chains.map((config) => ({
|
|
|
1043
1277
|
indexer: new Indexer(config.indexer),
|
|
1044
1278
|
asp: new Asp(config.asp),
|
|
1045
1279
|
}));
|
|
1046
|
-
/** Holds `address`'s wallet
|
|
1047
|
-
*
|
|
1048
|
-
*
|
|
1049
|
-
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();
|
|
1050
1285
|
const locks = globalThis.navigator?.locks;
|
|
1051
1286
|
if (!locks)
|
|
1052
|
-
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'));
|
|
1053
1290
|
let release = () => { };
|
|
1054
1291
|
const held = new Promise((r) => (release = r));
|
|
1055
|
-
|
|
1056
|
-
|
|
1057
|
-
|
|
1058
|
-
|
|
1059
|
-
|
|
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)));
|
|
1060
1302
|
});
|
|
1061
1303
|
if (!got)
|
|
1062
1304
|
fail('wallet-in-use', 'this wallet is open in another tab or window: close it there first');
|
|
1063
|
-
|
|
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 };
|
|
1064
1315
|
}
|
|
1065
1316
|
/** A deposit `deposit()` did not see through (a reload, a lost connection): settled by its transaction's receipt, or
|
|
1066
1317
|
* failed if it never sent one. Runs only between tasks, so no `deposit()` is still on its way to sending it. */
|