@zerc20/wallet-sdk 0.3.0 → 0.5.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/README.md +1 -1
- 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 +85 -9
- package/dist/chain.js +60 -33
- package/dist/chain.js.map +1 -1
- package/dist/context.d.ts +14 -6
- package/dist/context.js +17 -3
- package/dist/context.js.map +1 -1
- package/dist/core.d.ts +35 -5
- package/dist/core.js +95 -23
- 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 +5 -2
- package/dist/index.js +6 -2
- package/dist/index.js.map +1 -1
- package/dist/pos.d.ts +7 -1
- package/dist/pos.js +213 -55
- package/dist/pos.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 +2 -0
- package/dist/servers.js +1 -0
- package/dist/servers.js.map +1 -1
- package/dist/services.d.ts +11 -4
- package/dist/services.js +26 -7
- package/dist/services.js.map +1 -1
- package/dist/spend.d.ts +30 -9
- package/dist/spend.js +46 -17
- 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 +53 -19
- 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 +109 -16
- package/dist/wallet.js +291 -85
- package/dist/wallet.js.map +1 -1
- package/package.json +3 -2
- package/src/accountId.ts +22 -0
- package/src/chain.ts +95 -49
- package/src/context.ts +35 -8
- package/src/core.ts +109 -24
- package/src/errors.ts +2 -0
- package/src/index.ts +18 -2
- package/src/pos.ts +240 -61
- package/src/rageExit.ts +237 -61
- package/src/seed.ts +24 -1
- package/src/servers.ts +4 -1
- package/src/services.ts +28 -15
- package/src/spend.ts +58 -23
- package/src/state.ts +25 -1
- package/src/sync.ts +57 -20
- package/src/viewKey.ts +49 -0
- package/src/wallet.ts +387 -106
- package/wasm/zerc20_wallet_core.d.ts +8 -1
- package/wasm/zerc20_wallet_core.js +31 -1
- package/wasm/zerc20_wallet_core_bg.wasm +0 -0
- package/wasm/zerc20_wallet_core_bg.wasm.d.ts +1 -0
package/dist/wallet.js
CHANGED
|
@@ -1,17 +1,20 @@
|
|
|
1
1
|
// The wallet an app works with. Amounts are bigints in a token's base units.
|
|
2
|
-
import { CanisterClient, codeKey, commit, decodeCode, encodeCode, lookupRecipient, nextPosLane, planDeposit, planPosLane, posKey, registeredViewKey, } from '@zerc20/storage-sdk';
|
|
3
|
-
import { bytesToHex, formatUnits, hexToBytes, isAddress, numberToHex, zeroAddress, } from 'viem';
|
|
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, isAddressEqual, numberToHex, zeroAddress, } from 'viem';
|
|
4
|
+
import { toAccount } from 'viem/accounts';
|
|
5
|
+
import { idAccount } from './accountId.js';
|
|
4
6
|
import { Chain, MAX_REFERRAL_BPS, NATIVE, erc20Abi, tokenAbi } from './chain.js';
|
|
5
|
-
import { chainCtx, chainState, owner, switched, walletOn, walletSees } from './context.js';
|
|
7
|
+
import { chainCtx, chainState, owner, refundSigner, switched, walletOn, walletSees, } from './context.js';
|
|
6
8
|
import { burnAddressFor, core, initCore } from './core.js';
|
|
7
9
|
import { WalletError, fail, guard } from './errors.js';
|
|
8
10
|
import { checkNetwork } from './network.js';
|
|
9
11
|
import { Asp, Indexer, PAGE } from './servers.js';
|
|
10
|
-
import { rageExit } from './rageExit.js';
|
|
11
|
-
import { registerDeposit, relayerFees } from './services.js';
|
|
12
|
-
import { advanceSpend, chosenSettling, coins, grossed, inputsOf, landed, maxPayment, mergePool, mergeRound, payWith, planStep, roundsToMerge, settling, withdrawalOf, } from './spend.js';
|
|
12
|
+
import { flushDepositAddress, rageExit, refundDepositAddress } from './rageExit.js';
|
|
13
|
+
import { registerDeposit, relayerFees, rewatchDeposit } from './services.js';
|
|
14
|
+
import { advanceSpend, bpsOf, chosenSettling, coins, grossed, inputsOf, landed, maxPayment, mergePool, mergeRound, payWith, planStep, roundsToMerge, settling, withdrawalOf, } from './spend.js';
|
|
13
15
|
import { emptyWalletState, openState, sealState, stateKey, unspent, } from './state.js';
|
|
14
16
|
import { WAIT_MS, commitmentTx, keyAll, nextBurn, recordBurn, storageState, syncAll, takeRefresh, tokenIds, topUpBurns, unusedBurn, } from './sync.js';
|
|
17
|
+
import { decodeViewKey, encodeViewKey } from './viewKey.js';
|
|
15
18
|
/** The ledger's bound outside an emergency. */
|
|
16
19
|
const NO_BOUND = 2n ** 64n - 1n;
|
|
17
20
|
export class Wallet {
|
|
@@ -24,6 +27,7 @@ export class Wallet {
|
|
|
24
27
|
#release = () => { };
|
|
25
28
|
#referrer;
|
|
26
29
|
#unlock;
|
|
30
|
+
#readOnly = false;
|
|
27
31
|
#closed = new AbortController();
|
|
28
32
|
constructor(ctx, unlock) {
|
|
29
33
|
this.#ctx = ctx;
|
|
@@ -39,6 +43,16 @@ export class Wallet {
|
|
|
39
43
|
get address() {
|
|
40
44
|
return this.#ctx.address;
|
|
41
45
|
}
|
|
46
|
+
/** The account's view key, for `watch` to follow it with on another device: whoever holds it sees what this wallet
|
|
47
|
+
* shows, except the private payments to receive codes, and can spend none of it. Checksummed text (bech32m,
|
|
48
|
+
* `zerc20view1…`). */
|
|
49
|
+
viewKey() {
|
|
50
|
+
return encodeViewKey(this.#ctx.view, owner(this.#ctx));
|
|
51
|
+
}
|
|
52
|
+
/** Whether this wallet only watches its account (`watch`): it shows what the account holds and spends nothing. */
|
|
53
|
+
get readOnly() {
|
|
54
|
+
return this.#readOnly;
|
|
55
|
+
}
|
|
42
56
|
/** Opens the wallet. The first time on this address, registers it for payments: one signature. */
|
|
43
57
|
static async open(opts) {
|
|
44
58
|
checkNetwork(opts.network);
|
|
@@ -70,12 +84,7 @@ export class Wallet {
|
|
|
70
84
|
}
|
|
71
85
|
static async #open(opts, seed, account, release) {
|
|
72
86
|
const address = account.address;
|
|
73
|
-
const chains = opts.network
|
|
74
|
-
config,
|
|
75
|
-
chain: new Chain(config),
|
|
76
|
-
indexer: new Indexer(config.indexer),
|
|
77
|
-
asp: new Asp(config.asp),
|
|
78
|
-
}));
|
|
87
|
+
const chains = chainsOf(opts.network);
|
|
79
88
|
const code = await chains[0]?.chain.client.getCode({ address });
|
|
80
89
|
// an EIP-7702 delegated account is still an EOA that signs
|
|
81
90
|
if (code && code !== '0x' && !code.startsWith('0xef0100'))
|
|
@@ -90,37 +99,83 @@ export class Wallet {
|
|
|
90
99
|
// ponytail: from genesis (~4 MB per million commitments); ask for a birthday once the chain has many
|
|
91
100
|
const restoring = !saved?.length && opts.birthday === undefined && !!(await storage.registration(hexToBytes(address)));
|
|
92
101
|
const view = await registeredViewKey(storage, seed, signer, hexToBytes(address), publicKey);
|
|
93
|
-
const
|
|
94
|
-
const loaded = saved?.length ? openState(key, saved) : null;
|
|
95
|
-
// never started over in place: the first save would overwrite what it held, operations in flight included
|
|
96
|
-
if (saved?.length && !loaded) {
|
|
97
|
-
fail('invalid', 'the saved state does not open with this seed (another seed, a newer version, or damaged): clear it to start over, which finds what it held again');
|
|
98
|
-
}
|
|
99
|
-
if (loaded && loaded.address.toLowerCase() !== address.toLowerCase())
|
|
100
|
-
fail('invalid', `the saved state is ${loaded.address}'s: keep one per address`);
|
|
101
|
-
const state = loaded ?? emptyWalletState(address);
|
|
102
|
-
const ctx = {
|
|
102
|
+
const wallet = await Wallet.#begin({
|
|
103
103
|
network: opts.network,
|
|
104
104
|
seed,
|
|
105
105
|
owners: new Map(),
|
|
106
|
+
refundSigners: new Map(),
|
|
106
107
|
address,
|
|
107
108
|
account,
|
|
108
109
|
walletClient: opts.walletClient ?? null,
|
|
109
110
|
storage,
|
|
110
111
|
view,
|
|
111
112
|
chains,
|
|
112
|
-
|
|
113
|
-
save: () => opts.persist.save(sealState(key, state)),
|
|
114
|
-
lastFullRefreshMs: 0,
|
|
115
|
-
};
|
|
116
|
-
if (!loaded)
|
|
117
|
-
await start(ctx, restoring ? 0n : opts.birthday);
|
|
118
|
-
const wallet = new Wallet(ctx, typeof opts.seed === 'function' ? opts.seed : undefined);
|
|
113
|
+
}, { persist: opts.persist, saved, key: stateKey(seed), from: restoring ? 0n : opts.birthday }, typeof opts.seed === 'function' ? opts.seed : undefined);
|
|
119
114
|
wallet.#release = release;
|
|
120
115
|
wallet.#referrer = opts.referrer;
|
|
116
|
+
return wallet;
|
|
117
|
+
}
|
|
118
|
+
/** Opens an account to follow by its view key (`viewKey()`): its balances, coins and history as its own wallet shows
|
|
119
|
+
* them, less the private payments to its receive codes, whose keys only the seed derives. It spends and writes
|
|
120
|
+
* nothing: a call that would fails with `read-only`. The key must be the account's current one on this network. */
|
|
121
|
+
static async watch(opts) {
|
|
122
|
+
checkNetwork(opts.network);
|
|
123
|
+
await initCore();
|
|
124
|
+
const { view, owner } = decodeViewKey(opts.viewKey) ?? fail('invalid', 'not a view key');
|
|
125
|
+
const address = getAddress(bytesToHex(view.address));
|
|
126
|
+
const release = await lockWallet(address, 'view');
|
|
127
|
+
try {
|
|
128
|
+
const storage = await CanisterClient.connect(opts.network.storage.host, opts.network.storage.canisterId);
|
|
129
|
+
// what the account signed: a nullifying key that is not the account's would show spent coins as unspent
|
|
130
|
+
const r = await lookupRecipient(storage, view.address);
|
|
131
|
+
if (!r || bytesToHex(r.publicKey) !== owner.publicKey || bytesToHex(r.notifyKey) !== bytesToHex(notifyKey(view)))
|
|
132
|
+
fail('invalid', `not ${address}'s view key on ${opts.network.name}, or one it has replaced`);
|
|
133
|
+
const wallet = await Wallet.#begin({
|
|
134
|
+
network: opts.network,
|
|
135
|
+
seed: null,
|
|
136
|
+
owners: new Map([['', owner]]),
|
|
137
|
+
refundSigners: new Map(),
|
|
138
|
+
address,
|
|
139
|
+
// signs nothing: every call that would send is refused first
|
|
140
|
+
account: toAccount(address),
|
|
141
|
+
walletClient: null,
|
|
142
|
+
storage,
|
|
143
|
+
view,
|
|
144
|
+
chains: chainsOf(opts.network),
|
|
145
|
+
},
|
|
146
|
+
// registered, so its past is looked for
|
|
147
|
+
{
|
|
148
|
+
persist: opts.persist,
|
|
149
|
+
saved: await opts.persist.load(),
|
|
150
|
+
key: stateKey(view.seed),
|
|
151
|
+
from: opts.birthday ?? 0n,
|
|
152
|
+
});
|
|
153
|
+
wallet.#release = release;
|
|
154
|
+
wallet.#readOnly = true;
|
|
155
|
+
return wallet;
|
|
156
|
+
}
|
|
157
|
+
catch (e) {
|
|
158
|
+
release();
|
|
159
|
+
throw e;
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
/** The wallet over `base`, its state opened from what `persist` saved, with `key`, or started `from` a block or date. */
|
|
163
|
+
static async #begin(base, { persist, saved, key, from, }, unlock) {
|
|
164
|
+
const loaded = saved?.length ? openState(key, saved) : null;
|
|
165
|
+
// never started over in place: the first save would overwrite what it held, operations in flight included
|
|
166
|
+
if (saved?.length && !loaded) {
|
|
167
|
+
fail('invalid', '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');
|
|
168
|
+
}
|
|
169
|
+
if (loaded && loaded.address.toLowerCase() !== base.address.toLowerCase())
|
|
170
|
+
fail('invalid', `the saved state is ${loaded.address}'s: keep one per address`);
|
|
171
|
+
const state = loaded ?? emptyWalletState(base.address);
|
|
172
|
+
const ctx = { ...base, state, save: () => persist.save(sealState(key, state)), lastFullRefreshMs: 0 };
|
|
173
|
+
if (!loaded)
|
|
174
|
+
await start(ctx, from);
|
|
175
|
+
const wallet = new Wallet(ctx, unlock);
|
|
121
176
|
// a saved state names its tokens by id, and its coins stand by the chains' roots: read both now, so balances, coins
|
|
122
177
|
// and history work before the first sync
|
|
123
|
-
const [tokens] = await Promise.all([Promise.all(chains.map((c) => c.chain.tokens())), wallet.#readRoots()]);
|
|
178
|
+
const [tokens] = await Promise.all([Promise.all(ctx.chains.map((c) => c.chain.tokens())), wallet.#readRoots()]);
|
|
124
179
|
wallet.#tokens = tokens.flat();
|
|
125
180
|
keyAll(ctx, wallet.#tokens);
|
|
126
181
|
return wallet;
|
|
@@ -151,10 +206,16 @@ export class Wallet {
|
|
|
151
206
|
* anything else (a browser shows a passkey prompt only in the click that asked), and queued in the step that took it,
|
|
152
207
|
* so that a sync ending meanwhile does not let it go. */
|
|
153
208
|
async #seeded(f, needs = true) {
|
|
209
|
+
this.#writable();
|
|
154
210
|
if (needs && !this.#ctx.seed)
|
|
155
211
|
this.#hold(await this.#unlock());
|
|
156
212
|
return this.#exclusive(f);
|
|
157
213
|
}
|
|
214
|
+
/** Refuses a call that spends or writes to storage, in a wallet that watches. */
|
|
215
|
+
#writable() {
|
|
216
|
+
if (this.#readOnly)
|
|
217
|
+
fail('read-only', 'this wallet watches its account by its view key: open it with its seed');
|
|
218
|
+
}
|
|
158
219
|
/** Runs `f` after everything queued before it: one state, changed by one task at a time. */
|
|
159
220
|
#exclusive(f) {
|
|
160
221
|
this.#busy++;
|
|
@@ -192,8 +253,10 @@ export class Wallet {
|
|
|
192
253
|
return new Promise((resolve, reject) => {
|
|
193
254
|
this.#exclusive(async () => {
|
|
194
255
|
await this.#catchUp().then(resolve, reject);
|
|
195
|
-
// still this task's: a deposit queued meanwhile finds the one recorded ahead, rather than writing at handout
|
|
196
|
-
|
|
256
|
+
// still this task's: a deposit queued meanwhile finds the one recorded ahead, rather than writing at handout;
|
|
257
|
+
// tried again after the next sync. A wallet that watches records none, as it writes nothing.
|
|
258
|
+
if (!this.#readOnly)
|
|
259
|
+
await topUpBurns(this.#ctx, this.#closed.signal).catch(() => { });
|
|
197
260
|
this.#dropSeed();
|
|
198
261
|
}).catch(reject); // closed before it ran
|
|
199
262
|
});
|
|
@@ -201,10 +264,8 @@ export class Wallet {
|
|
|
201
264
|
async #catchUp() {
|
|
202
265
|
const errors = await syncAll(this.#ctx);
|
|
203
266
|
errors.push(...(await this.#readRoots()));
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
.catch((e) => (errors.push(e), this.#tokens.filter((t) => t.chainId === c.config.chainId)))));
|
|
207
|
-
this.#tokens = listed.flat();
|
|
267
|
+
// as syncAll read them: a chain it could not read keeps those read before
|
|
268
|
+
this.#tokens = this.#ctx.chains.flatMap((c) => c.chain.known);
|
|
208
269
|
for (const op of this.#ctx.state.ops.filter((o) => o.status === 'pending')) {
|
|
209
270
|
try {
|
|
210
271
|
if (op.kind === 'send' || op.kind === 'withdraw' || op.kind === 'consolidate')
|
|
@@ -320,7 +381,9 @@ export class Wallet {
|
|
|
320
381
|
amount: n.value,
|
|
321
382
|
state,
|
|
322
383
|
from: n.peer,
|
|
384
|
+
via: null,
|
|
323
385
|
screenedBy: null,
|
|
386
|
+
at: f.atMs === undefined ? null : new Date(f.atMs),
|
|
324
387
|
});
|
|
325
388
|
}
|
|
326
389
|
}
|
|
@@ -347,7 +410,9 @@ export class Wallet {
|
|
|
347
410
|
amount: f.amount,
|
|
348
411
|
state,
|
|
349
412
|
from: f.sender,
|
|
413
|
+
via: this.#via(d, f.sender),
|
|
350
414
|
screenedBy,
|
|
415
|
+
at: f.atMs === undefined ? null : new Date(f.atMs),
|
|
351
416
|
});
|
|
352
417
|
}
|
|
353
418
|
}
|
|
@@ -418,24 +483,17 @@ export class Wallet {
|
|
|
418
483
|
}
|
|
419
484
|
}
|
|
420
485
|
for (const d of s.deposits) {
|
|
421
|
-
// registered on this device, or on another, which says so in storage
|
|
422
|
-
const registered = d.depositAddress ??
|
|
423
|
-
s.receipts.flatMap((r) => r.kind === 'deposit-address' && r.chainId === d.chainId && r.burnAddress === d.address.toLowerCase()
|
|
424
|
-
? [r.depositAddress]
|
|
425
|
-
: [])[0];
|
|
426
486
|
// on chain once found; shown once its commitment is read, which names the amount
|
|
427
487
|
for (const f of d.found.filter((f) => f.amount !== null)) {
|
|
428
|
-
const forwarded = registered && f.sender?.toLowerCase() === registered.toLowerCase();
|
|
429
488
|
// into a burn address a payer chose: theirs to this wallet, as a payment is
|
|
430
489
|
const kind = d.managed ? 'sale' : d.payer ? 'received' : 'deposited';
|
|
431
|
-
const via = forwarded ? { depositAddress: registered } : null;
|
|
432
490
|
const entry = {
|
|
433
491
|
...at(kind, d.chainId, f, d.createdMs),
|
|
434
492
|
token: this.#token(d.chainId, f.tokenId),
|
|
435
493
|
amount: f.amount,
|
|
436
494
|
burnAddress: d.address,
|
|
437
495
|
};
|
|
438
|
-
out.push({ ...entry, fee: null, counterparty: f.sender, via, txHash: null });
|
|
496
|
+
out.push({ ...entry, fee: null, counterparty: f.sender, via: this.#via(d, f.sender), txHash: null });
|
|
439
497
|
}
|
|
440
498
|
}
|
|
441
499
|
// a deposit that paid someone (`deposit({ to })`), once its transfer is found
|
|
@@ -478,9 +536,17 @@ export class Wallet {
|
|
|
478
536
|
const found = s.sent.some((x) => x.chainId === op.chainId &&
|
|
479
537
|
x.address === op.data.burnAddress.toLowerCase() &&
|
|
480
538
|
x.found.some((f) => f.amount !== null));
|
|
481
|
-
|
|
539
|
+
const { to, code, txHash } = op.data;
|
|
540
|
+
if ((!to && !code) || !txHash || op.status === 'pending' || found)
|
|
482
541
|
continue;
|
|
483
|
-
out.push({
|
|
542
|
+
out.push({
|
|
543
|
+
...base,
|
|
544
|
+
kind: 'sent',
|
|
545
|
+
fee: null,
|
|
546
|
+
counterparty: to ?? null,
|
|
547
|
+
via: code ? { code } : null,
|
|
548
|
+
txHash,
|
|
549
|
+
});
|
|
484
550
|
continue;
|
|
485
551
|
}
|
|
486
552
|
// under way (in operations()), or shown by its change
|
|
@@ -498,6 +564,17 @@ export class Wallet {
|
|
|
498
564
|
}
|
|
499
565
|
return out.sort((a, b) => (b.at?.getTime() ?? 0) - (a.at?.getTime() ?? 0));
|
|
500
566
|
}
|
|
567
|
+
/** The deposit address `d` registered (here, or on another device, which says so in storage) if `sender` is it: what
|
|
568
|
+
* a deposit from `sender` came through. */
|
|
569
|
+
#via(d, sender) {
|
|
570
|
+
const registered = d.depositAddress ??
|
|
571
|
+
this.#ctx.state.receipts.flatMap((r) => r.kind === 'deposit-address' && r.chainId === d.chainId && r.burnAddress === d.address.toLowerCase()
|
|
572
|
+
? [r.depositAddress]
|
|
573
|
+
: [])[0];
|
|
574
|
+
return registered && sender && isAddressEqual(sender, registered)
|
|
575
|
+
? { depositAddress: getAddress(registered) }
|
|
576
|
+
: null;
|
|
577
|
+
}
|
|
501
578
|
/** The text of a receive code this wallet paid, by its burn address, from storage's record of paying it. */
|
|
502
579
|
#paidCode(chainId, burnAddress) {
|
|
503
580
|
for (const c of this.#ctx.state.storage?.outgoing.lanes ?? []) {
|
|
@@ -539,6 +616,31 @@ export class Wallet {
|
|
|
539
616
|
state: f.spent ? 'spent' : f.asp === null ? 'screening' : f.asp,
|
|
540
617
|
})));
|
|
541
618
|
}
|
|
619
|
+
/** The deposit addresses this wallet handed out (`depositAddress`), on any of its devices, newest first: for an app to
|
|
620
|
+
* show each while what is sent to it is on its way, which `activity()` shows once deposited. Each is dated, and its
|
|
621
|
+
* fee told, by its receipt in storage, alike on every device. */
|
|
622
|
+
depositAddresses() {
|
|
623
|
+
return this.#ctx.state.receipts
|
|
624
|
+
.flatMap((r) => {
|
|
625
|
+
if (r.kind !== 'deposit-address')
|
|
626
|
+
return [];
|
|
627
|
+
const token = this.#token(r.chainId, r.tokenId);
|
|
628
|
+
return [{ address: getAddress(r.depositAddress), token, fee: r.args?.fee ?? null, at: new Date(r.atMs) }];
|
|
629
|
+
})
|
|
630
|
+
.sort((a, b) => b.at.getTime() - a.at.getTime());
|
|
631
|
+
}
|
|
632
|
+
/** What `address` is to this wallet on `chainId`: one of its own burn addresses (`deposit`), one it paid someone
|
|
633
|
+
* into (`payment`, `deposit({ to })` to an address), or neither (null), as is a receive code's that it paid. On chain
|
|
634
|
+
* either is a plain transfer to an address that names no one: this tells an app listing the account's public
|
|
635
|
+
* transfers which they were. Both are recorded before the transfer is sent. */
|
|
636
|
+
burnAddressOf(chainId, address) {
|
|
637
|
+
const s = this.#ctx.state;
|
|
638
|
+
const a = address.toLowerCase();
|
|
639
|
+
if (s.deposits.some((d) => d.chainId === chainId && d.address.toLowerCase() === a))
|
|
640
|
+
return { kind: 'deposit' };
|
|
641
|
+
const paid = s.sent.find((x) => x.chainId === chainId && x.address.toLowerCase() === a);
|
|
642
|
+
return paid ? { kind: 'payment', to: paid.to } : null;
|
|
643
|
+
}
|
|
542
644
|
/** The payments to the burn addresses this wallet's points of sale handed out. Spent ones are, normally, in a
|
|
543
645
|
* merged note. */
|
|
544
646
|
payments() {
|
|
@@ -550,6 +652,7 @@ export class Wallet {
|
|
|
550
652
|
* addresses. */
|
|
551
653
|
posKey() {
|
|
552
654
|
return this.#exclusive(async () => {
|
|
655
|
+
this.#writable();
|
|
553
656
|
const ctx = this.#ctx;
|
|
554
657
|
const tokens = await tokenIds(ctx);
|
|
555
658
|
let n = 0;
|
|
@@ -561,8 +664,12 @@ export class Wallet {
|
|
|
561
664
|
});
|
|
562
665
|
}
|
|
563
666
|
/** Refunds deposit `id` to its sender without the ASP, the attester or a proved root: for a deposit the ASP
|
|
564
|
-
* rejected, or when those services stop. The account's wallet sends the transactions and pays their gas
|
|
565
|
-
|
|
667
|
+
* rejected, or when those services stop. The account's wallet sends the transactions and pays their gas, or with
|
|
668
|
+
* `from` another wallet of the user's does, as `deposit({ from })` pays. One through a deposit address of this
|
|
669
|
+
* wallet's (`CoinInfo.via`) goes to `to`, by default `from`'s account, else this account; any other goes back to its
|
|
670
|
+
* sender, and one through a point of sale's deposit address to the refund address its payer named. */
|
|
671
|
+
rageExit(id, opts = {}) {
|
|
672
|
+
const route = this.#route(opts);
|
|
566
673
|
return this.#seeded(async () => {
|
|
567
674
|
const [chain, index] = id.split(':');
|
|
568
675
|
const record = this.#ctx.state.deposits.find((d) => `${d.chainId}` === chain && d.found.some((f) => `${f.index}` === index)) ??
|
|
@@ -571,9 +678,32 @@ export class Wallet {
|
|
|
571
678
|
const op = this.#ctx.state.ops.find((o) => `${o.chainId}` === chain && inputsOf(o).some((c) => `${c.index}` === index));
|
|
572
679
|
if (op)
|
|
573
680
|
fail('invalid', `deposit ${id} is an input of operation ${op.id}, which is in flight`);
|
|
574
|
-
return rageExit(this.#ctx, record, Number(index));
|
|
681
|
+
return rageExit(this.#ctx, record, Number(index), route);
|
|
682
|
+
});
|
|
683
|
+
}
|
|
684
|
+
/** Sends what deposit address `address` of this wallet's holds of `asset` (NATIVE for the chain's own currency) to
|
|
685
|
+
* `to`: another token sent to it, which it cannot wrap. What it can wrap is wrapped instead, as a payment to it is.
|
|
686
|
+
* It deploys on its first flush, and refunds nothing before. `from` and `to` as for `rageExit`. */
|
|
687
|
+
refundDepositAddress(address, asset, opts = {}) {
|
|
688
|
+
if (!isAddress(asset, { strict: false }) || asset.toLowerCase() === zeroAddress)
|
|
689
|
+
fail('invalid', `${asset} is not an asset's address`);
|
|
690
|
+
const route = this.#route(opts);
|
|
691
|
+
return this.#seeded(async () => {
|
|
692
|
+
const record = this.#ctx.state.deposits.find((d) => !d.managed && this.#via(d, address)) ??
|
|
693
|
+
fail('invalid', `${address} is not a deposit address of this wallet's`);
|
|
694
|
+
return refundDepositAddress(this.#ctx, record, getAddress(address), getAddress(asset), route);
|
|
575
695
|
});
|
|
576
696
|
}
|
|
697
|
+
/** Who sends a refund and where it goes (`rageExit`), checked before the seed is asked for. */
|
|
698
|
+
#route(opts) {
|
|
699
|
+
const from = opts.from ?? null;
|
|
700
|
+
if (from && !from.account)
|
|
701
|
+
fail('invalid', 'the wallet client has no account');
|
|
702
|
+
const to = opts.to ?? from?.account?.address ?? this.address;
|
|
703
|
+
if (!isAddress(to, { strict: false }) || to.toLowerCase() === zeroAddress)
|
|
704
|
+
fail('invalid', `${to} is not an address to refund to`);
|
|
705
|
+
return { from, to: getAddress(to) };
|
|
706
|
+
}
|
|
577
707
|
operations() {
|
|
578
708
|
return this.#ctx.state.ops.map((op) => this.#operation(op));
|
|
579
709
|
}
|
|
@@ -638,7 +768,9 @@ export class Wallet {
|
|
|
638
768
|
let record = unusedBurn(ctx, token.chainId, token.tokenId);
|
|
639
769
|
if (!record) {
|
|
640
770
|
recording();
|
|
641
|
-
record = await recordBurn(ctx, token.chainId, token.tokenId, await tokenIds(ctx),
|
|
771
|
+
record = await recordBurn(ctx, token.chainId, token.tokenId, await tokenIds(ctx), {
|
|
772
|
+
signal: this.#closed.signal,
|
|
773
|
+
});
|
|
642
774
|
}
|
|
643
775
|
const next = chainState(ctx, token.chainId).burnNext;
|
|
644
776
|
// recording may have read another device's later ones
|
|
@@ -652,10 +784,12 @@ export class Wallet {
|
|
|
652
784
|
* burn address (approving it first if needed). The deposit is spendable once the ASP has screened it. More than the
|
|
653
785
|
* account holds fails at once (`insufficient-funds`), before anything is asked of the wallet.
|
|
654
786
|
*
|
|
655
|
-
* With `to`, an
|
|
656
|
-
* under the public key they registered, told to them in storage first as a note is
|
|
657
|
-
* receive it as a deposit of their own, screened like any; the burn address names no one. It
|
|
658
|
-
* as sent. Finding the address takes about 2^16 hashes (`burn-address`).
|
|
787
|
+
* With `to`, an account that registered, by its address or its zERC20 ID, it pays them instead, privately: into a
|
|
788
|
+
* fresh burn address of theirs, under the public key they registered, told to them in storage first as a note is
|
|
789
|
+
* (`planDeposit`). They receive it as a deposit of their own, screened like any; the burn address names no one. It
|
|
790
|
+
* shows in `activity()` as sent. Finding the address takes about 2^16 hashes (`burn-address`). Or `to` is a receive
|
|
791
|
+
* code (`receiveCode`), paid into its burn address, which its owner already watches: storage is told nothing, so
|
|
792
|
+
* only this device's `activity()` shows it as sent.
|
|
659
793
|
*
|
|
660
794
|
* With `wrapped`, it pays from the account's zERC20 itself, as a withdrawal pays out, rather than its underlying
|
|
661
795
|
* asset: a plain transfer into the burn address, with no wrap and no approval.
|
|
@@ -666,9 +800,11 @@ export class Wallet {
|
|
|
666
800
|
* Returns once the wrap is sent: the operation is done once it is mined, which `wait()` or the next sync finds.
|
|
667
801
|
* `onStep` hears what it waits on meanwhile, for an app to show. */
|
|
668
802
|
async deposit(opts) {
|
|
803
|
+
this.#writable();
|
|
669
804
|
if (opts.amount <= 0n)
|
|
670
805
|
fail('invalid', 'the amount must be positive');
|
|
671
806
|
const payer = opts.from ? (opts.from.account ?? fail('invalid', 'the wallet client has no account')) : null;
|
|
807
|
+
const to = opts.to === undefined ? null : recipientOf(opts.to, opts.token);
|
|
672
808
|
const step = opts.onStep ?? (() => { });
|
|
673
809
|
if (this.#busy > 0)
|
|
674
810
|
step('queued');
|
|
@@ -691,10 +827,16 @@ export class Wallet {
|
|
|
691
827
|
const of = wrapped ? token.symbol : `${token.symbol}'s underlying asset`;
|
|
692
828
|
fail('insufficient-funds', `${payer ? 'the wallet' : 'the account'} holds ${formatUnits(held, token.decimals)} of ${of}, less than ${formatUnits(amount, token.decimals)}`);
|
|
693
829
|
}
|
|
694
|
-
const address =
|
|
695
|
-
?
|
|
696
|
-
:
|
|
697
|
-
|
|
830
|
+
const address = to?.kind === 'code'
|
|
831
|
+
? to.burnAddress
|
|
832
|
+
: to
|
|
833
|
+
? await this.#burnFor(token, amount, to.address, () => step('burn-address'))
|
|
834
|
+
: (await this.#burnAddress(token, null, () => step('burn-address'))).address;
|
|
835
|
+
const op = this.#newOp('deposit', token, amount, to?.kind === 'code'
|
|
836
|
+
? { burnAddress: address, code: to.code }
|
|
837
|
+
: to
|
|
838
|
+
? { burnAddress: address, to: to.address }
|
|
839
|
+
: { burnAddress: address });
|
|
698
840
|
await this.#ctx.save();
|
|
699
841
|
try {
|
|
700
842
|
const wc = opts.from ? await switched(opts.from, token.chainId) : await walletOn(this.#ctx, token.chainId);
|
|
@@ -764,7 +906,7 @@ export class Wallet {
|
|
|
764
906
|
const ctx = this.#ctx;
|
|
765
907
|
const recipient = await lookupRecipient(ctx.storage, hexToBytes(to));
|
|
766
908
|
if (!recipient)
|
|
767
|
-
fail('recipient-not-registered', `${to}
|
|
909
|
+
fail('recipient-not-registered', `${to} never opened a zERC20 wallet: ask for a receive code`);
|
|
768
910
|
searching();
|
|
769
911
|
const burn = await burnAddressFor(token.chainId, bytesToHex(recipient.publicKey), this.#closed.signal);
|
|
770
912
|
// with the address, so that every device of this wallet finds the transfer it then sends
|
|
@@ -783,17 +925,28 @@ export class Wallet {
|
|
|
783
925
|
/** An address that takes `token`'s underlying asset from anywhere (an exchange withdrawal, another wallet) and
|
|
784
926
|
* deposits it privately, less `fee` (in the underlying's base units) to the deposit relayer each time: what arrives
|
|
785
927
|
* must exceed it. A wallet opened with a `referrer` also pays it its `bps` of the rest. Watched for 7 days by the
|
|
786
|
-
* deposit relayer
|
|
928
|
+
* deposit relayer. It names no account: its refunds go where a key of its own signs for (`rageExit`'s `to`,
|
|
929
|
+
* `refundDepositAddress`). `depositAddresses()` lists it. `onStep` hears what it waits on (`queued`,
|
|
930
|
+
* `burn-address`). */
|
|
787
931
|
depositAddress(token, opts = {}) {
|
|
788
932
|
const step = opts.onStep ?? (() => { });
|
|
933
|
+
const ctx = this.#ctx;
|
|
934
|
+
const code = (index) => ({ chainId: token.chainId, tokenId: token.tokenId, index });
|
|
935
|
+
// the seed is asked for only past the refund signers derived ahead
|
|
936
|
+
const derived = !!refundSigner(ctx, code(nextBurn(ctx, token.chainId, token.tokenId)));
|
|
789
937
|
if (this.#busy > 0)
|
|
790
938
|
step('queued');
|
|
791
|
-
return this.#
|
|
792
|
-
const c = chainCtx(
|
|
939
|
+
return this.#seeded(async () => {
|
|
940
|
+
const c = chainCtx(ctx, token.chainId);
|
|
793
941
|
const record = await this.#burnAddress(token, null, () => step('burn-address'));
|
|
794
|
-
|
|
942
|
+
if (!refundSigner(ctx, code(record.burnIndex)))
|
|
943
|
+
await this.#seed(); // calls queued before took the next ones
|
|
944
|
+
const signer = refundSigner(ctx, code(record.burnIndex));
|
|
945
|
+
const referrer = this.#referrer ?? { address: zeroAddress, bps: 0 };
|
|
946
|
+
const { depositAddress, feeRecipient, fee } = await registerDeposit(c.chain, token.address, record.address, { signer }, referrer);
|
|
795
947
|
record.depositAddress = depositAddress;
|
|
796
|
-
// for the wallet's other devices, to show what comes through it
|
|
948
|
+
// for the wallet's other devices, to show what comes through it and what it takes, and for a flush without the
|
|
949
|
+
// relayer: written by the next sync
|
|
797
950
|
const burnAddress = record.address.toLowerCase();
|
|
798
951
|
this.#ctx.state.receipts.push({
|
|
799
952
|
kind: 'deposit-address',
|
|
@@ -801,12 +954,42 @@ export class Wallet {
|
|
|
801
954
|
tokenId: token.tokenId,
|
|
802
955
|
burnAddress,
|
|
803
956
|
depositAddress,
|
|
957
|
+
args: { fee, feeRecipient, refundSigner: signer, referrer: referrer.address, referralBps: referrer.bps },
|
|
804
958
|
atMs: Date.now(),
|
|
805
959
|
unwritten: true,
|
|
806
960
|
});
|
|
807
961
|
await this.#ctx.save();
|
|
808
962
|
return { address: depositAddress, fee };
|
|
809
|
-
});
|
|
963
|
+
}, !derived);
|
|
964
|
+
}
|
|
965
|
+
/** Has the deposit relayer watch deposit address `address` of this wallet's for another 7 days, at the fee it was
|
|
966
|
+
* handed out with: what reached it after its 7 days is deposited then, and so is what reaches it within the next.
|
|
967
|
+
* `depositAddresses()` dates it from now, on every device. */
|
|
968
|
+
rewatchDepositAddress(address) {
|
|
969
|
+
return this.#seeded(async () => {
|
|
970
|
+
const receipt = this.#depositReceipt(address);
|
|
971
|
+
const fee = await rewatchDeposit(chainCtx(this.#ctx, receipt.chainId).chain, getAddress(address));
|
|
972
|
+
// for every device of the wallet, written by the next sync: storage dates it anew
|
|
973
|
+
receipt.atMs = Date.now();
|
|
974
|
+
receipt.unwritten = true;
|
|
975
|
+
await this.#ctx.save();
|
|
976
|
+
return { address: getAddress(address), fee };
|
|
977
|
+
}, false);
|
|
978
|
+
}
|
|
979
|
+
/** Deposits what deposit address `address` of this wallet's holds, as the deposit relayer would, for when it does
|
|
980
|
+
* not: down, or no longer watching the address. Its receipt says all the address was registered with, wherever its
|
|
981
|
+
* `fee` is known (`depositAddresses()`), so no relayer is asked. `from`, another wallet of the user's, sends the
|
|
982
|
+
* flush and pays its gas, and shows on chain as the depositor: never the account's own, which the address does not
|
|
983
|
+
* name. What it holds must exceed the fee (`insufficient-funds`). It shows in `activity()` as any deposit through
|
|
984
|
+
* the address. */
|
|
985
|
+
flushDepositAddress(address, opts) {
|
|
986
|
+
if (!opts.from.account)
|
|
987
|
+
fail('invalid', 'the wallet client has no account');
|
|
988
|
+
return this.#seeded(() => flushDepositAddress(this.#ctx, this.#depositReceipt(address), opts.from), false);
|
|
989
|
+
}
|
|
990
|
+
/** The receipt of deposit address `address` of this wallet's. */
|
|
991
|
+
#depositReceipt(address) {
|
|
992
|
+
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`));
|
|
810
993
|
}
|
|
811
994
|
/** A receive code for `token`, to hand to one payer: it pays any amount, as often as it likes, publicly by a transfer
|
|
812
995
|
* of the token to the code's burn address (a deposit, like a point of sale's payment addresses), or privately by
|
|
@@ -850,14 +1033,20 @@ export class Wallet {
|
|
|
850
1033
|
const steps = BigInt(roundsToMerge(mergePool(all, chosen)));
|
|
851
1034
|
// a merge pays the relayer publicly, so the protocol takes its cut of that, grossed up as a payment's is
|
|
852
1035
|
const cut = steps === 0n ? 0n : (await grossed(this.#ctx, token.chainId, token.tokenId, relayer)) - relayer;
|
|
853
|
-
return {
|
|
1036
|
+
return {
|
|
1037
|
+
relayer: relayer * steps,
|
|
1038
|
+
protocol: cut * steps,
|
|
1039
|
+
protocolBps: bpsOf(cut, relayer + cut),
|
|
1040
|
+
fee: (relayer + cut) * steps,
|
|
1041
|
+
steps: Number(steps),
|
|
1042
|
+
};
|
|
854
1043
|
}
|
|
855
1044
|
// as the payment's plan reckons it, so that `fee` is a `maxFee` it passes
|
|
856
|
-
const { protocol } = await withdrawalOf(this.#ctx, token.chainId, token.tokenId, opts.kind === 'withdraw', amount, relayer);
|
|
1045
|
+
const { withdrawn, protocol } = await withdrawalOf(this.#ctx, token.chainId, token.tokenId, opts.kind === 'withdraw', amount, relayer);
|
|
857
1046
|
const onTheWay = this.#ctx.state.ops.some((o) => o.status === 'pending') ||
|
|
858
1047
|
settling(this.#ctx, token.chainId, token.tokenId, size);
|
|
859
1048
|
payWith(all, amount + relayer + protocol, relayer, onTheWay);
|
|
860
|
-
return { relayer, protocol, fee: relayer + protocol, steps: 1 };
|
|
1049
|
+
return { relayer, protocol, protocolBps: bpsOf(protocol, withdrawn), fee: relayer + protocol, steps: 1 };
|
|
861
1050
|
}
|
|
862
1051
|
/** The most a send or withdrawal of `token` can pay: what is spendable, less the fees `fees` quotes on top of it, and
|
|
863
1052
|
* the merges first when one JoinSplit cannot spend it all, which `fees` then asks for (`needs-consolidation`). */
|
|
@@ -879,9 +1068,10 @@ export class Wallet {
|
|
|
879
1068
|
const merge = mergeRound(coins(this.#ctx, token.chainId, token.tokenId, size), fee);
|
|
880
1069
|
return merge && merge.map((m) => `${token.chainId}:${m.index}`);
|
|
881
1070
|
}
|
|
882
|
-
/** Pays `amount` privately: to an
|
|
883
|
-
* (`receiveCode`). An
|
|
884
|
-
* what is spendable now, or it fails here: `needs-consolidation`,
|
|
1071
|
+
/** Pays `amount` privately: to an account that has registered (any wallet that ever opened), by its address or its
|
|
1072
|
+
* zERC20 ID (`accountId`), or to a receive code (`receiveCode`). An account that never registered fails with
|
|
1073
|
+
* `recipient-not-registered`. One JoinSplit from what is spendable now, or it fails here: `needs-consolidation`,
|
|
1074
|
+
* `funds-settling` or `insufficient-funds`.
|
|
885
1075
|
* `amount` is what the recipient gets, and `maxFee` caps what it pays on top, `fees({ kind: 'send' }).fee`: the
|
|
886
1076
|
* relayer names its fee, so a quote the user saw is the cap. */
|
|
887
1077
|
async send(opts) {
|
|
@@ -890,21 +1080,16 @@ export class Wallet {
|
|
|
890
1080
|
fail('invalid', 'the amount must be positive');
|
|
891
1081
|
// the JoinSplit is proved by a later sync, which holds the seed until the payment is done
|
|
892
1082
|
return this.#seeded(async () => {
|
|
1083
|
+
const r = recipientOf(opts.to, token);
|
|
893
1084
|
let to;
|
|
894
|
-
if (
|
|
895
|
-
const recipient = await lookupRecipient(this.#ctx.storage, hexToBytes(
|
|
1085
|
+
if (r.kind === 'address') {
|
|
1086
|
+
const recipient = await lookupRecipient(this.#ctx.storage, hexToBytes(r.address));
|
|
896
1087
|
if (!recipient)
|
|
897
|
-
fail('recipient-not-registered', `${opts.to}
|
|
1088
|
+
fail('recipient-not-registered', `${opts.to.trim()} never opened a zERC20 wallet: ask for a receive code`);
|
|
898
1089
|
to = { kind: 'address', recipient: recipient };
|
|
899
1090
|
}
|
|
900
|
-
else
|
|
901
|
-
|
|
902
|
-
if (!code)
|
|
903
|
-
return fail('invalid', 'not an address or a receive code');
|
|
904
|
-
if (code.chainId !== token.chainId || code.tokenId !== token.tokenId)
|
|
905
|
-
fail('invalid', 'the receive code is for another token');
|
|
906
|
-
to = { kind: 'code', code: opts.to.trim() };
|
|
907
|
-
}
|
|
1091
|
+
else
|
|
1092
|
+
to = { kind: 'code', code: r.code };
|
|
908
1093
|
return this.#newPayment('send', token, amount, { to, maxFee: opts.maxFee, step: null, paid: 0n });
|
|
909
1094
|
});
|
|
910
1095
|
}
|
|
@@ -973,16 +1158,37 @@ export class Wallet {
|
|
|
973
1158
|
return this.#operation(op);
|
|
974
1159
|
}
|
|
975
1160
|
}
|
|
1161
|
+
/** Whom `to` names, as `send` and `deposit` take it: an account, by its address or its zERC20 ID, or a receive code of
|
|
1162
|
+
* `token`'s. */
|
|
1163
|
+
function recipientOf(to, token) {
|
|
1164
|
+
const address = isAddress(to) ? to : idAccount(to);
|
|
1165
|
+
if (address)
|
|
1166
|
+
return { kind: 'address', address };
|
|
1167
|
+
const code = decodeCode(to);
|
|
1168
|
+
if (!code)
|
|
1169
|
+
return fail('invalid', 'not an address, a zERC20 ID or a receive code');
|
|
1170
|
+
if (code.chainId !== token.chainId || code.tokenId !== token.tokenId)
|
|
1171
|
+
fail('invalid', 'the receive code is for another token');
|
|
1172
|
+
return { kind: 'code', code: to.trim(), burnAddress: bytesToHex(code.burnAddress) };
|
|
1173
|
+
}
|
|
1174
|
+
/** Each chain of `network`, with its servers. */
|
|
1175
|
+
const chainsOf = (network) => network.chains.map((config) => ({
|
|
1176
|
+
config,
|
|
1177
|
+
chain: new Chain(config),
|
|
1178
|
+
indexer: new Indexer(config.indexer),
|
|
1179
|
+
asp: new Asp(config.asp),
|
|
1180
|
+
}));
|
|
976
1181
|
/** Holds `address`'s wallet until the returned function is called: two tabs on one wallet would each overwrite the
|
|
977
|
-
* other's state and spend the same notes.
|
|
978
|
-
|
|
1182
|
+
* other's state and spend the same notes. A wallet that watches it (`view`) is held apart, as its state is its own.
|
|
1183
|
+
* Nothing where there are no Web Locks. */
|
|
1184
|
+
export async function lockWallet(address, kind = 'wallet') {
|
|
979
1185
|
const locks = globalThis.navigator?.locks;
|
|
980
1186
|
if (!locks)
|
|
981
1187
|
return () => { };
|
|
982
1188
|
let release = () => { };
|
|
983
1189
|
const held = new Promise((r) => (release = r));
|
|
984
1190
|
const got = await new Promise((resolve) => {
|
|
985
|
-
void locks.request(`zerc20
|
|
1191
|
+
void locks.request(`zerc20-${kind}:${address.toLowerCase()}`, { ifAvailable: true }, (lock) => {
|
|
986
1192
|
resolve(lock !== null);
|
|
987
1193
|
return lock ? held : undefined;
|
|
988
1194
|
});
|