@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/dist/wallet.js
CHANGED
|
@@ -1,8 +1,9 @@
|
|
|
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, numberToHex, zeroAddress, } from 'viem';
|
|
4
|
+
import { toAccount } from 'viem/accounts';
|
|
4
5
|
import { Chain, MAX_REFERRAL_BPS, NATIVE, erc20Abi, tokenAbi } from './chain.js';
|
|
5
|
-
import { chainCtx, chainState,
|
|
6
|
+
import { chainCtx, chainState, owner, switched, walletOn, walletSees, } from './context.js';
|
|
6
7
|
import { burnAddressFor, core, initCore } from './core.js';
|
|
7
8
|
import { WalletError, fail, guard } from './errors.js';
|
|
8
9
|
import { checkNetwork } from './network.js';
|
|
@@ -10,9 +11,9 @@ import { Asp, Indexer, PAGE } from './servers.js';
|
|
|
10
11
|
import { rageExit } from './rageExit.js';
|
|
11
12
|
import { registerDeposit, relayerFees } from './services.js';
|
|
12
13
|
import { advanceSpend, chosenSettling, coins, grossed, inputsOf, landed, maxPayment, mergePool, mergeRound, payWith, planStep, roundsToMerge, settling, withdrawalOf, } from './spend.js';
|
|
13
|
-
import { emptyWalletState, openState, sealState, } from './state.js';
|
|
14
|
-
import { WAIT_MS, commitmentTx, recordBurn, storageState, syncAll, takeRefresh, tokenIds, topUpBurns, unusedBurn, } from './sync.js';
|
|
15
|
-
|
|
14
|
+
import { emptyWalletState, openState, sealState, stateKey, unspent, } from './state.js';
|
|
15
|
+
import { WAIT_MS, commitmentTx, keyAll, nextBurn, recordBurn, storageState, syncAll, takeRefresh, tokenIds, topUpBurns, unusedBurn, } from './sync.js';
|
|
16
|
+
import { decodeViewKey, encodeViewKey } from './viewKey.js';
|
|
16
17
|
/** The ledger's bound outside an emergency. */
|
|
17
18
|
const NO_BOUND = 2n ** 64n - 1n;
|
|
18
19
|
export class Wallet {
|
|
@@ -24,9 +25,12 @@ export class Wallet {
|
|
|
24
25
|
#busy = 0;
|
|
25
26
|
#release = () => { };
|
|
26
27
|
#referrer;
|
|
28
|
+
#unlock;
|
|
29
|
+
#readOnly = false;
|
|
27
30
|
#closed = new AbortController();
|
|
28
|
-
constructor(ctx) {
|
|
31
|
+
constructor(ctx, unlock) {
|
|
29
32
|
this.#ctx = ctx;
|
|
33
|
+
this.#unlock = unlock;
|
|
30
34
|
}
|
|
31
35
|
/** Lets this wallet open again: in another tab, or here for another account. This object is done with: what is
|
|
32
36
|
* queued fails with `wallet-closed`, a burn address search stops, and the wallet is let go once the task under way
|
|
@@ -38,12 +42,19 @@ export class Wallet {
|
|
|
38
42
|
get address() {
|
|
39
43
|
return this.#ctx.address;
|
|
40
44
|
}
|
|
45
|
+
/** The account's view key, for `watch` to follow it with on another device: whoever holds it sees what this wallet
|
|
46
|
+
* shows, except the private payments to receive codes, and can spend none of it. Checksummed text (bech32m,
|
|
47
|
+
* `zerc20view1…`). */
|
|
48
|
+
viewKey() {
|
|
49
|
+
return encodeViewKey(this.#ctx.view, owner(this.#ctx));
|
|
50
|
+
}
|
|
51
|
+
/** Whether this wallet only watches its account (`watch`): it shows what the account holds and spends nothing. */
|
|
52
|
+
get readOnly() {
|
|
53
|
+
return this.#readOnly;
|
|
54
|
+
}
|
|
41
55
|
/** Opens the wallet. The first time on this address, registers it for payments: one signature. */
|
|
42
56
|
static async open(opts) {
|
|
43
|
-
await initCore();
|
|
44
57
|
checkNetwork(opts.network);
|
|
45
|
-
if (opts.seed.length !== 32)
|
|
46
|
-
fail('invalid', 'the seed is 32 bytes');
|
|
47
58
|
// the factory refuses these, which the relayer would report as its own failure
|
|
48
59
|
const r = opts.referrer;
|
|
49
60
|
if (r) {
|
|
@@ -52,67 +63,156 @@ export class Wallet {
|
|
|
52
63
|
if (!Number.isInteger(r.bps) || r.bps < 0 || r.bps > MAX_REFERRAL_BPS)
|
|
53
64
|
fail('invalid', `a referrer takes 0 to ${MAX_REFERRAL_BPS} bps, not ${r.bps}`);
|
|
54
65
|
}
|
|
55
|
-
|
|
66
|
+
if (!opts.account === !opts.walletClient)
|
|
67
|
+
fail('invalid', 'give an account or a wallet client, one of the two');
|
|
68
|
+
const account = opts.account ?? opts.walletClient.account ?? fail('invalid', 'the wallet client has no account');
|
|
69
|
+
// asked once the options are checked, and before anything is waited on: a browser shows a passkey prompt only in
|
|
70
|
+
// the click that opened
|
|
71
|
+
const seed = typeof opts.seed === 'function' ? await opts.seed() : opts.seed;
|
|
72
|
+
if (seed.length !== 32)
|
|
73
|
+
fail('invalid', 'the seed is 32 bytes');
|
|
74
|
+
await initCore();
|
|
56
75
|
const release = await lockWallet(account.address);
|
|
57
76
|
try {
|
|
58
|
-
return await Wallet.#open(opts, account, release);
|
|
77
|
+
return await Wallet.#open(opts, seed, account, release);
|
|
59
78
|
}
|
|
60
79
|
catch (e) {
|
|
61
80
|
release();
|
|
62
81
|
throw e;
|
|
63
82
|
}
|
|
64
83
|
}
|
|
65
|
-
static async #open(opts, account, release) {
|
|
84
|
+
static async #open(opts, seed, account, release) {
|
|
66
85
|
const address = account.address;
|
|
67
|
-
const chains = opts.network
|
|
68
|
-
config,
|
|
69
|
-
chain: new Chain(config),
|
|
70
|
-
indexer: new Indexer(config.indexer),
|
|
71
|
-
asp: new Asp(config.asp),
|
|
72
|
-
}));
|
|
86
|
+
const chains = chainsOf(opts.network);
|
|
73
87
|
const code = await chains[0]?.chain.client.getCode({ address });
|
|
74
88
|
// an EIP-7702 delegated account is still an EOA that signs
|
|
75
89
|
if (code && code !== '0x' && !code.startsWith('0xef0100'))
|
|
76
90
|
fail('unsupported-account', 'contract accounts cannot sign for a wallet');
|
|
77
|
-
const signer = async (message) => hexToBytes(await guard(
|
|
91
|
+
const signer = async (message) => hexToBytes(await guard(account.type === 'local'
|
|
92
|
+
? account.signMessage({ message })
|
|
93
|
+
: opts.walletClient.signMessage({ account, message })));
|
|
78
94
|
const storage = await CanisterClient.connect(opts.network.storage.host, opts.network.storage.canisterId);
|
|
79
|
-
const publicKey = hexToBytes(core.spendKey(
|
|
95
|
+
const publicKey = hexToBytes(core.spendKey(seed).publicKey);
|
|
80
96
|
const saved = await opts.persist.load();
|
|
81
97
|
// registered already: used before, here or on another device, so its past is looked for
|
|
82
98
|
// ponytail: from genesis (~4 MB per million commitments); ask for a birthday once the chain has many
|
|
83
99
|
const restoring = !saved?.length && opts.birthday === undefined && !!(await storage.registration(hexToBytes(address)));
|
|
84
|
-
const view = await registeredViewKey(storage,
|
|
85
|
-
const
|
|
86
|
-
// never started over in place: the first save would overwrite what it held, operations in flight included
|
|
87
|
-
if (saved?.length && !loaded) {
|
|
88
|
-
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');
|
|
89
|
-
}
|
|
90
|
-
if (loaded && loaded.address.toLowerCase() !== address.toLowerCase())
|
|
91
|
-
fail('invalid', `the saved state is ${loaded.address}'s: keep one per address`);
|
|
92
|
-
const state = loaded ?? emptyWalletState(address);
|
|
93
|
-
const ctx = {
|
|
100
|
+
const view = await registeredViewKey(storage, seed, signer, hexToBytes(address), publicKey);
|
|
101
|
+
const wallet = await Wallet.#begin({
|
|
94
102
|
network: opts.network,
|
|
95
|
-
seed
|
|
103
|
+
seed,
|
|
104
|
+
owners: new Map(),
|
|
96
105
|
address,
|
|
97
|
-
|
|
106
|
+
account,
|
|
107
|
+
walletClient: opts.walletClient ?? null,
|
|
98
108
|
storage,
|
|
99
109
|
view,
|
|
100
110
|
chains,
|
|
101
|
-
|
|
102
|
-
save: () => opts.persist.save(sealState(opts.seed, state)),
|
|
103
|
-
lastFullRefreshMs: 0,
|
|
104
|
-
};
|
|
105
|
-
if (!loaded)
|
|
106
|
-
await start(ctx, restoring ? 0n : opts.birthday);
|
|
107
|
-
const wallet = new Wallet(ctx);
|
|
111
|
+
}, { persist: opts.persist, saved, key: stateKey(seed), from: restoring ? 0n : opts.birthday }, typeof opts.seed === 'function' ? opts.seed : undefined);
|
|
108
112
|
wallet.#release = release;
|
|
109
113
|
wallet.#referrer = opts.referrer;
|
|
114
|
+
return wallet;
|
|
115
|
+
}
|
|
116
|
+
/** Opens an account to follow by its view key (`viewKey()`): its balances, coins and history as its own wallet shows
|
|
117
|
+
* them, less the private payments to its receive codes, whose keys only the seed derives. It spends and writes
|
|
118
|
+
* nothing: a call that would fails with `read-only`. The key must be the account's current one on this network. */
|
|
119
|
+
static async watch(opts) {
|
|
120
|
+
checkNetwork(opts.network);
|
|
121
|
+
await initCore();
|
|
122
|
+
const { view, owner } = decodeViewKey(opts.viewKey) ?? fail('invalid', 'not a view key');
|
|
123
|
+
const address = getAddress(bytesToHex(view.address));
|
|
124
|
+
const release = await lockWallet(address, 'view');
|
|
125
|
+
try {
|
|
126
|
+
const storage = await CanisterClient.connect(opts.network.storage.host, opts.network.storage.canisterId);
|
|
127
|
+
// what the account signed: a nullifying key that is not the account's would show spent coins as unspent
|
|
128
|
+
const r = await lookupRecipient(storage, view.address);
|
|
129
|
+
if (!r || bytesToHex(r.publicKey) !== owner.publicKey || bytesToHex(r.notifyKey) !== bytesToHex(notifyKey(view)))
|
|
130
|
+
fail('invalid', `not ${address}'s view key on ${opts.network.name}, or one it has replaced`);
|
|
131
|
+
const wallet = await Wallet.#begin({
|
|
132
|
+
network: opts.network,
|
|
133
|
+
seed: null,
|
|
134
|
+
owners: new Map([['', owner]]),
|
|
135
|
+
address,
|
|
136
|
+
// signs nothing: every call that would send is refused first
|
|
137
|
+
account: toAccount(address),
|
|
138
|
+
walletClient: null,
|
|
139
|
+
storage,
|
|
140
|
+
view,
|
|
141
|
+
chains: chainsOf(opts.network),
|
|
142
|
+
},
|
|
143
|
+
// registered, so its past is looked for
|
|
144
|
+
{
|
|
145
|
+
persist: opts.persist,
|
|
146
|
+
saved: await opts.persist.load(),
|
|
147
|
+
key: stateKey(view.seed),
|
|
148
|
+
from: opts.birthday ?? 0n,
|
|
149
|
+
});
|
|
150
|
+
wallet.#release = release;
|
|
151
|
+
wallet.#readOnly = true;
|
|
152
|
+
return wallet;
|
|
153
|
+
}
|
|
154
|
+
catch (e) {
|
|
155
|
+
release();
|
|
156
|
+
throw e;
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
/** The wallet over `base`, its state opened from what `persist` saved, with `key`, or started `from` a block or date. */
|
|
160
|
+
static async #begin(base, { persist, saved, key, from, }, unlock) {
|
|
161
|
+
const loaded = saved?.length ? openState(key, saved) : null;
|
|
162
|
+
// never started over in place: the first save would overwrite what it held, operations in flight included
|
|
163
|
+
if (saved?.length && !loaded) {
|
|
164
|
+
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');
|
|
165
|
+
}
|
|
166
|
+
if (loaded && loaded.address.toLowerCase() !== base.address.toLowerCase())
|
|
167
|
+
fail('invalid', `the saved state is ${loaded.address}'s: keep one per address`);
|
|
168
|
+
const state = loaded ?? emptyWalletState(base.address);
|
|
169
|
+
const ctx = { ...base, state, save: () => persist.save(sealState(key, state)), lastFullRefreshMs: 0 };
|
|
170
|
+
if (!loaded)
|
|
171
|
+
await start(ctx, from);
|
|
172
|
+
const wallet = new Wallet(ctx, unlock);
|
|
110
173
|
// a saved state names its tokens by id, and its coins stand by the chains' roots: read both now, so balances, coins
|
|
111
174
|
// and history work before the first sync
|
|
112
|
-
const [tokens] = await Promise.all([Promise.all(chains.map((c) => c.chain.tokens())), wallet.#readRoots()]);
|
|
175
|
+
const [tokens] = await Promise.all([Promise.all(ctx.chains.map((c) => c.chain.tokens())), wallet.#readRoots()]);
|
|
113
176
|
wallet.#tokens = tokens.flat();
|
|
177
|
+
keyAll(ctx, wallet.#tokens);
|
|
114
178
|
return wallet;
|
|
115
179
|
}
|
|
180
|
+
/** Holds `seed`, asked of `#unlock`, once it is checked to be this wallet's. */
|
|
181
|
+
#hold(seed) {
|
|
182
|
+
if (core.spendKey(seed).publicKey !== owner(this.#ctx).publicKey)
|
|
183
|
+
fail('invalid', "the seed given is another wallet's");
|
|
184
|
+
this.#ctx.seed = seed;
|
|
185
|
+
keyAll(this.#ctx, this.#tokens);
|
|
186
|
+
}
|
|
187
|
+
/** Within a task: the seed held, asked of `#unlock` if need be. */
|
|
188
|
+
async #seed() {
|
|
189
|
+
if (!this.#ctx.seed)
|
|
190
|
+
this.#hold(await this.#unlock());
|
|
191
|
+
}
|
|
192
|
+
/** At the end of a sync: lets the seed go when it was opened with a function for it, nothing waits behind the sync,
|
|
193
|
+
* and no payment, withdrawal or consolidation is in flight, as each may sign again (a JoinSplit that reverts is
|
|
194
|
+
* proved again, and every merge round is signed). */
|
|
195
|
+
#dropSeed() {
|
|
196
|
+
if (!this.#unlock || this.#busy > 1)
|
|
197
|
+
return;
|
|
198
|
+
if (this.#ctx.state.ops.some((o) => o.status === 'pending' && o.kind !== 'deposit'))
|
|
199
|
+
return;
|
|
200
|
+
this.#ctx.seed = null;
|
|
201
|
+
}
|
|
202
|
+
/** `#exclusive` for a call that `needs` the seed: held first, asked of `#unlock` if need be before the call waits on
|
|
203
|
+
* anything else (a browser shows a passkey prompt only in the click that asked), and queued in the step that took it,
|
|
204
|
+
* so that a sync ending meanwhile does not let it go. */
|
|
205
|
+
async #seeded(f, needs = true) {
|
|
206
|
+
this.#writable();
|
|
207
|
+
if (needs && !this.#ctx.seed)
|
|
208
|
+
this.#hold(await this.#unlock());
|
|
209
|
+
return this.#exclusive(f);
|
|
210
|
+
}
|
|
211
|
+
/** Refuses a call that spends or writes to storage, in a wallet that watches. */
|
|
212
|
+
#writable() {
|
|
213
|
+
if (this.#readOnly)
|
|
214
|
+
fail('read-only', 'this wallet watches its account by its view key: open it with its seed');
|
|
215
|
+
}
|
|
116
216
|
/** Runs `f` after everything queued before it: one state, changed by one task at a time. */
|
|
117
217
|
#exclusive(f) {
|
|
118
218
|
this.#busy++;
|
|
@@ -145,13 +245,16 @@ export class Wallet {
|
|
|
145
245
|
}
|
|
146
246
|
/** Catches up: new notes, deposits, spends and roots, then advances every operation in flight. A chain or server that
|
|
147
247
|
* fails holds back only what needs it, and the first error is thrown once the rest is done. Settles before the burn
|
|
148
|
-
* addresses are topped up, which can take a phone
|
|
248
|
+
* addresses are topped up, which can take a phone some seconds. */
|
|
149
249
|
sync() {
|
|
150
250
|
return new Promise((resolve, reject) => {
|
|
151
251
|
this.#exclusive(async () => {
|
|
152
252
|
await this.#catchUp().then(resolve, reject);
|
|
153
|
-
// still this task's: a deposit queued meanwhile finds the one recorded ahead, rather than writing at handout
|
|
154
|
-
|
|
253
|
+
// still this task's: a deposit queued meanwhile finds the one recorded ahead, rather than writing at handout;
|
|
254
|
+
// tried again after the next sync. A wallet that watches records none, as it writes nothing.
|
|
255
|
+
if (!this.#readOnly)
|
|
256
|
+
await topUpBurns(this.#ctx, this.#closed.signal).catch(() => { });
|
|
257
|
+
this.#dropSeed();
|
|
155
258
|
}).catch(reject); // closed before it ran
|
|
156
259
|
});
|
|
157
260
|
}
|
|
@@ -193,7 +296,7 @@ export class Wallet {
|
|
|
193
296
|
if (errors.length > 0)
|
|
194
297
|
throw errors[0];
|
|
195
298
|
}
|
|
196
|
-
/** Syncs on its own: every
|
|
299
|
+
/** Syncs on its own: every 3 s while something is under way, every 10 s otherwise. Returns a stop function. */
|
|
197
300
|
autoSync(onChange, onError) {
|
|
198
301
|
let stopped = false;
|
|
199
302
|
let timer;
|
|
@@ -208,7 +311,7 @@ export class Wallet {
|
|
|
208
311
|
if (stopped || this.#closed.signal.aborted)
|
|
209
312
|
return;
|
|
210
313
|
const busy = this.#ctx.state.ops.some((o) => o.status === 'pending' && Date.now() - o.createdMs < WAIT_MS * 4);
|
|
211
|
-
timer = setTimeout(tick, busy ?
|
|
314
|
+
timer = setTimeout(tick, busy ? 3_000 : 10_000);
|
|
212
315
|
};
|
|
213
316
|
void tick();
|
|
214
317
|
return () => {
|
|
@@ -226,15 +329,16 @@ export class Wallet {
|
|
|
226
329
|
}
|
|
227
330
|
balances() {
|
|
228
331
|
const coins = this.coins();
|
|
332
|
+
// storage says which notes to look for, not that they come: a payer writes its notes there before its JoinSplit
|
|
333
|
+
// leaves, and one that never lands leaves them behind. So a note counts once it is a coin (its commitment read);
|
|
334
|
+
// before that, only a note this device's own operation under way is making counts, as incoming. A zero-value
|
|
335
|
+
// change, there to carry its payment's receipt, never counts
|
|
336
|
+
const making = new Set(this.#ctx.state.ops.flatMap((o) => o.status === 'pending' && o.kind !== 'deposit' ? (o.data.changes ?? []) : []));
|
|
229
337
|
return this.#tokens.map((token) => {
|
|
230
338
|
const { chainId, tokenId } = token;
|
|
231
339
|
const b = { token, spendable: 0n, pending: [], blocked: 0n };
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
// until then it is incoming, as a payment's change is between its relay and the next sync
|
|
235
|
-
for (const n of this.#ctx.state.notes.filter((n) => n.chainId === chainId && n.tokenId === tokenId && n.value > 0n)) {
|
|
236
|
-
const stale = n.receipt?.kind === 'merged' && n.atNs !== null && Date.now() - Number(n.atNs / 1000000n) > DAY_MS;
|
|
237
|
-
if (!n.found.some((f) => f.confirmed) && !stale)
|
|
340
|
+
for (const n of this.#ctx.state.notes.filter((n) => n.chainId === chainId && n.tokenId === tokenId && n.value > 0n && making.has(n.salt))) {
|
|
341
|
+
if (!n.found.some((f) => f.confirmed))
|
|
238
342
|
b.pending.push({ amount: n.value, reason: 'incoming' });
|
|
239
343
|
}
|
|
240
344
|
for (const c of coins.filter((c) => c.token === token)) {
|
|
@@ -249,20 +353,21 @@ export class Wallet {
|
|
|
249
353
|
});
|
|
250
354
|
}
|
|
251
355
|
/** Every unspent note and deposit on chain, and where each stands, as of opening or the last sync. A note announced
|
|
252
|
-
* but not on chain yet is not one: `balances()` counts it as incoming. */
|
|
356
|
+
* but not read on chain yet is not one: `balances()` counts it as incoming if this device's operation is making it. */
|
|
253
357
|
coins() {
|
|
254
358
|
const size = (chainId) => this.#status.get(chainId)?.latestRoot?.size ?? 0;
|
|
255
359
|
const unproved = (chainId) => (this.#status.get(chainId)?.halted ? 'roots-halted' : 'awaiting-root');
|
|
256
360
|
const spending = new Set(this.#ctx.state.ops.flatMap((o) => inputsOf(o).map((c) => `${o.chainId}:${c.index}`)));
|
|
257
|
-
// spent by a JoinSplit in a block, which waits only to be final: gone, as activity() counts it
|
|
258
|
-
//
|
|
361
|
+
// spent by a JoinSplit in a block, which waits only to be final: gone, as activity() counts it. Sync sees it by its
|
|
362
|
+
// nullifier (`unspent`); this device's own is gone already from its relay on (a reorg that drops it brings them
|
|
363
|
+
// back as spending, and the operation relays it again)
|
|
259
364
|
const landed = new Set(this.#ctx.state.ops.flatMap((o) => o.kind !== 'deposit' && o.data.step?.stage === 'relayed'
|
|
260
365
|
? inputsOf(o).map((c) => `${o.chainId}:${c.index}`)
|
|
261
366
|
: []));
|
|
262
367
|
const out = [];
|
|
263
368
|
// a zero-value change carries its payment's receipt, and is no coin
|
|
264
369
|
for (const n of this.#ctx.state.notes.filter((n) => n.value > 0n)) {
|
|
265
|
-
for (const f of n.found.filter(
|
|
370
|
+
for (const f of n.found.filter(unspent)) {
|
|
266
371
|
const id = `${n.chainId}:${f.index}`;
|
|
267
372
|
// a 4-byte tag alone matches someone else's now and then: a coin once its commitment is read
|
|
268
373
|
if (!f.confirmed || landed.has(id))
|
|
@@ -281,7 +386,7 @@ export class Wallet {
|
|
|
281
386
|
}
|
|
282
387
|
for (const d of this.#ctx.state.deposits) {
|
|
283
388
|
// a coin once its commitment is read, which names the amount
|
|
284
|
-
for (const f of d.found.filter((f) =>
|
|
389
|
+
for (const f of d.found.filter((f) => unspent(f) && f.amount !== null)) {
|
|
285
390
|
const id = `${d.chainId}:${f.index}`;
|
|
286
391
|
if (landed.has(id))
|
|
287
392
|
continue;
|
|
@@ -505,6 +610,7 @@ export class Wallet {
|
|
|
505
610
|
* addresses. */
|
|
506
611
|
posKey() {
|
|
507
612
|
return this.#exclusive(async () => {
|
|
613
|
+
this.#writable();
|
|
508
614
|
const ctx = this.#ctx;
|
|
509
615
|
const tokens = await tokenIds(ctx);
|
|
510
616
|
let n = 0;
|
|
@@ -518,7 +624,7 @@ export class Wallet {
|
|
|
518
624
|
/** Refunds deposit `id` to its sender without the ASP, the attester or a proved root: for a deposit the ASP
|
|
519
625
|
* rejected, or when those services stop. The account's wallet sends the transactions and pays their gas. */
|
|
520
626
|
rageExit(id) {
|
|
521
|
-
return this.#
|
|
627
|
+
return this.#seeded(async () => {
|
|
522
628
|
const [chain, index] = id.split(':');
|
|
523
629
|
const record = this.#ctx.state.deposits.find((d) => `${d.chainId}` === chain && d.found.some((f) => `${f.index}` === index)) ??
|
|
524
630
|
fail('invalid', `no deposit ${id}`);
|
|
@@ -615,18 +721,22 @@ export class Wallet {
|
|
|
615
721
|
* With `wrapped`, it pays from the account's zERC20 itself, as a withdrawal pays out, rather than its underlying
|
|
616
722
|
* asset: a plain transfer into the burn address, with no wrap and no approval.
|
|
617
723
|
*
|
|
724
|
+
* With `from`, another wallet of the user's (a browser wallet) pays instead of the account: its balance is checked,
|
|
725
|
+
* it approves and wraps, and an exit refunds it there. The account pays nothing and signs nothing.
|
|
726
|
+
*
|
|
618
727
|
* Returns once the wrap is sent: the operation is done once it is mined, which `wait()` or the next sync finds.
|
|
619
728
|
* `onStep` hears what it waits on meanwhile, for an app to show. */
|
|
620
|
-
deposit(opts) {
|
|
729
|
+
async deposit(opts) {
|
|
730
|
+
this.#writable();
|
|
731
|
+
if (opts.amount <= 0n)
|
|
732
|
+
fail('invalid', 'the amount must be positive');
|
|
733
|
+
const payer = opts.from ? (opts.from.account ?? fail('invalid', 'the wallet client has no account')) : null;
|
|
621
734
|
const step = opts.onStep ?? (() => { });
|
|
622
735
|
if (this.#busy > 0)
|
|
623
736
|
step('queued');
|
|
624
737
|
return this.#exclusive(async () => {
|
|
625
738
|
const { token, amount } = opts;
|
|
626
|
-
|
|
627
|
-
fail('invalid', 'the amount must be positive');
|
|
628
|
-
const wc = this.#ctx.walletClient;
|
|
629
|
-
const account = wc.account;
|
|
739
|
+
const account = payer ?? this.#ctx.account;
|
|
630
740
|
const client = chainCtx(this.#ctx, token.chainId).chain.client;
|
|
631
741
|
const { wrapped } = opts;
|
|
632
742
|
const native = !wrapped && token.underlying.toLowerCase() === NATIVE.toLowerCase();
|
|
@@ -641,7 +751,7 @@ export class Wallet {
|
|
|
641
751
|
});
|
|
642
752
|
if (held < amount) {
|
|
643
753
|
const of = wrapped ? token.symbol : `${token.symbol}'s underlying asset`;
|
|
644
|
-
fail('insufficient-funds',
|
|
754
|
+
fail('insufficient-funds', `${payer ? 'the wallet' : 'the account'} holds ${formatUnits(held, token.decimals)} of ${of}, less than ${formatUnits(amount, token.decimals)}`);
|
|
645
755
|
}
|
|
646
756
|
const address = opts.to
|
|
647
757
|
? await this.#burnFor(token, amount, opts.to, () => step('burn-address'))
|
|
@@ -649,8 +759,7 @@ export class Wallet {
|
|
|
649
759
|
const op = this.#newOp('deposit', token, amount, opts.to ? { burnAddress: address, to: opts.to } : { burnAddress: address });
|
|
650
760
|
await this.#ctx.save();
|
|
651
761
|
try {
|
|
652
|
-
|
|
653
|
-
await guard(wc.switchChain({ id: Number(token.chainId) }));
|
|
762
|
+
const wc = opts.from ? await switched(opts.from, token.chainId) : await walletOn(this.#ctx, token.chainId);
|
|
654
763
|
if (!native && !wrapped) {
|
|
655
764
|
const allowance = await client.readContract({
|
|
656
765
|
address: token.underlying,
|
|
@@ -742,6 +851,7 @@ export class Wallet {
|
|
|
742
851
|
if (this.#busy > 0)
|
|
743
852
|
step('queued');
|
|
744
853
|
return this.#exclusive(async () => {
|
|
854
|
+
this.#writable();
|
|
745
855
|
const c = chainCtx(this.#ctx, token.chainId);
|
|
746
856
|
const record = await this.#burnAddress(token, null, () => step('burn-address'));
|
|
747
857
|
const { depositAddress, fee } = await registerDeposit(c.chain, token.address, record.address, this.address, this.#referrer);
|
|
@@ -767,17 +877,22 @@ export class Wallet {
|
|
|
767
877
|
* nothing; it names neither this address nor its public key. Anyone who gets the code can pay it, and see what its
|
|
768
878
|
* other payers paid privately: give each payer its own. It is checksummed text (bech32m, `zerc201…`). */
|
|
769
879
|
receiveCode(token) {
|
|
770
|
-
|
|
771
|
-
|
|
880
|
+
const ctx = this.#ctx;
|
|
881
|
+
const code = (index) => ({ chainId: token.chainId, tokenId: token.tokenId, index });
|
|
882
|
+
// the seed is asked for only past the codes derived ahead
|
|
883
|
+
const derived = !!owner(ctx, code(nextBurn(ctx, token.chainId, token.tokenId)));
|
|
884
|
+
return this.#seeded(async () => {
|
|
772
885
|
const { burnIndex, address } = await this.#burnAddress(token);
|
|
886
|
+
if (!owner(ctx, code(burnIndex)))
|
|
887
|
+
await this.#seed(); // calls queued before took the next ones
|
|
773
888
|
return encodeCode({
|
|
774
889
|
chainId: token.chainId,
|
|
775
890
|
tokenId: token.tokenId,
|
|
776
891
|
burnAddress: hexToBytes(address),
|
|
777
|
-
publicKey: hexToBytes(
|
|
892
|
+
publicKey: hexToBytes(owner(ctx, code(burnIndex)).publicKey),
|
|
778
893
|
key: codeKey(ctx.view.seed, token.chainId, token.tokenId, burnIndex),
|
|
779
894
|
});
|
|
780
|
-
});
|
|
895
|
+
}, !derived);
|
|
781
896
|
}
|
|
782
897
|
/** What a send or withdrawal of `amount` would cost now, or a consolidation of what the wallet holds. A send or
|
|
783
898
|
* withdrawal no single JoinSplit can pay now throws as it would (`needs-consolidation`, `funds-settling`,
|
|
@@ -832,11 +947,12 @@ export class Wallet {
|
|
|
832
947
|
* what is spendable now, or it fails here: `needs-consolidation`, `funds-settling` or `insufficient-funds`.
|
|
833
948
|
* `amount` is what the recipient gets, and `maxFee` caps what it pays on top, `fees({ kind: 'send' }).fee`: the
|
|
834
949
|
* relayer names its fee, so a quote the user saw is the cap. */
|
|
835
|
-
send(opts) {
|
|
836
|
-
|
|
837
|
-
|
|
838
|
-
|
|
839
|
-
|
|
950
|
+
async send(opts) {
|
|
951
|
+
const { token, amount } = opts;
|
|
952
|
+
if (amount <= 0n)
|
|
953
|
+
fail('invalid', 'the amount must be positive');
|
|
954
|
+
// the JoinSplit is proved by a later sync, which holds the seed until the payment is done
|
|
955
|
+
return this.#seeded(async () => {
|
|
840
956
|
let to;
|
|
841
957
|
if (isAddress(opts.to)) {
|
|
842
958
|
const recipient = await lookupRecipient(this.#ctx.storage, hexToBytes(opts.to));
|
|
@@ -866,7 +982,7 @@ export class Wallet {
|
|
|
866
982
|
* touched; one still settling is waited for. `maxFee` caps the fees of every merge together,
|
|
867
983
|
* `fees({ kind: 'consolidate' }).fee`. */
|
|
868
984
|
consolidate(opts) {
|
|
869
|
-
return this.#
|
|
985
|
+
return this.#seeded(async () => {
|
|
870
986
|
if (!this.#ctx.network.mergeProver)
|
|
871
987
|
fail('invalid', 'this network has no merge prover');
|
|
872
988
|
const to = { kind: 'self' };
|
|
@@ -895,12 +1011,12 @@ export class Wallet {
|
|
|
895
1011
|
/** Withdraws `amount` publicly to `to`, as zERC20 or with `unwrap` the underlying asset itself, so `to` needs no gas
|
|
896
1012
|
* to unwrap. One JoinSplit, as `send`, and as there `to` gets all of `amount` and `maxFee` caps what is paid on top
|
|
897
1013
|
* (`fees({ kind: 'withdraw' }).fee`). */
|
|
898
|
-
withdraw(opts) {
|
|
899
|
-
|
|
900
|
-
|
|
901
|
-
|
|
902
|
-
|
|
903
|
-
|
|
1014
|
+
async withdraw(opts) {
|
|
1015
|
+
if (!isAddress(opts.to))
|
|
1016
|
+
fail('invalid', `${String(opts.to)} is not an address`);
|
|
1017
|
+
if (opts.amount <= 0n)
|
|
1018
|
+
fail('invalid', 'the amount must be positive');
|
|
1019
|
+
return this.#seeded(async () => {
|
|
904
1020
|
const to = { kind: 'public', address: opts.to, unwrap: opts.unwrap };
|
|
905
1021
|
return this.#newPayment('withdraw', opts.token, opts.amount, { to, maxFee: opts.maxFee, step: null, paid: 0n });
|
|
906
1022
|
});
|
|
@@ -920,16 +1036,24 @@ export class Wallet {
|
|
|
920
1036
|
return this.#operation(op);
|
|
921
1037
|
}
|
|
922
1038
|
}
|
|
1039
|
+
/** Each chain of `network`, with its servers. */
|
|
1040
|
+
const chainsOf = (network) => network.chains.map((config) => ({
|
|
1041
|
+
config,
|
|
1042
|
+
chain: new Chain(config),
|
|
1043
|
+
indexer: new Indexer(config.indexer),
|
|
1044
|
+
asp: new Asp(config.asp),
|
|
1045
|
+
}));
|
|
923
1046
|
/** Holds `address`'s wallet until the returned function is called: two tabs on one wallet would each overwrite the
|
|
924
|
-
* other's state and spend the same notes.
|
|
925
|
-
|
|
1047
|
+
* other's state and spend the same notes. A wallet that watches it (`view`) is held apart, as its state is its own.
|
|
1048
|
+
* Nothing where there are no Web Locks. */
|
|
1049
|
+
export async function lockWallet(address, kind = 'wallet') {
|
|
926
1050
|
const locks = globalThis.navigator?.locks;
|
|
927
1051
|
if (!locks)
|
|
928
1052
|
return () => { };
|
|
929
1053
|
let release = () => { };
|
|
930
1054
|
const held = new Promise((r) => (release = r));
|
|
931
1055
|
const got = await new Promise((resolve) => {
|
|
932
|
-
void locks.request(`zerc20
|
|
1056
|
+
void locks.request(`zerc20-${kind}:${address.toLowerCase()}`, { ifAvailable: true }, (lock) => {
|
|
933
1057
|
resolve(lock !== null);
|
|
934
1058
|
return lock ? held : undefined;
|
|
935
1059
|
});
|