@zerc20/wallet-sdk 0.2.0 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/burnWorker.js +6 -6
- package/dist/burnWorker.js.map +1 -1
- package/dist/context.d.ts +25 -4
- package/dist/context.js +36 -6
- package/dist/context.js.map +1 -1
- package/dist/core.d.ts +32 -17
- package/dist/core.js +253 -70
- package/dist/core.js.map +1 -1
- package/dist/errors.d.ts +2 -0
- package/dist/errors.js.map +1 -1
- package/dist/index.d.ts +3 -2
- package/dist/index.js +3 -1
- package/dist/index.js.map +1 -1
- package/dist/pos.js +2 -2
- package/dist/pos.js.map +1 -1
- package/dist/rageExit.js +3 -5
- package/dist/rageExit.js.map +1 -1
- package/dist/seed.d.ts +4 -0
- package/dist/seed.js +12 -1
- package/dist/seed.js.map +1 -1
- package/dist/spend.d.ts +1 -1
- package/dist/spend.js +5 -4
- package/dist/spend.js.map +1 -1
- package/dist/state.d.ts +14 -4
- package/dist/state.js +10 -7
- package/dist/state.js.map +1 -1
- package/dist/sync.d.ts +16 -3
- package/dist/sync.js +93 -25
- package/dist/sync.js.map +1 -1
- package/dist/viewKey.d.ts +14 -0
- package/dist/viewKey.js +45 -0
- package/dist/viewKey.js.map +1 -0
- package/dist/wallet.d.ts +39 -12
- package/dist/wallet.js +209 -85
- package/dist/wallet.js.map +1 -1
- package/package.json +5 -3
- package/src/burnWorker.ts +9 -10
- package/src/context.ts +51 -8
- package/src/core.ts +279 -87
- package/src/errors.ts +2 -0
- package/src/index.ts +4 -1
- package/src/pos.ts +2 -2
- package/src/rageExit.ts +3 -4
- package/src/seed.ts +13 -1
- package/src/spend.ts +5 -5
- package/src/state.ts +17 -7
- package/src/sync.ts +97 -24
- package/src/viewKey.ts +49 -0
- package/src/wallet.ts +270 -87
- package/wasm/zerc20_wallet_core.d.ts +20 -10
- package/wasm/zerc20_wallet_core.js +74 -37
- package/wasm/zerc20_wallet_core_bg.wasm +0 -0
- package/wasm/zerc20_wallet_core_bg.wasm.d.ts +4 -3
package/src/wallet.ts
CHANGED
|
@@ -10,6 +10,7 @@ import {
|
|
|
10
10
|
encodeCode,
|
|
11
11
|
lookupRecipient,
|
|
12
12
|
nextPosLane,
|
|
13
|
+
notifyKey,
|
|
13
14
|
planDeposit,
|
|
14
15
|
planPosLane,
|
|
15
16
|
posKey,
|
|
@@ -19,16 +20,29 @@ import {
|
|
|
19
20
|
type Account,
|
|
20
21
|
type Address,
|
|
21
22
|
type Hex,
|
|
23
|
+
type LocalAccount,
|
|
22
24
|
type WalletClient,
|
|
23
25
|
bytesToHex,
|
|
24
26
|
formatUnits,
|
|
27
|
+
getAddress,
|
|
25
28
|
hexToBytes,
|
|
26
29
|
isAddress,
|
|
27
30
|
numberToHex,
|
|
28
31
|
zeroAddress,
|
|
29
32
|
} from 'viem';
|
|
33
|
+
import { toAccount } from 'viem/accounts';
|
|
30
34
|
import { Chain, MAX_REFERRAL_BPS, NATIVE, type Referrer, type Token, erc20Abi, tokenAbi } from './chain.js';
|
|
31
|
-
import {
|
|
35
|
+
import {
|
|
36
|
+
type ChainCtx,
|
|
37
|
+
type Ctx,
|
|
38
|
+
type SpendKey,
|
|
39
|
+
chainCtx,
|
|
40
|
+
chainState,
|
|
41
|
+
owner,
|
|
42
|
+
switched,
|
|
43
|
+
walletOn,
|
|
44
|
+
walletSees,
|
|
45
|
+
} from './context.js';
|
|
32
46
|
import { burnAddressFor, core, initCore } from './core.js';
|
|
33
47
|
import { type ErrorCode, WalletError, fail, guard } from './errors.js';
|
|
34
48
|
import { type Network, checkNetwork } from './network.js';
|
|
@@ -62,10 +76,14 @@ import {
|
|
|
62
76
|
emptyWalletState,
|
|
63
77
|
openState,
|
|
64
78
|
sealState,
|
|
79
|
+
stateKey,
|
|
80
|
+
unspent,
|
|
65
81
|
} from './state.js';
|
|
66
82
|
import {
|
|
67
83
|
WAIT_MS,
|
|
68
84
|
commitmentTx,
|
|
85
|
+
keyAll,
|
|
86
|
+
nextBurn,
|
|
69
87
|
recordBurn,
|
|
70
88
|
storageState,
|
|
71
89
|
syncAll,
|
|
@@ -74,17 +92,25 @@ import {
|
|
|
74
92
|
topUpBurns,
|
|
75
93
|
unusedBurn,
|
|
76
94
|
} from './sync.js';
|
|
95
|
+
import { decodeViewKey, encodeViewKey } from './viewKey.js';
|
|
77
96
|
|
|
78
|
-
const DAY_MS = 86_400_000;
|
|
79
97
|
/** The ledger's bound outside an emergency. */
|
|
80
98
|
const NO_BOUND = 2n ** 64n - 1n;
|
|
81
99
|
|
|
82
100
|
export interface OpenOptions {
|
|
83
101
|
network: Network;
|
|
84
|
-
/** The
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
102
|
+
/** The account's key, held by the app as a viem local account (a private key's, or `accountKey`'s): the wallet sends
|
|
103
|
+
* its transactions through each chain's RPC. Give this or `walletClient`. */
|
|
104
|
+
account?: LocalAccount;
|
|
105
|
+
/** The user's wallet (MetaMask, WalletConnect, ...) as a viem client with its account, which the wallet switches to
|
|
106
|
+
* each chain it sends on. */
|
|
107
|
+
walletClient?: WalletClient;
|
|
108
|
+
/** The 32-byte root of every key (see `seedFromSignature`), held while the wallet is open. Or a function that gives
|
|
109
|
+
* it, a passkey's say, to hold the seed only while it is needed: asked as the wallet opens, and again to pay,
|
|
110
|
+
* withdraw, consolidate or exit, or for a receive code past those derived ahead, each time before the call waits on
|
|
111
|
+
* anything (a browser shows a passkey prompt only in the click that asked). The seed goes at the end of a sync that
|
|
112
|
+
* nothing waits behind, once nothing in flight signs again. */
|
|
113
|
+
seed: Uint8Array | (() => Promise<Uint8Array>);
|
|
88
114
|
/** Where the encrypted state lives between sessions. */
|
|
89
115
|
persist: { load(): Promise<Uint8Array | null | undefined>; save(bytes: Uint8Array): Promise<void> };
|
|
90
116
|
/** Where a wallet with no saved state starts looking: the block its seed was first used at, or roughly when. Without
|
|
@@ -94,6 +120,11 @@ export interface OpenOptions {
|
|
|
94
120
|
referrer?: Referrer;
|
|
95
121
|
}
|
|
96
122
|
|
|
123
|
+
export interface WatchOptions extends Pick<OpenOptions, 'network' | 'persist' | 'birthday'> {
|
|
124
|
+
/** The account's view key, from its wallet's `viewKey()`. */
|
|
125
|
+
viewKey: string;
|
|
126
|
+
}
|
|
127
|
+
|
|
97
128
|
/** What a deposit, or a deposit address, waits on: for an app to show while it does. */
|
|
98
129
|
export type DepositStep =
|
|
99
130
|
/** Behind another call of this wallet: a sync, or another operation. */
|
|
@@ -108,7 +139,7 @@ export type DepositStep =
|
|
|
108
139
|
|
|
109
140
|
/** Why an amount is not spendable yet. */
|
|
110
141
|
export type PendingReason =
|
|
111
|
-
/**
|
|
142
|
+
/** A note this device's payment, withdrawal or merge under way is making, not yet read on chain. */
|
|
112
143
|
| 'incoming'
|
|
113
144
|
/** Waiting for a proved root that covers it (minutes). */
|
|
114
145
|
| 'awaiting-root'
|
|
@@ -157,7 +188,7 @@ export interface Activity {
|
|
|
157
188
|
txHash: Hex | null;
|
|
158
189
|
/** `done` once on chain (in a block: a reorg may still take it back until final), `failed` for a payment or
|
|
159
190
|
* withdrawal that cannot land, shown only on the device that tried it. What is not on chain yet is not activity:
|
|
160
|
-
* an operation under way is in `operations()`,
|
|
191
|
+
* an operation under way is in `operations()`, and the note it is making `incoming` in `balances()`. */
|
|
161
192
|
status: 'done' | 'failed';
|
|
162
193
|
}
|
|
163
194
|
|
|
@@ -240,10 +271,13 @@ export class Wallet {
|
|
|
240
271
|
#busy = 0;
|
|
241
272
|
#release = () => {};
|
|
242
273
|
#referrer: Referrer | undefined;
|
|
274
|
+
readonly #unlock: (() => Promise<Uint8Array>) | undefined;
|
|
275
|
+
#readOnly = false;
|
|
243
276
|
#closed = new AbortController();
|
|
244
277
|
|
|
245
|
-
private constructor(ctx: Ctx) {
|
|
278
|
+
private constructor(ctx: Ctx, unlock?: () => Promise<Uint8Array>) {
|
|
246
279
|
this.#ctx = ctx;
|
|
280
|
+
this.#unlock = unlock;
|
|
247
281
|
}
|
|
248
282
|
|
|
249
283
|
/** Lets this wallet open again: in another tab, or here for another account. This object is done with: what is
|
|
@@ -258,11 +292,21 @@ export class Wallet {
|
|
|
258
292
|
return this.#ctx.address;
|
|
259
293
|
}
|
|
260
294
|
|
|
295
|
+
/** The account's view key, for `watch` to follow it with on another device: whoever holds it sees what this wallet
|
|
296
|
+
* shows, except the private payments to receive codes, and can spend none of it. Checksummed text (bech32m,
|
|
297
|
+
* `zerc20view1…`). */
|
|
298
|
+
viewKey(): string {
|
|
299
|
+
return encodeViewKey(this.#ctx.view, owner(this.#ctx)!);
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
/** Whether this wallet only watches its account (`watch`): it shows what the account holds and spends nothing. */
|
|
303
|
+
get readOnly(): boolean {
|
|
304
|
+
return this.#readOnly;
|
|
305
|
+
}
|
|
306
|
+
|
|
261
307
|
/** Opens the wallet. The first time on this address, registers it for payments: one signature. */
|
|
262
308
|
static async open(opts: OpenOptions): Promise<Wallet> {
|
|
263
|
-
await initCore();
|
|
264
309
|
checkNetwork(opts.network);
|
|
265
|
-
if (opts.seed.length !== 32) fail('invalid', 'the seed is 32 bytes');
|
|
266
310
|
// the factory refuses these, which the relayer would report as its own failure
|
|
267
311
|
const r = opts.referrer;
|
|
268
312
|
if (r) {
|
|
@@ -271,72 +315,179 @@ export class Wallet {
|
|
|
271
315
|
if (!Number.isInteger(r.bps) || r.bps < 0 || r.bps > MAX_REFERRAL_BPS)
|
|
272
316
|
fail('invalid', `a referrer takes 0 to ${MAX_REFERRAL_BPS} bps, not ${r.bps}`);
|
|
273
317
|
}
|
|
274
|
-
|
|
318
|
+
if (!opts.account === !opts.walletClient) fail('invalid', 'give an account or a wallet client, one of the two');
|
|
319
|
+
const account = opts.account ?? opts.walletClient!.account ?? fail('invalid', 'the wallet client has no account');
|
|
320
|
+
// asked once the options are checked, and before anything is waited on: a browser shows a passkey prompt only in
|
|
321
|
+
// the click that opened
|
|
322
|
+
const seed = typeof opts.seed === 'function' ? await opts.seed() : opts.seed;
|
|
323
|
+
if (seed.length !== 32) fail('invalid', 'the seed is 32 bytes');
|
|
324
|
+
await initCore();
|
|
275
325
|
const release = await lockWallet(account.address);
|
|
276
326
|
try {
|
|
277
|
-
return await Wallet.#open(opts, account, release);
|
|
327
|
+
return await Wallet.#open(opts, seed, account, release);
|
|
278
328
|
} catch (e) {
|
|
279
329
|
release();
|
|
280
330
|
throw e;
|
|
281
331
|
}
|
|
282
332
|
}
|
|
283
333
|
|
|
284
|
-
static async #open(opts: OpenOptions, account: Account, release: () => void): Promise<Wallet> {
|
|
334
|
+
static async #open(opts: OpenOptions, seed: Uint8Array, account: Account, release: () => void): Promise<Wallet> {
|
|
285
335
|
const address = account.address;
|
|
286
|
-
const chains = opts.network
|
|
287
|
-
config,
|
|
288
|
-
chain: new Chain(config),
|
|
289
|
-
indexer: new Indexer(config.indexer),
|
|
290
|
-
asp: new Asp(config.asp),
|
|
291
|
-
}));
|
|
336
|
+
const chains = chainsOf(opts.network);
|
|
292
337
|
const code = await chains[0]?.chain.client.getCode({ address });
|
|
293
338
|
// an EIP-7702 delegated account is still an EOA that signs
|
|
294
339
|
if (code && code !== '0x' && !code.startsWith('0xef0100'))
|
|
295
340
|
fail('unsupported-account', 'contract accounts cannot sign for a wallet');
|
|
296
341
|
const signer: Signer = async (message) =>
|
|
297
|
-
hexToBytes(
|
|
342
|
+
hexToBytes(
|
|
343
|
+
await guard(
|
|
344
|
+
account.type === 'local'
|
|
345
|
+
? account.signMessage({ message })
|
|
346
|
+
: opts.walletClient!.signMessage({ account, message }),
|
|
347
|
+
),
|
|
348
|
+
);
|
|
298
349
|
const storage = await CanisterClient.connect(opts.network.storage.host, opts.network.storage.canisterId);
|
|
299
|
-
const publicKey = hexToBytes((core.spendKey(
|
|
350
|
+
const publicKey = hexToBytes((core.spendKey(seed) as SpendKey).publicKey);
|
|
300
351
|
const saved = await opts.persist.load();
|
|
301
352
|
// registered already: used before, here or on another device, so its past is looked for
|
|
302
353
|
// ponytail: from genesis (~4 MB per million commitments); ask for a birthday once the chain has many
|
|
303
354
|
const restoring =
|
|
304
355
|
!saved?.length && opts.birthday === undefined && !!(await storage.registration(hexToBytes(address)));
|
|
305
|
-
const view = await registeredViewKey(storage,
|
|
306
|
-
const
|
|
356
|
+
const view = await registeredViewKey(storage, seed, signer, hexToBytes(address), publicKey);
|
|
357
|
+
const wallet = await Wallet.#begin(
|
|
358
|
+
{
|
|
359
|
+
network: opts.network,
|
|
360
|
+
seed,
|
|
361
|
+
owners: new Map(),
|
|
362
|
+
address,
|
|
363
|
+
account,
|
|
364
|
+
walletClient: opts.walletClient ?? null,
|
|
365
|
+
storage,
|
|
366
|
+
view,
|
|
367
|
+
chains,
|
|
368
|
+
},
|
|
369
|
+
{ persist: opts.persist, saved, key: stateKey(seed), from: restoring ? 0n : opts.birthday },
|
|
370
|
+
typeof opts.seed === 'function' ? opts.seed : undefined,
|
|
371
|
+
);
|
|
372
|
+
wallet.#release = release;
|
|
373
|
+
wallet.#referrer = opts.referrer;
|
|
374
|
+
return wallet;
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
/** Opens an account to follow by its view key (`viewKey()`): its balances, coins and history as its own wallet shows
|
|
378
|
+
* them, less the private payments to its receive codes, whose keys only the seed derives. It spends and writes
|
|
379
|
+
* nothing: a call that would fails with `read-only`. The key must be the account's current one on this network. */
|
|
380
|
+
static async watch(opts: WatchOptions): Promise<Wallet> {
|
|
381
|
+
checkNetwork(opts.network);
|
|
382
|
+
await initCore();
|
|
383
|
+
const { view, owner } = decodeViewKey(opts.viewKey) ?? fail('invalid', 'not a view key');
|
|
384
|
+
const address = getAddress(bytesToHex(view.address));
|
|
385
|
+
const release = await lockWallet(address, 'view');
|
|
386
|
+
try {
|
|
387
|
+
const storage = await CanisterClient.connect(opts.network.storage.host, opts.network.storage.canisterId);
|
|
388
|
+
// what the account signed: a nullifying key that is not the account's would show spent coins as unspent
|
|
389
|
+
const r = await lookupRecipient(storage, view.address);
|
|
390
|
+
if (!r || bytesToHex(r.publicKey) !== owner.publicKey || bytesToHex(r.notifyKey) !== bytesToHex(notifyKey(view)))
|
|
391
|
+
fail('invalid', `not ${address}'s view key on ${opts.network.name}, or one it has replaced`);
|
|
392
|
+
const wallet = await Wallet.#begin(
|
|
393
|
+
{
|
|
394
|
+
network: opts.network,
|
|
395
|
+
seed: null,
|
|
396
|
+
owners: new Map([['', owner]]),
|
|
397
|
+
address,
|
|
398
|
+
// signs nothing: every call that would send is refused first
|
|
399
|
+
account: toAccount(address),
|
|
400
|
+
walletClient: null,
|
|
401
|
+
storage,
|
|
402
|
+
view,
|
|
403
|
+
chains: chainsOf(opts.network),
|
|
404
|
+
},
|
|
405
|
+
// registered, so its past is looked for
|
|
406
|
+
{
|
|
407
|
+
persist: opts.persist,
|
|
408
|
+
saved: await opts.persist.load(),
|
|
409
|
+
key: stateKey(view.seed),
|
|
410
|
+
from: opts.birthday ?? 0n,
|
|
411
|
+
},
|
|
412
|
+
);
|
|
413
|
+
wallet.#release = release;
|
|
414
|
+
wallet.#readOnly = true;
|
|
415
|
+
return wallet;
|
|
416
|
+
} catch (e) {
|
|
417
|
+
release();
|
|
418
|
+
throw e;
|
|
419
|
+
}
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
/** The wallet over `base`, its state opened from what `persist` saved, with `key`, or started `from` a block or date. */
|
|
423
|
+
static async #begin(
|
|
424
|
+
base: Omit<Ctx, 'state' | 'save' | 'lastFullRefreshMs'>,
|
|
425
|
+
{
|
|
426
|
+
persist,
|
|
427
|
+
saved,
|
|
428
|
+
key,
|
|
429
|
+
from,
|
|
430
|
+
}: { persist: OpenOptions['persist']; saved: Uint8Array | null | undefined; key: Uint8Array; from?: bigint | Date },
|
|
431
|
+
unlock?: () => Promise<Uint8Array>,
|
|
432
|
+
): Promise<Wallet> {
|
|
433
|
+
const loaded = saved?.length ? openState(key, saved) : null;
|
|
307
434
|
// never started over in place: the first save would overwrite what it held, operations in flight included
|
|
308
435
|
if (saved?.length && !loaded) {
|
|
309
436
|
fail(
|
|
310
437
|
'invalid',
|
|
311
|
-
'the saved state does not open with this
|
|
438
|
+
'the saved state does not open with this key (another seed or view key, a newer version, or damaged): clear it to start over, which finds what it held again',
|
|
312
439
|
);
|
|
313
440
|
}
|
|
314
|
-
if (loaded && loaded.address.toLowerCase() !== address.toLowerCase())
|
|
441
|
+
if (loaded && loaded.address.toLowerCase() !== base.address.toLowerCase())
|
|
315
442
|
fail('invalid', `the saved state is ${loaded.address}'s: keep one per address`);
|
|
316
|
-
const state: WalletState = loaded ?? emptyWalletState(address);
|
|
317
|
-
const ctx: Ctx = {
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
address,
|
|
321
|
-
walletClient: opts.walletClient,
|
|
322
|
-
storage,
|
|
323
|
-
view,
|
|
324
|
-
chains,
|
|
325
|
-
state,
|
|
326
|
-
save: () => opts.persist.save(sealState(opts.seed, state)),
|
|
327
|
-
lastFullRefreshMs: 0,
|
|
328
|
-
};
|
|
329
|
-
if (!loaded) await start(ctx, restoring ? 0n : opts.birthday);
|
|
330
|
-
const wallet = new Wallet(ctx);
|
|
331
|
-
wallet.#release = release;
|
|
332
|
-
wallet.#referrer = opts.referrer;
|
|
443
|
+
const state: WalletState = loaded ?? emptyWalletState(base.address);
|
|
444
|
+
const ctx: Ctx = { ...base, state, save: () => persist.save(sealState(key, state)), lastFullRefreshMs: 0 };
|
|
445
|
+
if (!loaded) await start(ctx, from);
|
|
446
|
+
const wallet = new Wallet(ctx, unlock);
|
|
333
447
|
// a saved state names its tokens by id, and its coins stand by the chains' roots: read both now, so balances, coins
|
|
334
448
|
// and history work before the first sync
|
|
335
|
-
const [tokens] = await Promise.all([Promise.all(chains.map((c) => c.chain.tokens())), wallet.#readRoots()]);
|
|
449
|
+
const [tokens] = await Promise.all([Promise.all(ctx.chains.map((c) => c.chain.tokens())), wallet.#readRoots()]);
|
|
336
450
|
wallet.#tokens = tokens.flat();
|
|
451
|
+
keyAll(ctx, wallet.#tokens);
|
|
337
452
|
return wallet;
|
|
338
453
|
}
|
|
339
454
|
|
|
455
|
+
/** Holds `seed`, asked of `#unlock`, once it is checked to be this wallet's. */
|
|
456
|
+
#hold(seed: Uint8Array): void {
|
|
457
|
+
if ((core.spendKey(seed) as SpendKey).publicKey !== owner(this.#ctx)!.publicKey)
|
|
458
|
+
fail('invalid', "the seed given is another wallet's");
|
|
459
|
+
this.#ctx.seed = seed;
|
|
460
|
+
keyAll(this.#ctx, this.#tokens);
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
/** Within a task: the seed held, asked of `#unlock` if need be. */
|
|
464
|
+
async #seed(): Promise<void> {
|
|
465
|
+
if (!this.#ctx.seed) this.#hold(await this.#unlock!());
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
/** At the end of a sync: lets the seed go when it was opened with a function for it, nothing waits behind the sync,
|
|
469
|
+
* and no payment, withdrawal or consolidation is in flight, as each may sign again (a JoinSplit that reverts is
|
|
470
|
+
* proved again, and every merge round is signed). */
|
|
471
|
+
#dropSeed(): void {
|
|
472
|
+
if (!this.#unlock || this.#busy > 1) return;
|
|
473
|
+
if (this.#ctx.state.ops.some((o) => o.status === 'pending' && o.kind !== 'deposit')) return;
|
|
474
|
+
this.#ctx.seed = null;
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
/** `#exclusive` for a call that `needs` the seed: held first, asked of `#unlock` if need be before the call waits on
|
|
478
|
+
* anything else (a browser shows a passkey prompt only in the click that asked), and queued in the step that took it,
|
|
479
|
+
* so that a sync ending meanwhile does not let it go. */
|
|
480
|
+
async #seeded<T>(f: () => Promise<T>, needs = true): Promise<T> {
|
|
481
|
+
this.#writable();
|
|
482
|
+
if (needs && !this.#ctx.seed) this.#hold(await this.#unlock!());
|
|
483
|
+
return this.#exclusive(f);
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
/** Refuses a call that spends or writes to storage, in a wallet that watches. */
|
|
487
|
+
#writable(): void {
|
|
488
|
+
if (this.#readOnly) fail('read-only', 'this wallet watches its account by its view key: open it with its seed');
|
|
489
|
+
}
|
|
490
|
+
|
|
340
491
|
/** Runs `f` after everything queued before it: one state, changed by one task at a time. */
|
|
341
492
|
#exclusive<T>(f: () => Promise<T>): Promise<T> {
|
|
342
493
|
this.#busy++;
|
|
@@ -372,13 +523,15 @@ export class Wallet {
|
|
|
372
523
|
|
|
373
524
|
/** Catches up: new notes, deposits, spends and roots, then advances every operation in flight. A chain or server that
|
|
374
525
|
* fails holds back only what needs it, and the first error is thrown once the rest is done. Settles before the burn
|
|
375
|
-
* addresses are topped up, which can take a phone
|
|
526
|
+
* addresses are topped up, which can take a phone some seconds. */
|
|
376
527
|
sync(): Promise<void> {
|
|
377
528
|
return new Promise((resolve, reject) => {
|
|
378
529
|
this.#exclusive(async () => {
|
|
379
530
|
await this.#catchUp().then(resolve, reject);
|
|
380
|
-
// still this task's: a deposit queued meanwhile finds the one recorded ahead, rather than writing at handout
|
|
381
|
-
|
|
531
|
+
// still this task's: a deposit queued meanwhile finds the one recorded ahead, rather than writing at handout;
|
|
532
|
+
// tried again after the next sync. A wallet that watches records none, as it writes nothing.
|
|
533
|
+
if (!this.#readOnly) await topUpBurns(this.#ctx, this.#closed.signal).catch(() => {});
|
|
534
|
+
this.#dropSeed();
|
|
382
535
|
}).catch(reject); // closed before it ran
|
|
383
536
|
});
|
|
384
537
|
}
|
|
@@ -424,7 +577,7 @@ export class Wallet {
|
|
|
424
577
|
if (errors.length > 0) throw errors[0];
|
|
425
578
|
}
|
|
426
579
|
|
|
427
|
-
/** Syncs on its own: every
|
|
580
|
+
/** Syncs on its own: every 3 s while something is under way, every 10 s otherwise. Returns a stop function. */
|
|
428
581
|
autoSync(onChange?: () => void, onError?: (e: unknown) => void): () => void {
|
|
429
582
|
let stopped = false;
|
|
430
583
|
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
@@ -437,7 +590,7 @@ export class Wallet {
|
|
|
437
590
|
}
|
|
438
591
|
if (stopped || this.#closed.signal.aborted) return;
|
|
439
592
|
const busy = this.#ctx.state.ops.some((o) => o.status === 'pending' && Date.now() - o.createdMs < WAIT_MS * 4);
|
|
440
|
-
timer = setTimeout(tick, busy ?
|
|
593
|
+
timer = setTimeout(tick, busy ? 3_000 : 10_000);
|
|
441
594
|
};
|
|
442
595
|
void tick();
|
|
443
596
|
return () => {
|
|
@@ -460,18 +613,22 @@ export class Wallet {
|
|
|
460
613
|
|
|
461
614
|
balances(): Balance[] {
|
|
462
615
|
const coins = this.coins();
|
|
616
|
+
// storage says which notes to look for, not that they come: a payer writes its notes there before its JoinSplit
|
|
617
|
+
// leaves, and one that never lands leaves them behind. So a note counts once it is a coin (its commitment read);
|
|
618
|
+
// before that, only a note this device's own operation under way is making counts, as incoming. A zero-value
|
|
619
|
+
// change, there to carry its payment's receipt, never counts
|
|
620
|
+
const making = new Set(
|
|
621
|
+
this.#ctx.state.ops.flatMap((o) =>
|
|
622
|
+
o.status === 'pending' && o.kind !== 'deposit' ? (o.data.changes ?? []) : [],
|
|
623
|
+
),
|
|
624
|
+
);
|
|
463
625
|
return this.#tokens.map((token) => {
|
|
464
626
|
const { chainId, tokenId } = token;
|
|
465
627
|
const b: Balance = { token, spendable: 0n, pending: [], blocked: 0n };
|
|
466
|
-
// a merge that never landed (another device spent an input first) stops counting after a day; a zero-value
|
|
467
|
-
// change, there to carry its payment's receipt, never counts. A note is a coin once its commitment is read, so
|
|
468
|
-
// until then it is incoming, as a payment's change is between its relay and the next sync
|
|
469
628
|
for (const n of this.#ctx.state.notes.filter(
|
|
470
|
-
(n) => n.chainId === chainId && n.tokenId === tokenId && n.value > 0n,
|
|
629
|
+
(n) => n.chainId === chainId && n.tokenId === tokenId && n.value > 0n && making.has(n.salt),
|
|
471
630
|
)) {
|
|
472
|
-
|
|
473
|
-
n.receipt?.kind === 'merged' && n.atNs !== null && Date.now() - Number(n.atNs / 1_000_000n) > DAY_MS;
|
|
474
|
-
if (!n.found.some((f) => f.confirmed) && !stale) b.pending.push({ amount: n.value, reason: 'incoming' });
|
|
631
|
+
if (!n.found.some((f) => f.confirmed)) b.pending.push({ amount: n.value, reason: 'incoming' });
|
|
475
632
|
}
|
|
476
633
|
for (const c of coins.filter((c) => c.token === token)) {
|
|
477
634
|
if (c.state === 'spendable') b.spendable += c.amount;
|
|
@@ -483,13 +640,14 @@ export class Wallet {
|
|
|
483
640
|
}
|
|
484
641
|
|
|
485
642
|
/** Every unspent note and deposit on chain, and where each stands, as of opening or the last sync. A note announced
|
|
486
|
-
* but not on chain yet is not one: `balances()` counts it as incoming. */
|
|
643
|
+
* but not read on chain yet is not one: `balances()` counts it as incoming if this device's operation is making it. */
|
|
487
644
|
coins(): CoinInfo[] {
|
|
488
645
|
const size = (chainId: bigint) => this.#status.get(chainId)?.latestRoot?.size ?? 0;
|
|
489
646
|
const unproved = (chainId: bigint) => (this.#status.get(chainId)?.halted ? 'roots-halted' : 'awaiting-root');
|
|
490
647
|
const spending = new Set(this.#ctx.state.ops.flatMap((o) => inputsOf(o).map((c) => `${o.chainId}:${c.index}`)));
|
|
491
|
-
// spent by a JoinSplit in a block, which waits only to be final: gone, as activity() counts it
|
|
492
|
-
//
|
|
648
|
+
// spent by a JoinSplit in a block, which waits only to be final: gone, as activity() counts it. Sync sees it by its
|
|
649
|
+
// nullifier (`unspent`); this device's own is gone already from its relay on (a reorg that drops it brings them
|
|
650
|
+
// back as spending, and the operation relays it again)
|
|
493
651
|
const landed = new Set(
|
|
494
652
|
this.#ctx.state.ops.flatMap((o) =>
|
|
495
653
|
o.kind !== 'deposit' && o.data.step?.stage === 'relayed'
|
|
@@ -500,7 +658,7 @@ export class Wallet {
|
|
|
500
658
|
const out: CoinInfo[] = [];
|
|
501
659
|
// a zero-value change carries its payment's receipt, and is no coin
|
|
502
660
|
for (const n of this.#ctx.state.notes.filter((n) => n.value > 0n)) {
|
|
503
|
-
for (const f of n.found.filter(
|
|
661
|
+
for (const f of n.found.filter(unspent)) {
|
|
504
662
|
const id = `${n.chainId}:${f.index}`;
|
|
505
663
|
// a 4-byte tag alone matches someone else's now and then: a coin once its commitment is read
|
|
506
664
|
if (!f.confirmed || landed.has(id)) continue;
|
|
@@ -518,7 +676,7 @@ export class Wallet {
|
|
|
518
676
|
}
|
|
519
677
|
for (const d of this.#ctx.state.deposits) {
|
|
520
678
|
// a coin once its commitment is read, which names the amount
|
|
521
|
-
for (const f of d.found.filter((f) =>
|
|
679
|
+
for (const f of d.found.filter((f) => unspent(f) && f.amount !== null)) {
|
|
522
680
|
const id = `${d.chainId}:${f.index}`;
|
|
523
681
|
if (landed.has(id)) continue;
|
|
524
682
|
const state =
|
|
@@ -754,6 +912,7 @@ export class Wallet {
|
|
|
754
912
|
* addresses. */
|
|
755
913
|
posKey(): Promise<Hex> {
|
|
756
914
|
return this.#exclusive(async () => {
|
|
915
|
+
this.#writable();
|
|
757
916
|
const ctx = this.#ctx;
|
|
758
917
|
const tokens = await tokenIds(ctx);
|
|
759
918
|
let n = 0;
|
|
@@ -768,7 +927,7 @@ export class Wallet {
|
|
|
768
927
|
/** Refunds deposit `id` to its sender without the ASP, the attester or a proved root: for a deposit the ASP
|
|
769
928
|
* rejected, or when those services stop. The account's wallet sends the transactions and pays their gas. */
|
|
770
929
|
rageExit(id: string): Promise<Hex[]> {
|
|
771
|
-
return this.#
|
|
930
|
+
return this.#seeded(async () => {
|
|
772
931
|
const [chain, index] = id.split(':');
|
|
773
932
|
const record =
|
|
774
933
|
this.#ctx.state.deposits.find((d) => `${d.chainId}` === chain && d.found.some((f) => `${f.index}` === index)) ??
|
|
@@ -874,22 +1033,27 @@ export class Wallet {
|
|
|
874
1033
|
* With `wrapped`, it pays from the account's zERC20 itself, as a withdrawal pays out, rather than its underlying
|
|
875
1034
|
* asset: a plain transfer into the burn address, with no wrap and no approval.
|
|
876
1035
|
*
|
|
1036
|
+
* With `from`, another wallet of the user's (a browser wallet) pays instead of the account: its balance is checked,
|
|
1037
|
+
* it approves and wraps, and an exit refunds it there. The account pays nothing and signs nothing.
|
|
1038
|
+
*
|
|
877
1039
|
* Returns once the wrap is sent: the operation is done once it is mined, which `wait()` or the next sync finds.
|
|
878
1040
|
* `onStep` hears what it waits on meanwhile, for an app to show. */
|
|
879
|
-
deposit(opts: {
|
|
1041
|
+
async deposit(opts: {
|
|
880
1042
|
token: Token;
|
|
881
1043
|
amount: bigint;
|
|
882
1044
|
to?: Address;
|
|
883
1045
|
wrapped?: boolean;
|
|
1046
|
+
from?: WalletClient;
|
|
884
1047
|
onStep?: (step: DepositStep) => void;
|
|
885
1048
|
}): Promise<Operation> {
|
|
1049
|
+
this.#writable();
|
|
1050
|
+
if (opts.amount <= 0n) fail('invalid', 'the amount must be positive');
|
|
1051
|
+
const payer = opts.from ? (opts.from.account ?? fail('invalid', 'the wallet client has no account')) : null;
|
|
886
1052
|
const step = opts.onStep ?? (() => {});
|
|
887
1053
|
if (this.#busy > 0) step('queued');
|
|
888
1054
|
return this.#exclusive(async () => {
|
|
889
1055
|
const { token, amount } = opts;
|
|
890
|
-
|
|
891
|
-
const wc = this.#ctx.walletClient;
|
|
892
|
-
const account = wc.account!;
|
|
1056
|
+
const account = payer ?? this.#ctx.account;
|
|
893
1057
|
const client = chainCtx(this.#ctx, token.chainId).chain.client;
|
|
894
1058
|
const { wrapped } = opts;
|
|
895
1059
|
const native = !wrapped && token.underlying.toLowerCase() === NATIVE.toLowerCase();
|
|
@@ -906,7 +1070,7 @@ export class Wallet {
|
|
|
906
1070
|
const of = wrapped ? token.symbol : `${token.symbol}'s underlying asset`;
|
|
907
1071
|
fail(
|
|
908
1072
|
'insufficient-funds',
|
|
909
|
-
|
|
1073
|
+
`${payer ? 'the wallet' : 'the account'} holds ${formatUnits(held, token.decimals)} of ${of}, less than ${formatUnits(amount, token.decimals)}`,
|
|
910
1074
|
);
|
|
911
1075
|
}
|
|
912
1076
|
const address = opts.to
|
|
@@ -920,8 +1084,7 @@ export class Wallet {
|
|
|
920
1084
|
);
|
|
921
1085
|
await this.#ctx.save();
|
|
922
1086
|
try {
|
|
923
|
-
|
|
924
|
-
await guard(wc.switchChain({ id: Number(token.chainId) }));
|
|
1087
|
+
const wc = opts.from ? await switched(opts.from, token.chainId) : await walletOn(this.#ctx, token.chainId);
|
|
925
1088
|
if (!native && !wrapped) {
|
|
926
1089
|
const allowance = await client.readContract({
|
|
927
1090
|
address: token.underlying,
|
|
@@ -1024,6 +1187,7 @@ export class Wallet {
|
|
|
1024
1187
|
const step = opts.onStep ?? (() => {});
|
|
1025
1188
|
if (this.#busy > 0) step('queued');
|
|
1026
1189
|
return this.#exclusive(async () => {
|
|
1190
|
+
this.#writable();
|
|
1027
1191
|
const c = chainCtx(this.#ctx, token.chainId);
|
|
1028
1192
|
const record = await this.#burnAddress(token, null, () => step('burn-address'));
|
|
1029
1193
|
const { depositAddress, fee } = await registerDeposit(
|
|
@@ -1056,19 +1220,21 @@ export class Wallet {
|
|
|
1056
1220
|
* nothing; it names neither this address nor its public key. Anyone who gets the code can pay it, and see what its
|
|
1057
1221
|
* other payers paid privately: give each payer its own. It is checksummed text (bech32m, `zerc201…`). */
|
|
1058
1222
|
receiveCode(token: Token): Promise<string> {
|
|
1059
|
-
|
|
1060
|
-
|
|
1223
|
+
const ctx = this.#ctx;
|
|
1224
|
+
const code = (index: number) => ({ chainId: token.chainId, tokenId: token.tokenId, index });
|
|
1225
|
+
// the seed is asked for only past the codes derived ahead
|
|
1226
|
+
const derived = !!owner(ctx, code(nextBurn(ctx, token.chainId, token.tokenId)));
|
|
1227
|
+
return this.#seeded(async () => {
|
|
1061
1228
|
const { burnIndex, address } = await this.#burnAddress(token);
|
|
1229
|
+
if (!owner(ctx, code(burnIndex))) await this.#seed(); // calls queued before took the next ones
|
|
1062
1230
|
return encodeCode({
|
|
1063
1231
|
chainId: token.chainId,
|
|
1064
1232
|
tokenId: token.tokenId,
|
|
1065
1233
|
burnAddress: hexToBytes(address),
|
|
1066
|
-
publicKey: hexToBytes(
|
|
1067
|
-
spendKey(ctx, { chainId: token.chainId, tokenId: token.tokenId, index: burnIndex }).publicKey,
|
|
1068
|
-
),
|
|
1234
|
+
publicKey: hexToBytes(owner(ctx, code(burnIndex))!.publicKey),
|
|
1069
1235
|
key: codeKey(ctx.view.seed, token.chainId, token.tokenId, burnIndex),
|
|
1070
1236
|
});
|
|
1071
|
-
});
|
|
1237
|
+
}, !derived);
|
|
1072
1238
|
}
|
|
1073
1239
|
|
|
1074
1240
|
/** What a send or withdrawal of `amount` would cost now, or a consolidation of what the wallet holds. A send or
|
|
@@ -1153,10 +1319,11 @@ export class Wallet {
|
|
|
1153
1319
|
* what is spendable now, or it fails here: `needs-consolidation`, `funds-settling` or `insufficient-funds`.
|
|
1154
1320
|
* `amount` is what the recipient gets, and `maxFee` caps what it pays on top, `fees({ kind: 'send' }).fee`: the
|
|
1155
1321
|
* relayer names its fee, so a quote the user saw is the cap. */
|
|
1156
|
-
send(opts: { to: string; token: Token; amount: bigint; maxFee: bigint }): Promise<Operation> {
|
|
1157
|
-
|
|
1158
|
-
|
|
1159
|
-
|
|
1322
|
+
async send(opts: { to: string; token: Token; amount: bigint; maxFee: bigint }): Promise<Operation> {
|
|
1323
|
+
const { token, amount } = opts;
|
|
1324
|
+
if (amount <= 0n) fail('invalid', 'the amount must be positive');
|
|
1325
|
+
// the JoinSplit is proved by a later sync, which holds the seed until the payment is done
|
|
1326
|
+
return this.#seeded(async () => {
|
|
1160
1327
|
let to: Destination;
|
|
1161
1328
|
if (isAddress(opts.to)) {
|
|
1162
1329
|
const recipient = await lookupRecipient(this.#ctx.storage, hexToBytes(opts.to));
|
|
@@ -1188,7 +1355,7 @@ export class Wallet {
|
|
|
1188
1355
|
* touched; one still settling is waited for. `maxFee` caps the fees of every merge together,
|
|
1189
1356
|
* `fees({ kind: 'consolidate' }).fee`. */
|
|
1190
1357
|
consolidate(opts: { token: Token; coins?: string[]; maxFee: bigint }): Promise<Operation> {
|
|
1191
|
-
return this.#
|
|
1358
|
+
return this.#seeded(async () => {
|
|
1192
1359
|
if (!this.#ctx.network.mergeProver) fail('invalid', 'this network has no merge prover');
|
|
1193
1360
|
const to: Destination = { kind: 'self' };
|
|
1194
1361
|
const data: SpendData = { to, maxFee: opts.maxFee, step: null, paid: 0n };
|
|
@@ -1215,10 +1382,16 @@ export class Wallet {
|
|
|
1215
1382
|
/** Withdraws `amount` publicly to `to`, as zERC20 or with `unwrap` the underlying asset itself, so `to` needs no gas
|
|
1216
1383
|
* to unwrap. One JoinSplit, as `send`, and as there `to` gets all of `amount` and `maxFee` caps what is paid on top
|
|
1217
1384
|
* (`fees({ kind: 'withdraw' }).fee`). */
|
|
1218
|
-
withdraw(opts: {
|
|
1219
|
-
|
|
1220
|
-
|
|
1221
|
-
|
|
1385
|
+
async withdraw(opts: {
|
|
1386
|
+
to: Address;
|
|
1387
|
+
token: Token;
|
|
1388
|
+
amount: bigint;
|
|
1389
|
+
maxFee: bigint;
|
|
1390
|
+
unwrap?: boolean;
|
|
1391
|
+
}): Promise<Operation> {
|
|
1392
|
+
if (!isAddress(opts.to)) fail('invalid', `${String(opts.to)} is not an address`);
|
|
1393
|
+
if (opts.amount <= 0n) fail('invalid', 'the amount must be positive');
|
|
1394
|
+
return this.#seeded(async () => {
|
|
1222
1395
|
const to: Destination = { kind: 'public', address: opts.to, unwrap: opts.unwrap };
|
|
1223
1396
|
return this.#newPayment('withdraw', opts.token, opts.amount, { to, maxFee: opts.maxFee, step: null, paid: 0n });
|
|
1224
1397
|
});
|
|
@@ -1239,15 +1412,25 @@ export class Wallet {
|
|
|
1239
1412
|
}
|
|
1240
1413
|
}
|
|
1241
1414
|
|
|
1415
|
+
/** Each chain of `network`, with its servers. */
|
|
1416
|
+
const chainsOf = (network: Network): ChainCtx[] =>
|
|
1417
|
+
network.chains.map((config) => ({
|
|
1418
|
+
config,
|
|
1419
|
+
chain: new Chain(config),
|
|
1420
|
+
indexer: new Indexer(config.indexer),
|
|
1421
|
+
asp: new Asp(config.asp),
|
|
1422
|
+
}));
|
|
1423
|
+
|
|
1242
1424
|
/** Holds `address`'s wallet until the returned function is called: two tabs on one wallet would each overwrite the
|
|
1243
|
-
* other's state and spend the same notes.
|
|
1244
|
-
|
|
1425
|
+
* other's state and spend the same notes. A wallet that watches it (`view`) is held apart, as its state is its own.
|
|
1426
|
+
* Nothing where there are no Web Locks. */
|
|
1427
|
+
export async function lockWallet(address: Address, kind: 'wallet' | 'view' = 'wallet'): Promise<() => void> {
|
|
1245
1428
|
const locks = (globalThis as { navigator?: Navigator }).navigator?.locks;
|
|
1246
1429
|
if (!locks) return () => {};
|
|
1247
1430
|
let release = () => {};
|
|
1248
1431
|
const held = new Promise<void>((r) => (release = r));
|
|
1249
1432
|
const got = await new Promise<boolean>((resolve) => {
|
|
1250
|
-
void locks.request(`zerc20
|
|
1433
|
+
void locks.request(`zerc20-${kind}:${address.toLowerCase()}`, { ifAvailable: true }, (lock) => {
|
|
1251
1434
|
resolve(lock !== null);
|
|
1252
1435
|
return lock ? held : undefined;
|
|
1253
1436
|
});
|