@zerc20/wallet-sdk 0.0.0-stage → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +24 -0
- package/README.md +26 -2
- package/dist/burnWorker.d.ts +1 -0
- package/dist/burnWorker.js +21 -0
- package/dist/burnWorker.js.map +1 -0
- package/dist/chain.d.ts +359 -0
- package/dist/chain.js +155 -0
- package/dist/chain.js.map +1 -0
- package/dist/context.d.ts +48 -0
- package/dist/context.js +40 -0
- package/dist/context.js.map +1 -0
- package/dist/core.d.ts +43 -0
- package/dist/core.js +106 -0
- package/dist/core.js.map +1 -0
- package/dist/errors.d.ts +38 -0
- package/dist/errors.js +26 -0
- package/dist/errors.js.map +1 -0
- package/dist/index.d.ts +6 -0
- package/dist/index.js +19 -0
- package/dist/index.js.map +1 -0
- package/dist/network.d.ts +66 -0
- package/dist/network.js +55 -0
- package/dist/network.js.map +1 -0
- package/dist/pos.d.ts +93 -0
- package/dist/pos.js +302 -0
- package/dist/pos.js.map +1 -0
- package/dist/prove.d.ts +13 -0
- package/dist/prove.js +53 -0
- package/dist/prove.js.map +1 -0
- package/dist/rageExit.d.ts +5 -0
- package/dist/rageExit.js +146 -0
- package/dist/rageExit.js.map +1 -0
- package/dist/scan.d.ts +17 -0
- package/dist/scan.js +47 -0
- package/dist/scan.js.map +1 -0
- package/dist/seed.d.ts +8 -0
- package/dist/seed.js +35 -0
- package/dist/seed.js.map +1 -0
- package/dist/servers.d.ts +60 -0
- package/dist/servers.js +117 -0
- package/dist/servers.js.map +1 -0
- package/dist/services.d.ts +62 -0
- package/dist/services.js +131 -0
- package/dist/services.js.map +1 -0
- package/dist/spend.d.ts +151 -0
- package/dist/spend.js +745 -0
- package/dist/spend.js.map +1 -0
- package/dist/state.d.ts +174 -0
- package/dist/state.js +45 -0
- package/dist/state.js.map +1 -0
- package/dist/sync.d.ts +84 -0
- package/dist/sync.js +615 -0
- package/dist/sync.js.map +1 -0
- package/dist/tree.d.ts +19 -0
- package/dist/tree.js +63 -0
- package/dist/tree.js.map +1 -0
- package/dist/wallet.d.ts +303 -0
- package/dist/wallet.js +1002 -0
- package/dist/wallet.js.map +1 -0
- package/package.json +60 -4
- package/src/burnWorker.ts +25 -0
- package/src/chain.ts +228 -0
- package/src/context.ts +84 -0
- package/src/core.ts +147 -0
- package/src/errors.ts +63 -0
- package/src/index.ts +41 -0
- package/src/network.ts +94 -0
- package/src/pos.ts +415 -0
- package/src/prove.ts +67 -0
- package/src/rageExit.ts +168 -0
- package/src/scan.ts +62 -0
- package/src/seed.ts +46 -0
- package/src/servers.ts +172 -0
- package/src/services.ts +195 -0
- package/src/snarkjs.d.ts +11 -0
- package/src/spend.ts +912 -0
- package/src/state.ts +211 -0
- package/src/sync.ts +698 -0
- package/src/tree.ts +108 -0
- package/src/wallet.ts +1316 -0
- package/wasm/zerc20_wallet_core.d.ts +176 -0
- package/wasm/zerc20_wallet_core.js +783 -0
- package/wasm/zerc20_wallet_core_bg.wasm +0 -0
- package/wasm/zerc20_wallet_core_bg.wasm.d.ts +32 -0
package/dist/wallet.js
ADDED
|
@@ -0,0 +1,1002 @@
|
|
|
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';
|
|
4
|
+
import { Chain, MAX_REFERRAL_BPS, NATIVE, erc20Abi, tokenAbi } from './chain.js';
|
|
5
|
+
import { chainCtx, chainState, spendKey, walletSees } from './context.js';
|
|
6
|
+
import { burnAddressFor, core, initCore } from './core.js';
|
|
7
|
+
import { WalletError, fail, guard } from './errors.js';
|
|
8
|
+
import { checkNetwork } from './network.js';
|
|
9
|
+
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';
|
|
13
|
+
import { emptyWalletState, openState, sealState, } from './state.js';
|
|
14
|
+
import { WAIT_MS, commitmentTx, recordBurn, storageState, syncAll, takeRefresh, tokenIds, topUpBurns, unusedBurn, } from './sync.js';
|
|
15
|
+
const DAY_MS = 86_400_000;
|
|
16
|
+
/** The ledger's bound outside an emergency. */
|
|
17
|
+
const NO_BOUND = 2n ** 64n - 1n;
|
|
18
|
+
export class Wallet {
|
|
19
|
+
#ctx;
|
|
20
|
+
#tokens = [];
|
|
21
|
+
#status = new Map();
|
|
22
|
+
#queue = Promise.resolve();
|
|
23
|
+
/** Calls running or queued. */
|
|
24
|
+
#busy = 0;
|
|
25
|
+
#release = () => { };
|
|
26
|
+
#referrer;
|
|
27
|
+
#closed = new AbortController();
|
|
28
|
+
constructor(ctx) {
|
|
29
|
+
this.#ctx = ctx;
|
|
30
|
+
}
|
|
31
|
+
/** Lets this wallet open again: in another tab, or here for another account. This object is done with: what is
|
|
32
|
+
* queued fails with `wallet-closed`, a burn address search stops, and the wallet is let go once the task under way
|
|
33
|
+
* ends, so that two never write its state at once. */
|
|
34
|
+
close() {
|
|
35
|
+
this.#closed.abort(new WalletError('wallet-closed', 'this wallet was closed: open it again'));
|
|
36
|
+
return this.#queue.then(() => this.#release());
|
|
37
|
+
}
|
|
38
|
+
get address() {
|
|
39
|
+
return this.#ctx.address;
|
|
40
|
+
}
|
|
41
|
+
/** Opens the wallet. The first time on this address, registers it for payments: one signature. */
|
|
42
|
+
static async open(opts) {
|
|
43
|
+
await initCore();
|
|
44
|
+
checkNetwork(opts.network);
|
|
45
|
+
if (opts.seed.length !== 32)
|
|
46
|
+
fail('invalid', 'the seed is 32 bytes');
|
|
47
|
+
// the factory refuses these, which the relayer would report as its own failure
|
|
48
|
+
const r = opts.referrer;
|
|
49
|
+
if (r) {
|
|
50
|
+
if (!isAddress(r.address, { strict: false }) || r.address.toLowerCase() === zeroAddress)
|
|
51
|
+
fail('invalid', `the referrer ${r.address} is not an address to pay`);
|
|
52
|
+
if (!Number.isInteger(r.bps) || r.bps < 0 || r.bps > MAX_REFERRAL_BPS)
|
|
53
|
+
fail('invalid', `a referrer takes 0 to ${MAX_REFERRAL_BPS} bps, not ${r.bps}`);
|
|
54
|
+
}
|
|
55
|
+
const account = opts.walletClient.account ?? fail('invalid', 'the wallet client has no account');
|
|
56
|
+
const release = await lockWallet(account.address);
|
|
57
|
+
try {
|
|
58
|
+
return await Wallet.#open(opts, account, release);
|
|
59
|
+
}
|
|
60
|
+
catch (e) {
|
|
61
|
+
release();
|
|
62
|
+
throw e;
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
static async #open(opts, account, release) {
|
|
66
|
+
const address = account.address;
|
|
67
|
+
const chains = opts.network.chains.map((config) => ({
|
|
68
|
+
config,
|
|
69
|
+
chain: new Chain(config),
|
|
70
|
+
indexer: new Indexer(config.indexer),
|
|
71
|
+
asp: new Asp(config.asp),
|
|
72
|
+
}));
|
|
73
|
+
const code = await chains[0]?.chain.client.getCode({ address });
|
|
74
|
+
// an EIP-7702 delegated account is still an EOA that signs
|
|
75
|
+
if (code && code !== '0x' && !code.startsWith('0xef0100'))
|
|
76
|
+
fail('unsupported-account', 'contract accounts cannot sign for a wallet');
|
|
77
|
+
const signer = async (message) => hexToBytes(await guard(opts.walletClient.signMessage({ account, message })));
|
|
78
|
+
const storage = await CanisterClient.connect(opts.network.storage.host, opts.network.storage.canisterId);
|
|
79
|
+
const publicKey = hexToBytes(core.spendKey(opts.seed).publicKey);
|
|
80
|
+
const saved = await opts.persist.load();
|
|
81
|
+
// registered already: used before, here or on another device, so its past is looked for
|
|
82
|
+
// ponytail: from genesis (~4 MB per million commitments); ask for a birthday once the chain has many
|
|
83
|
+
const restoring = !saved?.length && opts.birthday === undefined && !!(await storage.registration(hexToBytes(address)));
|
|
84
|
+
const view = await registeredViewKey(storage, opts.seed, signer, hexToBytes(address), publicKey);
|
|
85
|
+
const loaded = saved?.length ? openState(opts.seed, saved) : null;
|
|
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 = {
|
|
94
|
+
network: opts.network,
|
|
95
|
+
seed: opts.seed,
|
|
96
|
+
address,
|
|
97
|
+
walletClient: opts.walletClient,
|
|
98
|
+
storage,
|
|
99
|
+
view,
|
|
100
|
+
chains,
|
|
101
|
+
state,
|
|
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);
|
|
108
|
+
wallet.#release = release;
|
|
109
|
+
wallet.#referrer = opts.referrer;
|
|
110
|
+
// a saved state names its tokens by id, and its coins stand by the chains' roots: read both now, so balances, coins
|
|
111
|
+
// and history work before the first sync
|
|
112
|
+
const [tokens] = await Promise.all([Promise.all(chains.map((c) => c.chain.tokens())), wallet.#readRoots()]);
|
|
113
|
+
wallet.#tokens = tokens.flat();
|
|
114
|
+
return wallet;
|
|
115
|
+
}
|
|
116
|
+
/** Runs `f` after everything queued before it: one state, changed by one task at a time. */
|
|
117
|
+
#exclusive(f) {
|
|
118
|
+
this.#busy++;
|
|
119
|
+
const run = this.#queue
|
|
120
|
+
.then(() => {
|
|
121
|
+
this.#closed.signal.throwIfAborted();
|
|
122
|
+
return f();
|
|
123
|
+
})
|
|
124
|
+
.finally(() => this.#busy--);
|
|
125
|
+
this.#queue = run.catch(() => undefined);
|
|
126
|
+
return run;
|
|
127
|
+
}
|
|
128
|
+
/** Each chain's latest proved root, which tells what is spendable, and whether the protocol is in an emergency. A
|
|
129
|
+
* chain that does not answer keeps what was read before, and its error is returned. */
|
|
130
|
+
async #readRoots() {
|
|
131
|
+
const errors = [];
|
|
132
|
+
await Promise.all(this.#ctx.chains.map(async (c) => {
|
|
133
|
+
try {
|
|
134
|
+
const [status, bound] = await Promise.all([c.indexer.status(), c.chain.bound()]);
|
|
135
|
+
// in an emergency the ledger accepts no root above its bound
|
|
136
|
+
const latestRoot = status.latestRoot && BigInt(status.latestRoot.size) <= bound ? status.latestRoot : null;
|
|
137
|
+
const halted = status.mismatch !== null || bound !== NO_BOUND;
|
|
138
|
+
this.#status.set(c.config.chainId, { ...status, latestRoot, halted });
|
|
139
|
+
}
|
|
140
|
+
catch (e) {
|
|
141
|
+
errors.push(e);
|
|
142
|
+
}
|
|
143
|
+
}));
|
|
144
|
+
return errors;
|
|
145
|
+
}
|
|
146
|
+
/** Catches up: new notes, deposits, spends and roots, then advances every operation in flight. A chain or server that
|
|
147
|
+
* 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 tens of seconds. */
|
|
149
|
+
sync() {
|
|
150
|
+
return new Promise((resolve, reject) => {
|
|
151
|
+
this.#exclusive(async () => {
|
|
152
|
+
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
|
+
await topUpBurns(this.#ctx, this.#closed.signal).catch(() => { }); // tried again after the next sync
|
|
155
|
+
}).catch(reject); // closed before it ran
|
|
156
|
+
});
|
|
157
|
+
}
|
|
158
|
+
async #catchUp() {
|
|
159
|
+
const errors = await syncAll(this.#ctx);
|
|
160
|
+
errors.push(...(await this.#readRoots()));
|
|
161
|
+
const listed = await Promise.all(this.#ctx.chains.map((c) => c.chain
|
|
162
|
+
.tokens()
|
|
163
|
+
.catch((e) => (errors.push(e), this.#tokens.filter((t) => t.chainId === c.config.chainId)))));
|
|
164
|
+
this.#tokens = listed.flat();
|
|
165
|
+
for (const op of this.#ctx.state.ops.filter((o) => o.status === 'pending')) {
|
|
166
|
+
try {
|
|
167
|
+
if (op.kind === 'send' || op.kind === 'withdraw' || op.kind === 'consolidate')
|
|
168
|
+
await advanceSpend(this.#ctx, op);
|
|
169
|
+
if (op.kind === 'deposit')
|
|
170
|
+
await settleDeposit(this.#ctx, op);
|
|
171
|
+
delete op.retrying;
|
|
172
|
+
}
|
|
173
|
+
catch (e) {
|
|
174
|
+
// only what trying again cannot change fails an operation: a server or an RPC that does not answer (any
|
|
175
|
+
// error that is not the wallet's own), or answers what the chain does not show yet, is tried next sync. It is
|
|
176
|
+
// kept on the operation, so that one that never gets past it does not look merely slow.
|
|
177
|
+
if (!(e instanceof WalletError) || e.code === 'unavailable' || e.code === 'indexer-mismatch') {
|
|
178
|
+
const message = e instanceof WalletError ? e.message : e instanceof Error ? `${e.name}: ${e.message}` : String(e);
|
|
179
|
+
op.retrying = {
|
|
180
|
+
message,
|
|
181
|
+
code: e instanceof WalletError ? e.code : undefined,
|
|
182
|
+
sinceMs: op.retrying?.sinceMs ?? Date.now(),
|
|
183
|
+
};
|
|
184
|
+
continue;
|
|
185
|
+
}
|
|
186
|
+
delete op.retrying;
|
|
187
|
+
op.status = 'failed';
|
|
188
|
+
op.error = e.message;
|
|
189
|
+
op.errorCode = e.code;
|
|
190
|
+
await this.#ctx.save();
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
if (errors.length > 0)
|
|
194
|
+
throw errors[0];
|
|
195
|
+
}
|
|
196
|
+
/** Syncs on its own: every 15 s while something is under way, every 5 min otherwise. Returns a stop function. */
|
|
197
|
+
autoSync(onChange, onError) {
|
|
198
|
+
let stopped = false;
|
|
199
|
+
let timer;
|
|
200
|
+
const tick = async () => {
|
|
201
|
+
try {
|
|
202
|
+
await this.sync();
|
|
203
|
+
onChange?.();
|
|
204
|
+
}
|
|
205
|
+
catch (e) {
|
|
206
|
+
onError?.(e);
|
|
207
|
+
}
|
|
208
|
+
if (stopped || this.#closed.signal.aborted)
|
|
209
|
+
return;
|
|
210
|
+
const busy = this.#ctx.state.ops.some((o) => o.status === 'pending' && Date.now() - o.createdMs < WAIT_MS * 4);
|
|
211
|
+
timer = setTimeout(tick, busy ? 15_000 : 300_000);
|
|
212
|
+
};
|
|
213
|
+
void tick();
|
|
214
|
+
return () => {
|
|
215
|
+
stopped = true;
|
|
216
|
+
clearTimeout(timer);
|
|
217
|
+
};
|
|
218
|
+
}
|
|
219
|
+
/** The tokens of every chain, as of opening or the last sync. */
|
|
220
|
+
tokens() {
|
|
221
|
+
return this.#tokens;
|
|
222
|
+
}
|
|
223
|
+
#token(chainId, tokenId) {
|
|
224
|
+
return (this.#tokens.find((t) => t.chainId === chainId && t.tokenId === tokenId) ??
|
|
225
|
+
fail('invalid', `unknown token ${tokenId} on ${chainId}`));
|
|
226
|
+
}
|
|
227
|
+
balances() {
|
|
228
|
+
const coins = this.coins();
|
|
229
|
+
return this.#tokens.map((token) => {
|
|
230
|
+
const { chainId, tokenId } = token;
|
|
231
|
+
const b = { token, spendable: 0n, pending: [], blocked: 0n };
|
|
232
|
+
// a merge that never landed (another device spent an input first) stops counting after a day; a zero-value
|
|
233
|
+
// change, there to carry its payment's receipt, never counts. A note is a coin once its commitment is read, so
|
|
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)
|
|
238
|
+
b.pending.push({ amount: n.value, reason: 'incoming' });
|
|
239
|
+
}
|
|
240
|
+
for (const c of coins.filter((c) => c.token === token)) {
|
|
241
|
+
if (c.state === 'spendable')
|
|
242
|
+
b.spendable += c.amount;
|
|
243
|
+
else if (c.state === 'blocked')
|
|
244
|
+
b.blocked += c.amount;
|
|
245
|
+
else if (c.state !== 'spending')
|
|
246
|
+
b.pending.push({ amount: c.amount, reason: c.state });
|
|
247
|
+
}
|
|
248
|
+
return b;
|
|
249
|
+
});
|
|
250
|
+
}
|
|
251
|
+
/** 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. */
|
|
253
|
+
coins() {
|
|
254
|
+
const size = (chainId) => this.#status.get(chainId)?.latestRoot?.size ?? 0;
|
|
255
|
+
const unproved = (chainId) => (this.#status.get(chainId)?.halted ? 'roots-halted' : 'awaiting-root');
|
|
256
|
+
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 (a reorg that drops
|
|
258
|
+
// it brings them back as spending, and the operation relays it again)
|
|
259
|
+
const landed = new Set(this.#ctx.state.ops.flatMap((o) => o.kind !== 'deposit' && o.data.step?.stage === 'relayed'
|
|
260
|
+
? inputsOf(o).map((c) => `${o.chainId}:${c.index}`)
|
|
261
|
+
: []));
|
|
262
|
+
const out = [];
|
|
263
|
+
// a zero-value change carries its payment's receipt, and is no coin
|
|
264
|
+
for (const n of this.#ctx.state.notes.filter((n) => n.value > 0n)) {
|
|
265
|
+
for (const f of n.found.filter((f) => !f.spent)) {
|
|
266
|
+
const id = `${n.chainId}:${f.index}`;
|
|
267
|
+
// a 4-byte tag alone matches someone else's now and then: a coin once its commitment is read
|
|
268
|
+
if (!f.confirmed || landed.has(id))
|
|
269
|
+
continue;
|
|
270
|
+
const state = spending.has(id) ? 'spending' : f.index >= size(n.chainId) ? unproved(n.chainId) : 'spendable';
|
|
271
|
+
out.push({
|
|
272
|
+
id,
|
|
273
|
+
kind: 'note',
|
|
274
|
+
token: this.#token(n.chainId, n.tokenId),
|
|
275
|
+
amount: n.value,
|
|
276
|
+
state,
|
|
277
|
+
from: n.peer,
|
|
278
|
+
screenedBy: null,
|
|
279
|
+
});
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
for (const d of this.#ctx.state.deposits) {
|
|
283
|
+
// a coin once its commitment is read, which names the amount
|
|
284
|
+
for (const f of d.found.filter((f) => !f.spent && f.amount !== null)) {
|
|
285
|
+
const id = `${d.chainId}:${f.index}`;
|
|
286
|
+
if (landed.has(id))
|
|
287
|
+
continue;
|
|
288
|
+
const state = f.asp === 'rejected'
|
|
289
|
+
? 'blocked'
|
|
290
|
+
: spending.has(id)
|
|
291
|
+
? 'spending'
|
|
292
|
+
: f.asp === null
|
|
293
|
+
? 'screening'
|
|
294
|
+
: f.index >= size(d.chainId)
|
|
295
|
+
? unproved(d.chainId)
|
|
296
|
+
: 'spendable';
|
|
297
|
+
const screenedBy = state === 'screening' && f.decidedByMs !== undefined ? new Date(f.decidedByMs) : null;
|
|
298
|
+
out.push({
|
|
299
|
+
id,
|
|
300
|
+
kind: 'deposit',
|
|
301
|
+
token: this.#token(d.chainId, f.tokenId),
|
|
302
|
+
amount: f.amount,
|
|
303
|
+
state,
|
|
304
|
+
from: f.sender,
|
|
305
|
+
screenedBy,
|
|
306
|
+
});
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
return out;
|
|
310
|
+
}
|
|
311
|
+
/** Everything this wallet received, sent, deposited, sold, withdrew, merged and returned, newest first: the same on every
|
|
312
|
+
* device of the wallet, read from storage and the chain. This device's operations add only what no other device can
|
|
313
|
+
* know: a failed attempt, and a payment in a block whose change is not found yet. */
|
|
314
|
+
activity() {
|
|
315
|
+
const s = this.#ctx.state;
|
|
316
|
+
const out = [];
|
|
317
|
+
const ms = (t) => (t === null || t === undefined ? null : new Date(Number(t)));
|
|
318
|
+
/** One on chain at `coin`, dated by its block, or else `fallbackMs`. */
|
|
319
|
+
const at = (kind, chainId, f, fallbackMs) => ({
|
|
320
|
+
id: `${kind}:${chainId}:${f.index}`,
|
|
321
|
+
kind,
|
|
322
|
+
coin: `${chainId}:${f.index}`,
|
|
323
|
+
at: ms(f.atMs ?? fallbackMs),
|
|
324
|
+
burnAddress: null,
|
|
325
|
+
status: 'done',
|
|
326
|
+
});
|
|
327
|
+
// payments whose change shows them, by the change's salt
|
|
328
|
+
const shown = new Set();
|
|
329
|
+
for (const n of s.notes) {
|
|
330
|
+
// on chain, and its commitment read: a 4-byte tag alone matches someone else's now and then
|
|
331
|
+
const f = n.found.find((f) => f.confirmed);
|
|
332
|
+
if (!f)
|
|
333
|
+
continue;
|
|
334
|
+
const token = this.#token(n.chainId, n.tokenId);
|
|
335
|
+
const stored = n.atNs === null ? null : Number(n.atNs / 1000000n);
|
|
336
|
+
const r = n.receipt;
|
|
337
|
+
if (n.source === 'received') {
|
|
338
|
+
out.push({
|
|
339
|
+
...at('received', n.chainId, f, stored),
|
|
340
|
+
token,
|
|
341
|
+
amount: n.value,
|
|
342
|
+
fee: null,
|
|
343
|
+
counterparty: n.peer,
|
|
344
|
+
via: null,
|
|
345
|
+
txHash: null,
|
|
346
|
+
});
|
|
347
|
+
}
|
|
348
|
+
else if (r?.kind === 'merged') {
|
|
349
|
+
out.push({
|
|
350
|
+
...at('merged', n.chainId, f, stored),
|
|
351
|
+
token,
|
|
352
|
+
amount: n.value,
|
|
353
|
+
fee: r.fee,
|
|
354
|
+
counterparty: null,
|
|
355
|
+
via: null,
|
|
356
|
+
txHash: null,
|
|
357
|
+
});
|
|
358
|
+
}
|
|
359
|
+
else if (r) {
|
|
360
|
+
shown.add(n.salt);
|
|
361
|
+
const kind = r.kind === 'withdrawn' || r.kind === 'unwrapped' ? 'withdrawn' : 'sent';
|
|
362
|
+
const code = r.kind === 'sent-to-code' ? this.#paidCode(n.chainId, r.counterparty) : null;
|
|
363
|
+
const counterparty = r.kind === 'sent-to-code' ? null : r.counterparty;
|
|
364
|
+
out.push({
|
|
365
|
+
...at(kind, n.chainId, f, stored),
|
|
366
|
+
token,
|
|
367
|
+
amount: r.amount,
|
|
368
|
+
fee: r.fee,
|
|
369
|
+
counterparty,
|
|
370
|
+
via: code ? { code } : null,
|
|
371
|
+
txHash: null,
|
|
372
|
+
});
|
|
373
|
+
}
|
|
374
|
+
}
|
|
375
|
+
for (const d of s.deposits) {
|
|
376
|
+
// registered on this device, or on another, which says so in storage
|
|
377
|
+
const registered = d.depositAddress ??
|
|
378
|
+
s.receipts.flatMap((r) => r.kind === 'deposit-address' && r.chainId === d.chainId && r.burnAddress === d.address.toLowerCase()
|
|
379
|
+
? [r.depositAddress]
|
|
380
|
+
: [])[0];
|
|
381
|
+
// on chain once found; shown once its commitment is read, which names the amount
|
|
382
|
+
for (const f of d.found.filter((f) => f.amount !== null)) {
|
|
383
|
+
const forwarded = registered && f.sender?.toLowerCase() === registered.toLowerCase();
|
|
384
|
+
// into a burn address a payer chose: theirs to this wallet, as a payment is
|
|
385
|
+
const kind = d.managed ? 'sale' : d.payer ? 'received' : 'deposited';
|
|
386
|
+
const via = forwarded ? { depositAddress: registered } : null;
|
|
387
|
+
const entry = {
|
|
388
|
+
...at(kind, d.chainId, f, d.createdMs),
|
|
389
|
+
token: this.#token(d.chainId, f.tokenId),
|
|
390
|
+
amount: f.amount,
|
|
391
|
+
burnAddress: d.address,
|
|
392
|
+
};
|
|
393
|
+
out.push({ ...entry, fee: null, counterparty: f.sender, via, txHash: null });
|
|
394
|
+
}
|
|
395
|
+
}
|
|
396
|
+
// a deposit that paid someone (`deposit({ to })`), once its transfer is found
|
|
397
|
+
for (const x of s.sent) {
|
|
398
|
+
for (const f of x.found.filter((f) => f.amount !== null)) {
|
|
399
|
+
out.push({
|
|
400
|
+
...at('sent', x.chainId, f, x.createdMs),
|
|
401
|
+
token: this.#token(x.chainId, f.tokenId),
|
|
402
|
+
amount: f.amount,
|
|
403
|
+
fee: null,
|
|
404
|
+
counterparty: x.to,
|
|
405
|
+
via: null,
|
|
406
|
+
txHash: null,
|
|
407
|
+
});
|
|
408
|
+
}
|
|
409
|
+
}
|
|
410
|
+
for (const r of s.receipts) {
|
|
411
|
+
if (r.kind !== 'returned')
|
|
412
|
+
continue;
|
|
413
|
+
const entry = {
|
|
414
|
+
...at('returned', r.chainId, { index: r.index }, r.atMs),
|
|
415
|
+
token: this.#token(r.chainId, r.tokenId),
|
|
416
|
+
amount: r.amount,
|
|
417
|
+
};
|
|
418
|
+
out.push({ ...entry, fee: null, counterparty: r.recipient, via: null, txHash: r.txHash });
|
|
419
|
+
}
|
|
420
|
+
for (const op of s.ops) {
|
|
421
|
+
const failed = op.status === 'failed';
|
|
422
|
+
const base = {
|
|
423
|
+
id: op.id,
|
|
424
|
+
token: this.#token(op.chainId, op.tokenId),
|
|
425
|
+
amount: op.amount,
|
|
426
|
+
at: new Date(op.createdMs),
|
|
427
|
+
coin: null,
|
|
428
|
+
burnAddress: null,
|
|
429
|
+
status: failed ? 'failed' : 'done',
|
|
430
|
+
};
|
|
431
|
+
if (op.kind === 'deposit') {
|
|
432
|
+
// until its transfer is found, as above
|
|
433
|
+
const found = s.sent.some((x) => x.chainId === op.chainId &&
|
|
434
|
+
x.address === op.data.burnAddress.toLowerCase() &&
|
|
435
|
+
x.found.some((f) => f.amount !== null));
|
|
436
|
+
if (!op.data.to || !op.data.txHash || op.status === 'pending' || found)
|
|
437
|
+
continue;
|
|
438
|
+
out.push({ ...base, kind: 'sent', fee: null, counterparty: op.data.to, via: null, txHash: op.data.txHash });
|
|
439
|
+
continue;
|
|
440
|
+
}
|
|
441
|
+
// under way (in operations()), or shown by its change
|
|
442
|
+
if (op.kind === 'consolidate' || (!failed && !landed(op)) || shown.has(op.data.changes?.[0] ?? '0x'))
|
|
443
|
+
continue;
|
|
444
|
+
const to = op.data.to;
|
|
445
|
+
out.push({
|
|
446
|
+
...base,
|
|
447
|
+
kind: op.kind === 'send' ? 'sent' : 'withdrawn',
|
|
448
|
+
fee: op.data.paid,
|
|
449
|
+
counterparty: to.kind === 'public' ? to.address : to.kind === 'address' ? bytesToHex(to.recipient.address) : null,
|
|
450
|
+
via: to.kind === 'code' ? { code: to.code } : null,
|
|
451
|
+
txHash: op.data.txHashes?.at(-1) ?? null,
|
|
452
|
+
});
|
|
453
|
+
}
|
|
454
|
+
return out.sort((a, b) => (b.at?.getTime() ?? 0) - (a.at?.getTime() ?? 0));
|
|
455
|
+
}
|
|
456
|
+
/** The text of a receive code this wallet paid, by its burn address, from storage's record of paying it. */
|
|
457
|
+
#paidCode(chainId, burnAddress) {
|
|
458
|
+
for (const c of this.#ctx.state.storage?.outgoing.lanes ?? []) {
|
|
459
|
+
if ('code' in c.via && c.via.code.chainId === chainId && bytesToHex(c.via.code.burnAddress) === burnAddress)
|
|
460
|
+
return encodeCode(c.via.code);
|
|
461
|
+
}
|
|
462
|
+
return null;
|
|
463
|
+
}
|
|
464
|
+
/** `a`'s transaction and its block's time, read now: for an app to show on asking, as a sync does not read them. A
|
|
465
|
+
* coin's is the ledger's log of it, in the block the indexer names. Null while the chain does not show it. */
|
|
466
|
+
async transaction(a) {
|
|
467
|
+
const c = this.#ctx.chains.find((c) => c.config.chainId === a.token.chainId) ??
|
|
468
|
+
fail('invalid', `unknown chain ${a.token.chainId}`);
|
|
469
|
+
let hash = a.txHash;
|
|
470
|
+
let block = null;
|
|
471
|
+
if (!hash && a.coin) {
|
|
472
|
+
const tx = await commitmentTx(c.chain, c.indexer, Number(a.coin.split(':')[1]));
|
|
473
|
+
if (!tx)
|
|
474
|
+
return null;
|
|
475
|
+
({ hash, block } = tx);
|
|
476
|
+
}
|
|
477
|
+
if (!hash)
|
|
478
|
+
return null;
|
|
479
|
+
block ??= (await c.chain.client.getTransactionReceipt({ hash }).catch(() => null))?.blockNumber ?? null;
|
|
480
|
+
if (block === null)
|
|
481
|
+
return { hash, at: null }; // not mined, or not yet seen by this RPC
|
|
482
|
+
const { timestamp } = await c.chain.client.getBlock({ blockNumber: block });
|
|
483
|
+
return { hash, at: new Date(Number(timestamp) * 1000) };
|
|
484
|
+
}
|
|
485
|
+
/** Every deposit found, with the id `rageExit` takes. */
|
|
486
|
+
deposits() {
|
|
487
|
+
return this.#ctx.state.deposits.flatMap((d) => d.found.map((f) => ({
|
|
488
|
+
id: `${d.chainId}:${f.index}`,
|
|
489
|
+
address: d.address,
|
|
490
|
+
managed: d.managed === true,
|
|
491
|
+
token: this.#token(d.chainId, f.tokenId),
|
|
492
|
+
amount: f.amount,
|
|
493
|
+
sender: f.sender,
|
|
494
|
+
state: f.spent ? 'spent' : f.asp === null ? 'screening' : f.asp,
|
|
495
|
+
})));
|
|
496
|
+
}
|
|
497
|
+
/** The payments to the burn addresses this wallet's points of sale handed out. Spent ones are, normally, in a
|
|
498
|
+
* merged note. */
|
|
499
|
+
payments() {
|
|
500
|
+
return this.deposits().filter((d) => d.managed);
|
|
501
|
+
}
|
|
502
|
+
/** The key of a new point of sale (`PointOfSale.connect`): the lane where it records the payment addresses it makes
|
|
503
|
+
* for this wallet, which this wallet, every other device of it and a restore read, and `payments()` shows what reaches
|
|
504
|
+
* them. The point of sale can spend none of it. One key for one point of sale: two would hand out the same
|
|
505
|
+
* addresses. */
|
|
506
|
+
posKey() {
|
|
507
|
+
return this.#exclusive(async () => {
|
|
508
|
+
const ctx = this.#ctx;
|
|
509
|
+
const tokens = await tokenIds(ctx);
|
|
510
|
+
let n = 0;
|
|
511
|
+
// the number is the last plan's: after `Occupied`, another device took the first one
|
|
512
|
+
const planned = (s) => planPosLane(ctx.view, s, ctx.chains[0].config.chainId, (n = nextPosLane(s)));
|
|
513
|
+
takeRefresh(ctx, await commit(ctx.storage, ctx.view, storageState(ctx), planned), tokens);
|
|
514
|
+
await ctx.save();
|
|
515
|
+
return bytesToHex(posKey(ctx.view.seed, n));
|
|
516
|
+
});
|
|
517
|
+
}
|
|
518
|
+
/** Refunds deposit `id` to its sender without the ASP, the attester or a proved root: for a deposit the ASP
|
|
519
|
+
* rejected, or when those services stop. The account's wallet sends the transactions and pays their gas. */
|
|
520
|
+
rageExit(id) {
|
|
521
|
+
return this.#exclusive(async () => {
|
|
522
|
+
const [chain, index] = id.split(':');
|
|
523
|
+
const record = this.#ctx.state.deposits.find((d) => `${d.chainId}` === chain && d.found.some((f) => `${f.index}` === index)) ??
|
|
524
|
+
fail('invalid', `no deposit ${id}`);
|
|
525
|
+
// an operation in flight may still land with it, and would then be taken for paid when this refund spends it
|
|
526
|
+
const op = this.#ctx.state.ops.find((o) => `${o.chainId}` === chain && inputsOf(o).some((c) => `${c.index}` === index));
|
|
527
|
+
if (op)
|
|
528
|
+
fail('invalid', `deposit ${id} is an input of operation ${op.id}, which is in flight`);
|
|
529
|
+
return rageExit(this.#ctx, record, Number(index));
|
|
530
|
+
});
|
|
531
|
+
}
|
|
532
|
+
operations() {
|
|
533
|
+
return this.#ctx.state.ops.map((op) => this.#operation(op));
|
|
534
|
+
}
|
|
535
|
+
#operation(op) {
|
|
536
|
+
return {
|
|
537
|
+
id: op.id,
|
|
538
|
+
kind: op.kind,
|
|
539
|
+
chainId: op.chainId,
|
|
540
|
+
get status() {
|
|
541
|
+
return op.status;
|
|
542
|
+
},
|
|
543
|
+
get error() {
|
|
544
|
+
return op.error;
|
|
545
|
+
},
|
|
546
|
+
get errorCode() {
|
|
547
|
+
return op.errorCode;
|
|
548
|
+
},
|
|
549
|
+
get retrying() {
|
|
550
|
+
const r = op.retrying;
|
|
551
|
+
return r ? { message: r.message, code: r.code, since: new Date(r.sinceMs) } : null;
|
|
552
|
+
},
|
|
553
|
+
get txHashes() {
|
|
554
|
+
return op.kind === 'deposit' ? (op.data.txHash ? [op.data.txHash] : []) : (op.data.txHashes ?? []);
|
|
555
|
+
},
|
|
556
|
+
get stage() {
|
|
557
|
+
return op.kind === 'deposit' ? null : (op.data.step?.stage ?? null);
|
|
558
|
+
},
|
|
559
|
+
to: op.kind === 'deposit' ? (op.data.to ?? null) : null,
|
|
560
|
+
wait: async () => {
|
|
561
|
+
for (;;) {
|
|
562
|
+
if (op.status === 'done')
|
|
563
|
+
return;
|
|
564
|
+
if (op.status === 'failed')
|
|
565
|
+
throw new WalletError(op.errorCode ?? 'invalid', op.error ?? 'failed');
|
|
566
|
+
await this.sync();
|
|
567
|
+
if (op.status === 'pending')
|
|
568
|
+
await new Promise((r) => setTimeout(r, 15_000));
|
|
569
|
+
}
|
|
570
|
+
},
|
|
571
|
+
};
|
|
572
|
+
}
|
|
573
|
+
#newOp(kind, token, amount, data) {
|
|
574
|
+
// `data` is the kind's own, which TypeScript cannot follow through a generic
|
|
575
|
+
const op = {
|
|
576
|
+
id: bytesToHex(crypto.getRandomValues(new Uint8Array(8))),
|
|
577
|
+
kind,
|
|
578
|
+
chainId: token.chainId,
|
|
579
|
+
tokenId: token.tokenId,
|
|
580
|
+
amount,
|
|
581
|
+
createdMs: Date.now(),
|
|
582
|
+
status: 'pending',
|
|
583
|
+
data,
|
|
584
|
+
};
|
|
585
|
+
this.#ctx.state.ops.push(op);
|
|
586
|
+
return op;
|
|
587
|
+
}
|
|
588
|
+
/** A fresh burn address of `token`, recorded in storage before anything is sent to it: the one recorded ahead, or
|
|
589
|
+
* else a new one (about 2^16 hashes and a storage write), which `recording` is told of first. */
|
|
590
|
+
async #burnAddress(token, depositAddress = null, recording = () => { }) {
|
|
591
|
+
const ctx = this.#ctx;
|
|
592
|
+
// taken once recorded: a search that close() stops changes nothing
|
|
593
|
+
let record = unusedBurn(ctx, token.chainId, token.tokenId);
|
|
594
|
+
if (!record) {
|
|
595
|
+
recording();
|
|
596
|
+
record = await recordBurn(ctx, token.chainId, token.tokenId, await tokenIds(ctx), this.#closed.signal);
|
|
597
|
+
}
|
|
598
|
+
const next = chainState(ctx, token.chainId).burnNext;
|
|
599
|
+
// recording may have read another device's later ones
|
|
600
|
+
next[token.tokenId] = Math.max(next[token.tokenId] ?? 0, record.burnIndex + 1);
|
|
601
|
+
record.createdMs = Date.now();
|
|
602
|
+
record.depositAddress = depositAddress;
|
|
603
|
+
await ctx.save();
|
|
604
|
+
return record;
|
|
605
|
+
}
|
|
606
|
+
/** Moves `amount` of the account's public tokens into the private balance: wraps the underlying asset into a fresh
|
|
607
|
+
* burn address (approving it first if needed). The deposit is spendable once the ASP has screened it. More than the
|
|
608
|
+
* account holds fails at once (`insufficient-funds`), before anything is asked of the wallet.
|
|
609
|
+
*
|
|
610
|
+
* With `to`, an address that registered, it pays them instead, privately: into a fresh burn address of theirs,
|
|
611
|
+
* under the public key they registered, told to them in storage first as a note is (`planDeposit`). They
|
|
612
|
+
* receive it as a deposit of their own, screened like any; the burn address names no one. It shows in `activity()`
|
|
613
|
+
* as sent. Finding the address takes about 2^16 hashes (`burn-address`).
|
|
614
|
+
*
|
|
615
|
+
* With `wrapped`, it pays from the account's zERC20 itself, as a withdrawal pays out, rather than its underlying
|
|
616
|
+
* asset: a plain transfer into the burn address, with no wrap and no approval.
|
|
617
|
+
*
|
|
618
|
+
* Returns once the wrap is sent: the operation is done once it is mined, which `wait()` or the next sync finds.
|
|
619
|
+
* `onStep` hears what it waits on meanwhile, for an app to show. */
|
|
620
|
+
deposit(opts) {
|
|
621
|
+
const step = opts.onStep ?? (() => { });
|
|
622
|
+
if (this.#busy > 0)
|
|
623
|
+
step('queued');
|
|
624
|
+
return this.#exclusive(async () => {
|
|
625
|
+
const { token, amount } = opts;
|
|
626
|
+
if (amount <= 0n)
|
|
627
|
+
fail('invalid', 'the amount must be positive');
|
|
628
|
+
const wc = this.#ctx.walletClient;
|
|
629
|
+
const account = wc.account;
|
|
630
|
+
const client = chainCtx(this.#ctx, token.chainId).chain.client;
|
|
631
|
+
const { wrapped } = opts;
|
|
632
|
+
const native = !wrapped && token.underlying.toLowerCase() === NATIVE.toLowerCase();
|
|
633
|
+
// a wrap of more would revert, and an ERC20's only after the approval it asks for first
|
|
634
|
+
const held = native
|
|
635
|
+
? await client.getBalance({ address: account.address })
|
|
636
|
+
: await client.readContract({
|
|
637
|
+
address: wrapped ? token.address : token.underlying,
|
|
638
|
+
abi: erc20Abi,
|
|
639
|
+
functionName: 'balanceOf',
|
|
640
|
+
args: [account.address],
|
|
641
|
+
});
|
|
642
|
+
if (held < amount) {
|
|
643
|
+
const of = wrapped ? token.symbol : `${token.symbol}'s underlying asset`;
|
|
644
|
+
fail('insufficient-funds', `the account holds ${formatUnits(held, token.decimals)} of ${of}, less than ${formatUnits(amount, token.decimals)}`);
|
|
645
|
+
}
|
|
646
|
+
const address = opts.to
|
|
647
|
+
? await this.#burnFor(token, amount, opts.to, () => step('burn-address'))
|
|
648
|
+
: (await this.#burnAddress(token, null, () => step('burn-address'))).address;
|
|
649
|
+
const op = this.#newOp('deposit', token, amount, opts.to ? { burnAddress: address, to: opts.to } : { burnAddress: address });
|
|
650
|
+
await this.#ctx.save();
|
|
651
|
+
try {
|
|
652
|
+
if ((await wc.getChainId()) !== Number(token.chainId))
|
|
653
|
+
await guard(wc.switchChain({ id: Number(token.chainId) }));
|
|
654
|
+
if (!native && !wrapped) {
|
|
655
|
+
const allowance = await client.readContract({
|
|
656
|
+
address: token.underlying,
|
|
657
|
+
abi: erc20Abi,
|
|
658
|
+
functionName: 'allowance',
|
|
659
|
+
args: [account.address, token.address],
|
|
660
|
+
});
|
|
661
|
+
if (allowance < amount) {
|
|
662
|
+
step('approve');
|
|
663
|
+
const tx = await guard(wc.writeContract({
|
|
664
|
+
account,
|
|
665
|
+
chain: null,
|
|
666
|
+
address: token.underlying,
|
|
667
|
+
abi: erc20Abi,
|
|
668
|
+
functionName: 'approve',
|
|
669
|
+
args: [token.address, amount],
|
|
670
|
+
}));
|
|
671
|
+
// mined before the wrap, still holding every other call: an approval sets the allowance, so another
|
|
672
|
+
// deposit's in between would overwrite this one's
|
|
673
|
+
await client.waitForTransactionReceipt({ hash: tx });
|
|
674
|
+
await walletSees(wc, tx);
|
|
675
|
+
}
|
|
676
|
+
}
|
|
677
|
+
step('wrap');
|
|
678
|
+
const tx = await guard(wrapped
|
|
679
|
+
? wc.writeContract({
|
|
680
|
+
account,
|
|
681
|
+
chain: null,
|
|
682
|
+
address: token.address,
|
|
683
|
+
abi: erc20Abi,
|
|
684
|
+
functionName: 'transfer',
|
|
685
|
+
args: [address, amount],
|
|
686
|
+
})
|
|
687
|
+
: wc.writeContract({
|
|
688
|
+
account,
|
|
689
|
+
chain: null,
|
|
690
|
+
address: token.address,
|
|
691
|
+
abi: tokenAbi,
|
|
692
|
+
functionName: 'wrap',
|
|
693
|
+
args: [amount, address],
|
|
694
|
+
value: native ? amount : 0n,
|
|
695
|
+
}));
|
|
696
|
+
op.data.txHash = tx;
|
|
697
|
+
// its receipt is for the next sync (settleDeposit): waiting for it here would hold every other call a block
|
|
698
|
+
}
|
|
699
|
+
catch (e) {
|
|
700
|
+
// declined, or nothing sent for another reason: over. A sent one is settled by its receipt on a later sync.
|
|
701
|
+
if (!op.data.txHash) {
|
|
702
|
+
op.status = 'failed';
|
|
703
|
+
op.error = e.message;
|
|
704
|
+
if (e instanceof WalletError)
|
|
705
|
+
op.errorCode = e.code;
|
|
706
|
+
}
|
|
707
|
+
await this.#ctx.save();
|
|
708
|
+
throw e;
|
|
709
|
+
}
|
|
710
|
+
await this.#ctx.save();
|
|
711
|
+
return this.#operation(op);
|
|
712
|
+
});
|
|
713
|
+
}
|
|
714
|
+
/** A fresh burn address of `to`'s, for a deposit of `amount` that pays them, told to them in storage: a note that only
|
|
715
|
+
* shows on chain cannot be found. */
|
|
716
|
+
async #burnFor(token, amount, to, searching) {
|
|
717
|
+
const ctx = this.#ctx;
|
|
718
|
+
const recipient = await lookupRecipient(ctx.storage, hexToBytes(to));
|
|
719
|
+
if (!recipient)
|
|
720
|
+
fail('recipient-not-registered', `${to} cannot be paid privately: withdraw to it publicly, or ask for a receive code`);
|
|
721
|
+
searching();
|
|
722
|
+
const burn = await burnAddressFor(token.chainId, bytesToHex(recipient.publicKey), this.#closed.signal);
|
|
723
|
+
// with the address, so that every device of this wallet finds the transfer it then sends
|
|
724
|
+
const value = hexToBytes(numberToHex(amount, { size: 32 }));
|
|
725
|
+
const deposit = {
|
|
726
|
+
chainId: token.chainId,
|
|
727
|
+
tokenId: token.tokenId,
|
|
728
|
+
value,
|
|
729
|
+
burnSalt: hexToBytes(burn.salt),
|
|
730
|
+
burnAddress: hexToBytes(burn.address),
|
|
731
|
+
};
|
|
732
|
+
const result = await commit(ctx.storage, ctx.view, storageState(ctx), (s) => planDeposit(ctx.view, s, recipient, deposit));
|
|
733
|
+
takeRefresh(ctx, result, await tokenIds(ctx));
|
|
734
|
+
return burn.address;
|
|
735
|
+
}
|
|
736
|
+
/** An address that takes `token`'s underlying asset from anywhere (an exchange withdrawal, another wallet) and
|
|
737
|
+
* deposits it privately, less `fee` (in the underlying's base units) to the deposit relayer each time: what arrives
|
|
738
|
+
* must exceed it. A wallet opened with a `referrer` also pays it its `bps` of the rest. Watched for 7 days by the
|
|
739
|
+
* deposit relayer; refunds go back to this account. `onStep` hears what it waits on (`queued`, `burn-address`). */
|
|
740
|
+
depositAddress(token, opts = {}) {
|
|
741
|
+
const step = opts.onStep ?? (() => { });
|
|
742
|
+
if (this.#busy > 0)
|
|
743
|
+
step('queued');
|
|
744
|
+
return this.#exclusive(async () => {
|
|
745
|
+
const c = chainCtx(this.#ctx, token.chainId);
|
|
746
|
+
const record = await this.#burnAddress(token, null, () => step('burn-address'));
|
|
747
|
+
const { depositAddress, fee } = await registerDeposit(c.chain, token.address, record.address, this.address, this.#referrer);
|
|
748
|
+
record.depositAddress = depositAddress;
|
|
749
|
+
// for the wallet's other devices, to show what comes through it: written by the next sync
|
|
750
|
+
const burnAddress = record.address.toLowerCase();
|
|
751
|
+
this.#ctx.state.receipts.push({
|
|
752
|
+
kind: 'deposit-address',
|
|
753
|
+
chainId: token.chainId,
|
|
754
|
+
tokenId: token.tokenId,
|
|
755
|
+
burnAddress,
|
|
756
|
+
depositAddress,
|
|
757
|
+
atMs: Date.now(),
|
|
758
|
+
unwritten: true,
|
|
759
|
+
});
|
|
760
|
+
await this.#ctx.save();
|
|
761
|
+
return { address: depositAddress, fee };
|
|
762
|
+
});
|
|
763
|
+
}
|
|
764
|
+
/** A receive code for `token`, to hand to one payer: it pays any amount, as often as it likes, publicly by a transfer
|
|
765
|
+
* of the token to the code's burn address (a deposit, like a point of sale's payment addresses), or privately by
|
|
766
|
+
* `send`. It is one of this wallet's burn addresses, recorded in storage ahead of need, so handing it out writes
|
|
767
|
+
* nothing; it names neither this address nor its public key. Anyone who gets the code can pay it, and see what its
|
|
768
|
+
* other payers paid privately: give each payer its own. It is checksummed text (bech32m, `zerc201…`). */
|
|
769
|
+
receiveCode(token) {
|
|
770
|
+
return this.#exclusive(async () => {
|
|
771
|
+
const ctx = this.#ctx;
|
|
772
|
+
const { burnIndex, address } = await this.#burnAddress(token);
|
|
773
|
+
return encodeCode({
|
|
774
|
+
chainId: token.chainId,
|
|
775
|
+
tokenId: token.tokenId,
|
|
776
|
+
burnAddress: hexToBytes(address),
|
|
777
|
+
publicKey: hexToBytes(spendKey(ctx, { chainId: token.chainId, tokenId: token.tokenId, index: burnIndex }).publicKey),
|
|
778
|
+
key: codeKey(ctx.view.seed, token.chainId, token.tokenId, burnIndex),
|
|
779
|
+
});
|
|
780
|
+
});
|
|
781
|
+
}
|
|
782
|
+
/** What a send or withdrawal of `amount` would cost now, or a consolidation of what the wallet holds. A send or
|
|
783
|
+
* withdrawal no single JoinSplit can pay now throws as it would (`needs-consolidation`, `funds-settling`,
|
|
784
|
+
* `insufficient-funds`). A consolidation of chosen `coins` throws `funds-settling` until all of them are spendable. */
|
|
785
|
+
async fees(opts) {
|
|
786
|
+
const { token } = opts;
|
|
787
|
+
const amount = opts.amount ?? 0n;
|
|
788
|
+
const c = chainCtx(this.#ctx, token.chainId);
|
|
789
|
+
const relayer = (await relayerFees(c.config)).fees.get(token.tokenId)?.fee ??
|
|
790
|
+
fail('unavailable', 'the relayer does not relay this token');
|
|
791
|
+
const size = this.#status.get(token.chainId)?.latestRoot?.size ?? 0;
|
|
792
|
+
const all = coins(this.#ctx, token.chainId, token.tokenId, size);
|
|
793
|
+
if (opts.kind === 'consolidate') {
|
|
794
|
+
const chosen = opts.coins && this.#chosen(token, opts.coins);
|
|
795
|
+
if (chosen && chosenSettling(this.#ctx, token.chainId, chosen, all)) {
|
|
796
|
+
fail('funds-settling', 'a chosen note or deposit is still settling: quote again once coins() shows it spendable');
|
|
797
|
+
}
|
|
798
|
+
const steps = BigInt(roundsToMerge(mergePool(all, chosen)));
|
|
799
|
+
// a merge pays the relayer publicly, so the protocol takes its cut of that, grossed up as a payment's is
|
|
800
|
+
const cut = steps === 0n ? 0n : (await grossed(this.#ctx, token.chainId, token.tokenId, relayer)) - relayer;
|
|
801
|
+
return { relayer: relayer * steps, protocol: cut * steps, fee: (relayer + cut) * steps, steps: Number(steps) };
|
|
802
|
+
}
|
|
803
|
+
// as the payment's plan reckons it, so that `fee` is a `maxFee` it passes
|
|
804
|
+
const { protocol } = await withdrawalOf(this.#ctx, token.chainId, token.tokenId, opts.kind === 'withdraw', amount, relayer);
|
|
805
|
+
const onTheWay = this.#ctx.state.ops.some((o) => o.status === 'pending') ||
|
|
806
|
+
settling(this.#ctx, token.chainId, token.tokenId, size);
|
|
807
|
+
payWith(all, amount + relayer + protocol, relayer, onTheWay);
|
|
808
|
+
return { relayer, protocol, fee: relayer + protocol, steps: 1 };
|
|
809
|
+
}
|
|
810
|
+
/** The most a send or withdrawal of `token` can pay: what is spendable, less the fees `fees` quotes on top of it, and
|
|
811
|
+
* the merges first when one JoinSplit cannot spend it all, which `fees` then asks for (`needs-consolidation`). */
|
|
812
|
+
async maxAmount(opts) {
|
|
813
|
+
const { token } = opts;
|
|
814
|
+
const c = chainCtx(this.#ctx, token.chainId);
|
|
815
|
+
const relayer = (await relayerFees(c.config)).fees.get(token.tokenId)?.fee ??
|
|
816
|
+
fail('unavailable', 'the relayer does not relay this token');
|
|
817
|
+
const size = this.#status.get(token.chainId)?.latestRoot?.size ?? 0;
|
|
818
|
+
return maxPayment(this.#ctx, token.chainId, token.tokenId, opts.kind === 'withdraw', coins(this.#ctx, token.chainId, token.tokenId, size), relayer);
|
|
819
|
+
}
|
|
820
|
+
/** What the next merge of a consolidation of everything would spend, as `coins()` ids: up to 100, the largest first,
|
|
821
|
+
* for `consolidate({ coins })`. Null when nothing spendable is worth merging. */
|
|
822
|
+
async nextMerge(token) {
|
|
823
|
+
const c = chainCtx(this.#ctx, token.chainId);
|
|
824
|
+
const fee = (await relayerFees(c.config)).fees.get(token.tokenId)?.fee ??
|
|
825
|
+
fail('unavailable', 'the relayer does not relay this token');
|
|
826
|
+
const size = this.#status.get(token.chainId)?.latestRoot?.size ?? 0;
|
|
827
|
+
const merge = mergeRound(coins(this.#ctx, token.chainId, token.tokenId, size), fee);
|
|
828
|
+
return merge && merge.map((m) => `${token.chainId}:${m.index}`);
|
|
829
|
+
}
|
|
830
|
+
/** Pays `amount` privately: to an address that has registered (any wallet that ever opened), or to a receive code
|
|
831
|
+
* (`receiveCode`). An address that never registered fails with `recipient-not-registered`. One JoinSplit from
|
|
832
|
+
* what is spendable now, or it fails here: `needs-consolidation`, `funds-settling` or `insufficient-funds`.
|
|
833
|
+
* `amount` is what the recipient gets, and `maxFee` caps what it pays on top, `fees({ kind: 'send' }).fee`: the
|
|
834
|
+
* relayer names its fee, so a quote the user saw is the cap. */
|
|
835
|
+
send(opts) {
|
|
836
|
+
return this.#exclusive(async () => {
|
|
837
|
+
const { token, amount } = opts;
|
|
838
|
+
if (amount <= 0n)
|
|
839
|
+
fail('invalid', 'the amount must be positive');
|
|
840
|
+
let to;
|
|
841
|
+
if (isAddress(opts.to)) {
|
|
842
|
+
const recipient = await lookupRecipient(this.#ctx.storage, hexToBytes(opts.to));
|
|
843
|
+
if (!recipient)
|
|
844
|
+
fail('recipient-not-registered', `${opts.to} cannot be paid privately: withdraw to it publicly, or ask for a receive code`);
|
|
845
|
+
to = { kind: 'address', recipient: recipient };
|
|
846
|
+
}
|
|
847
|
+
else {
|
|
848
|
+
const code = decodeCode(opts.to);
|
|
849
|
+
if (!code)
|
|
850
|
+
return fail('invalid', 'not an address or a receive code');
|
|
851
|
+
if (code.chainId !== token.chainId || code.tokenId !== token.tokenId)
|
|
852
|
+
fail('invalid', 'the receive code is for another token');
|
|
853
|
+
to = { kind: 'code', code: opts.to.trim() };
|
|
854
|
+
}
|
|
855
|
+
return this.#newPayment('send', token, amount, { to, maxFee: opts.maxFee, step: null, paid: 0n });
|
|
856
|
+
});
|
|
857
|
+
}
|
|
858
|
+
/** Merges `token`'s scattered notes and deposits into one note, without paying anyone: fewer, larger notes make
|
|
859
|
+
* later payments a single JoinSplit. One Nova merge spends up to 100 of them and pays the relayer's fee once; the
|
|
860
|
+
* network's merge prover proves it, seeing its witness in its enclave while it does: the openings of what it spends, but
|
|
861
|
+
* no private key, so it can prove only the merge this wallet signed. More
|
|
862
|
+
* take another merge, once a proved root is over the first one's note; `fees({ kind: 'consolidate' })` tells how
|
|
863
|
+
* many. A lone deposit becomes a note. A payment that fails with `needs-consolidation` goes through after this.
|
|
864
|
+
*
|
|
865
|
+
* With `coins` (ids from `coins()`, notes and deposits), only those are merged, into one note, and nothing else is
|
|
866
|
+
* touched; one still settling is waited for. `maxFee` caps the fees of every merge together,
|
|
867
|
+
* `fees({ kind: 'consolidate' }).fee`. */
|
|
868
|
+
consolidate(opts) {
|
|
869
|
+
return this.#exclusive(async () => {
|
|
870
|
+
if (!this.#ctx.network.mergeProver)
|
|
871
|
+
fail('invalid', 'this network has no merge prover');
|
|
872
|
+
const to = { kind: 'self' };
|
|
873
|
+
const data = { to, maxFee: opts.maxFee, step: null, paid: 0n };
|
|
874
|
+
if (opts.coins)
|
|
875
|
+
data.chosen = this.#chosen(opts.token, opts.coins);
|
|
876
|
+
const op = this.#newOp('consolidate', opts.token, 0n, data);
|
|
877
|
+
await this.#ctx.save();
|
|
878
|
+
return this.#operation(op);
|
|
879
|
+
});
|
|
880
|
+
}
|
|
881
|
+
/** The commitment indices of coins `ids` of `token`, none blocked or already being spent. */
|
|
882
|
+
#chosen(token, ids) {
|
|
883
|
+
if (ids.length === 0)
|
|
884
|
+
fail('invalid', 'choose at least one note or deposit');
|
|
885
|
+
const coins = this.coins();
|
|
886
|
+
return ids.map((id) => {
|
|
887
|
+
const c = coins.find((x) => x.id === id) ?? fail('invalid', `no unspent note or deposit ${id}`);
|
|
888
|
+
if (c.token.chainId !== token.chainId || c.token.tokenId !== token.tokenId)
|
|
889
|
+
fail('invalid', `${id} is not ${token.symbol}`);
|
|
890
|
+
if (c.state === 'blocked' || c.state === 'spending')
|
|
891
|
+
fail('invalid', `${id} is ${c.state}`);
|
|
892
|
+
return Number(id.split(':')[1]);
|
|
893
|
+
});
|
|
894
|
+
}
|
|
895
|
+
/** Withdraws `amount` publicly to `to`, as zERC20 or with `unwrap` the underlying asset itself, so `to` needs no gas
|
|
896
|
+
* to unwrap. One JoinSplit, as `send`, and as there `to` gets all of `amount` and `maxFee` caps what is paid on top
|
|
897
|
+
* (`fees({ kind: 'withdraw' }).fee`). */
|
|
898
|
+
withdraw(opts) {
|
|
899
|
+
return this.#exclusive(async () => {
|
|
900
|
+
if (!isAddress(opts.to))
|
|
901
|
+
fail('invalid', `${String(opts.to)} is not an address`);
|
|
902
|
+
if (opts.amount <= 0n)
|
|
903
|
+
fail('invalid', 'the amount must be positive');
|
|
904
|
+
const to = { kind: 'public', address: opts.to, unwrap: opts.unwrap };
|
|
905
|
+
return this.#newPayment('withdraw', opts.token, opts.amount, { to, maxFee: opts.maxFee, step: null, paid: 0n });
|
|
906
|
+
});
|
|
907
|
+
}
|
|
908
|
+
/** A send or withdrawal with its one JoinSplit planned now: it fails here rather than later, and its inputs are
|
|
909
|
+
* taken at once, so a payment right after it cannot count on them. */
|
|
910
|
+
async #newPayment(kind, token, amount, data) {
|
|
911
|
+
const op = this.#newOp(kind, token, amount, data);
|
|
912
|
+
try {
|
|
913
|
+
data.step = (await planStep(this.#ctx, op)); // a payment's plan is a step, or a throw
|
|
914
|
+
}
|
|
915
|
+
catch (e) {
|
|
916
|
+
this.#ctx.state.ops.splice(this.#ctx.state.ops.indexOf(op), 1);
|
|
917
|
+
throw e;
|
|
918
|
+
}
|
|
919
|
+
await this.#ctx.save();
|
|
920
|
+
return this.#operation(op);
|
|
921
|
+
}
|
|
922
|
+
}
|
|
923
|
+
/** 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. Nothing where there are no Web Locks. */
|
|
925
|
+
export async function lockWallet(address) {
|
|
926
|
+
const locks = globalThis.navigator?.locks;
|
|
927
|
+
if (!locks)
|
|
928
|
+
return () => { };
|
|
929
|
+
let release = () => { };
|
|
930
|
+
const held = new Promise((r) => (release = r));
|
|
931
|
+
const got = await new Promise((resolve) => {
|
|
932
|
+
void locks.request(`zerc20-wallet:${address.toLowerCase()}`, { ifAvailable: true }, (lock) => {
|
|
933
|
+
resolve(lock !== null);
|
|
934
|
+
return lock ? held : undefined;
|
|
935
|
+
});
|
|
936
|
+
});
|
|
937
|
+
if (!got)
|
|
938
|
+
fail('wallet-in-use', 'this wallet is open in another tab or window: close it there first');
|
|
939
|
+
return release;
|
|
940
|
+
}
|
|
941
|
+
/** A deposit `deposit()` did not see through (a reload, a lost connection): settled by its transaction's receipt, or
|
|
942
|
+
* failed if it never sent one. Runs only between tasks, so no `deposit()` is still on its way to sending it. */
|
|
943
|
+
async function settleDeposit(ctx, op) {
|
|
944
|
+
const hash = op.data.txHash;
|
|
945
|
+
if (hash) {
|
|
946
|
+
const receipt = await chainCtx(ctx, op.chainId)
|
|
947
|
+
.chain.client.getTransactionReceipt({ hash })
|
|
948
|
+
.catch(() => null);
|
|
949
|
+
if (!receipt)
|
|
950
|
+
return; // not mined yet
|
|
951
|
+
op.status = receipt.status === 'success' ? 'done' : 'failed';
|
|
952
|
+
if (op.status === 'failed')
|
|
953
|
+
op.error = 'the deposit transaction reverted';
|
|
954
|
+
}
|
|
955
|
+
else {
|
|
956
|
+
op.status = 'failed';
|
|
957
|
+
op.error = 'the deposit was never sent';
|
|
958
|
+
}
|
|
959
|
+
await ctx.save();
|
|
960
|
+
}
|
|
961
|
+
/** A fresh state: a new wallet looks only at what comes next, a restored one from its birthday on. */
|
|
962
|
+
async function start(ctx, birthday) {
|
|
963
|
+
for (const c of ctx.chains) {
|
|
964
|
+
const s = chainState(ctx, c.config.chainId);
|
|
965
|
+
const status = await c.indexer.status();
|
|
966
|
+
let floor = { commitments: status.commitmentsFinal, nullifiers: status.nullifiersFinal };
|
|
967
|
+
if (birthday !== undefined)
|
|
968
|
+
floor = await floorAt(c, birthday);
|
|
969
|
+
s.samples.push({ atMs: 0, ...floor });
|
|
970
|
+
}
|
|
971
|
+
await ctx.save();
|
|
972
|
+
}
|
|
973
|
+
/** The counts before `birthday` (a block, or a date found by bisecting block times), from the indexer's marks. */
|
|
974
|
+
async function floorAt(c, birthday) {
|
|
975
|
+
let block = typeof birthday === 'bigint' ? birthday : 0n;
|
|
976
|
+
if (birthday instanceof Date) {
|
|
977
|
+
let [lo, hi] = [0n, await c.chain.client.getBlockNumber()];
|
|
978
|
+
const target = BigInt(Math.floor(birthday.getTime() / 1000));
|
|
979
|
+
while (lo < hi) {
|
|
980
|
+
const mid = (lo + hi + 1n) / 2n;
|
|
981
|
+
if ((await c.chain.client.getBlock({ blockNumber: mid })).timestamp <= target)
|
|
982
|
+
lo = mid;
|
|
983
|
+
else
|
|
984
|
+
hi = mid - 1n;
|
|
985
|
+
}
|
|
986
|
+
block = lo;
|
|
987
|
+
}
|
|
988
|
+
// the last page of commitments that began before the birthday
|
|
989
|
+
let floor = { commitments: 0, nullifiers: 0 };
|
|
990
|
+
for (let p = 0;; p++) {
|
|
991
|
+
const marks = await c.indexer.page('marks', p);
|
|
992
|
+
for (let k = 0; k < marks.length / 16; k++) {
|
|
993
|
+
const view = new DataView(marks.buffer, marks.byteOffset + k * 16, 16);
|
|
994
|
+
if (view.getBigUint64(0, false) > block)
|
|
995
|
+
return floor;
|
|
996
|
+
floor = { commitments: (p * PAGE + k) * PAGE, nullifiers: Number(view.getBigUint64(8, false)) };
|
|
997
|
+
}
|
|
998
|
+
if (marks.length < PAGE * 16)
|
|
999
|
+
return floor;
|
|
1000
|
+
}
|
|
1001
|
+
}
|
|
1002
|
+
//# sourceMappingURL=wallet.js.map
|