@opeqlabs/sdk 0.0.0-stage → 0.1.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.
@@ -0,0 +1,636 @@
1
+ import * as E from "./evmwots.js";
2
+ import * as S from "./secp256k1.js";
3
+ import { signTx1559, hexToBytes, bytesToHex } from "./evmtx.js";
4
+ import { keccak256 } from "./keccak.js";
5
+ import { FACTORY_RUNTIME, ACCOUNT_RUNTIME } from "./evmcode.js";
6
+ import * as V from "./vaultstore.js";
7
+
8
+ const enc = new TextEncoder();
9
+ const sel = (sig) => bytesToHex(keccak256(enc.encode(sig)).slice(0, 4));
10
+ const SEL = {
11
+ epoch: sel("epoch()"), next: sel("next()"), pubSeed: sel("pubSeed()"), root: sel("root()"),
12
+ balanceOf: sel("balanceOf(address)"), decimals: sel("decimals()"), symbol: sel("symbol()"),
13
+ };
14
+ export const DEADLINE_S = 15 * 60;
15
+ export const REKEY_SOON = 100;
16
+ const GAS_HEADROOM = 13n;
17
+ export const EXEC_GAS = 320000n;
18
+ export const REKEY_GAS = 300000n;
19
+ export const TOPUP_LOW = 3n;
20
+ export const TAKEOVER_LO = 968;
21
+ const REKEY_EXTRA = 70000n;
22
+ const PENDING_REKEYS = 4;
23
+ export const TOPUP_TARGET = 10n;
24
+ const SWEEP_GAS = 60000n;
25
+ export const SETUP_GAS = { factory: 1700000n, account: 1300000n };
26
+ const b64 = (b) => btoa(String.fromCharCode(...b));
27
+ const unb64 = (s) => Uint8Array.from(atob(s), (c) => c.charCodeAt(0));
28
+ const lower = (a) => a.toLowerCase();
29
+ const word = (h, i = 0) => BigInt("0x" + (h.slice(2 + 64 * i, 66 + 64 * i) || "0"));
30
+
31
+ export function checkAddress(text) {
32
+ const t = String(text || "").trim();
33
+ if (!/^0x[0-9a-fA-F]{40}$/.test(t)) throw new Error("That is not an EVM address (0x followed by 40 hex characters).");
34
+ if (/[a-f]/.test(t.slice(2)) && /[A-F]/.test(t.slice(2))) {
35
+ const h = keccak256(enc.encode(t.slice(2).toLowerCase()));
36
+ for (let i = 0; i < 40; i++) {
37
+ const c = t[2 + i], nib = (h[i >> 1] >> (i % 2 ? 0 : 4)) & 15;
38
+ if (/[a-f]/i.test(c) && (nib >= 8) !== (c === c.toUpperCase())) throw new Error("That address has a typo: its upper and lower case letters do not match its checksum.");
39
+ }
40
+ }
41
+ if (/^0x0{40}$/.test(t)) throw new Error("That is the zero address. Funds sent there are burned.");
42
+ return lower(t);
43
+ }
44
+
45
+ export function parseUnits(text, decimals) {
46
+ let t = String(text || "").trim();
47
+ if (t.startsWith(".")) t = "0" + t;
48
+ const re = new RegExp(`^\\d{1,30}(\\.\\d{1,${decimals}})?$`);
49
+ if (!decimals ? !/^\d{1,30}$/.test(t) : !re.test(t)) throw new Error(`Enter an amount like 0.01, with at most ${decimals} decimals.`);
50
+ const [w, f = ""] = t.split(".");
51
+ const v = BigInt(w) * 10n ** BigInt(decimals) + BigInt((f + "0".repeat(decimals)).slice(0, decimals) || "0");
52
+ if (v <= 0n) throw new Error("The amount must be more than zero.");
53
+ return v;
54
+ }
55
+
56
+ export function formatUnits(v, decimals, max = 6) {
57
+ const neg = v < 0n;
58
+ if (neg) v = -v;
59
+ const base = 10n ** BigInt(decimals);
60
+ const w = v / base;
61
+ let f = (v % base).toString().padStart(decimals, "0").slice(0, max).replace(/0+$/, "");
62
+ return (neg ? "-" : "") + w.toLocaleString("en-US") + (f ? "." + f : "");
63
+ }
64
+
65
+ export class EvmRpc {
66
+ constructor(url) { this.url = url; this.n = 0; }
67
+ async call(method, params = []) {
68
+ const ctl = new AbortController();
69
+ const t = setTimeout(() => ctl.abort(), 20000);
70
+ let j;
71
+ try {
72
+ const r = await fetch(this.url, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ jsonrpc: "2.0", id: ++this.n, method, params }), signal: ctl.signal });
73
+ j = await r.json();
74
+ } catch (e) {
75
+ throw new Error(e.name === "AbortError" ? "the network did not answer in 20 seconds" : "could not reach the network");
76
+ } finally { clearTimeout(t); }
77
+ if (j.error) { const e = new Error(String(j.error.message || "request failed").slice(0, 200)); e.fromRpc = true; e.data = j.error.data; throw e; }
78
+ return j.result;
79
+ }
80
+ ethCall(to, data, block = "latest") { return this.call("eth_call", [{ to, data }, block]); }
81
+ async balance(a, block = "latest") { return BigInt(await this.call("eth_getBalance", [a, block])); }
82
+ async code(a, block = "latest") { return await this.call("eth_getCode", [a, block]); }
83
+ }
84
+
85
+ export class EvmChain {
86
+ constructor({ chain, store, macKey, legacyMac, vaultId, master, compute, rpcUrl, checkUrl }) {
87
+ this.legacyMac = legacyMac || null;
88
+ this.chain = chain;
89
+ this.id = chain.chainId;
90
+ this.rpc = new EvmRpc(rpcUrl || chain.rpc);
91
+ const ck = checkUrl === undefined ? chain.check : checkUrl;
92
+ this.check = ck ? new EvmRpc(ck) : null;
93
+ this.store = store;
94
+ this.macKey = macKey;
95
+ this.vaultId = vaultId;
96
+ this.master = master;
97
+ this.compute = compute;
98
+ this.fee = S.feeKeyFromMaster(master);
99
+ this.feeAddress = S.addressOf(this.fee);
100
+ this.factory = E.factoryAddress();
101
+ this.key = null;
102
+ this.identity = null;
103
+ this.minBlock = 0;
104
+ }
105
+
106
+ async at(fn) {
107
+ for (let i = 0; i < 20; i++) {
108
+ const n = Number(await this.rpc.call("eth_blockNumber"));
109
+ if (n >= this.minBlock) {
110
+ try { return await fn("0x" + n.toString(16)); }
111
+ catch (e) { if (!e.fromRpc || !/header not found|unknown block|block not found|missing trie node/i.test(e.message)) throw e; }
112
+ }
113
+ await new Promise((r) => setTimeout(r, 1000));
114
+ }
115
+ throw new Error("the network RPC is behind the last confirmed transaction; try again in a minute");
116
+ }
117
+
118
+ async checkNetwork() {
119
+ const id = Number(await this.rpc.call("eth_chainId"));
120
+ if (id !== this.id) throw new Error(`the RPC is on chain ${id}, not ${this.chain.name} (${this.id})`);
121
+ if (this.check && Number(await this.check.call("eth_chainId")) !== this.id) throw new Error(`the second network source is not on ${this.chain.name}`);
122
+ }
123
+
124
+ async run(op, args, progress) {
125
+ if (!this.master || this.master.every((b) => b === 0)) throw new Error("the wallet locked, nothing was signed");
126
+ const m = this.master.slice();
127
+ try { return await this.compute.run(op, { ...args, master: m }, progress); }
128
+ finally { m.fill(0); }
129
+ }
130
+
131
+ async agree(st, keys) {
132
+ if (!this.check) return;
133
+ let answered = false;
134
+ for (let i = 0; i < 10; i++) {
135
+ const w = await this.chainStateAt("latest", this.check).catch(() => null);
136
+ if (w) answered = true;
137
+ if (w && keys.every((k) => w[k] === st[k])) return;
138
+ await new Promise((r) => setTimeout(r, 1500));
139
+ }
140
+ throw new Error(answered ? "two independent network sources disagree about your account, so nothing was signed. Try again in a minute" : "the second network source did not answer, so nothing was signed. Try again in a minute");
141
+ }
142
+
143
+ async clock() {
144
+ const a = Number((await this.rpc.call("eth_getBlockByNumber", ["latest", false])).timestamp);
145
+ if (!this.check) return a;
146
+ const b = Number((await this.check.call("eth_getBlockByNumber", ["latest", false])).timestamp);
147
+ if (Math.abs(a - b) > 120) throw new Error("the two network sources disagree about the time, so nothing was signed. Try again in a minute");
148
+ return Math.min(a, b);
149
+ }
150
+
151
+ async seenBoth() {
152
+ const a = await this.chainState();
153
+ const b = this.check ? await this.chainStateAt("latest", this.check).catch(() => null) : a;
154
+ return { a, b };
155
+ }
156
+
157
+ pendingText(p, signed = false) {
158
+ return `a transfer you signed ${signed ? "" : "earlier "}may still go through until ${new Date(p.deadline * 1000).toLocaleTimeString()}. Wait until it confirms or that time passes, or cancel it${signed ? "" : " first. Nothing new was signed"}`;
159
+ }
160
+
161
+ async resolvePending(r) {
162
+ const p = r.pending;
163
+ if (!p) return;
164
+ const { a, b } = await this.seenBoth();
165
+ const gone = (x) => x && (x.epoch !== r.epoch || x.next > p.leaf);
166
+ let done = gone(a) && gone(b);
167
+ if (!done) done = (await this.clock()) > p.deadline && Date.now() / 1000 > p.deadline;
168
+ if (!done) { const e = new Error(this.pendingText(p)); e.pendingBlock = true; throw e; }
169
+ delete r.pending;
170
+ await this.put(r);
171
+ }
172
+
173
+ async gasFor(len, steps, calls = [], extra = 0n) {
174
+ const filler = crypto.getRandomValues(new Uint8Array(len));
175
+ const req = [{ from: this.feeAddress, to: this.feeAddress, data: bytesToHex(filler) }];
176
+ const base = BigInt(await this.rpc.call("eth_estimateGas", req));
177
+ if (this.check) {
178
+ const other = await this.check.call("eth_estimateGas", req).then(BigInt).catch(() => null);
179
+ if (other === null) throw new Error("the second network source did not answer the fee check. Nothing was signed; try again in a minute");
180
+ if (base > (other * 5n) / 4n + 2000n || other > (base * 5n) / 4n + 2000n) throw new Error("the two network sources disagree about the gas this needs. Nothing was signed; try again in a minute");
181
+ }
182
+ return ((base + 30000n + BigInt(steps) * 80n + (await this.callsGas(calls)) + extra) * GAS_HEADROOM) / 10n;
183
+ }
184
+
185
+ async callsGas(calls) {
186
+ let inner = 0n;
187
+ for (const c of calls) {
188
+ let g;
189
+ try { g = BigInt(await this.rpc.call("eth_estimateGas", [{ from: this.account, to: c.to, value: "0x" + BigInt(c.value || 0).toString(16), data: bytesToHex(c.data || new Uint8Array(0)) }])); }
190
+ catch (e) { throw new Error(`this transfer would fail on chain (${String(e.message).slice(0, 120)}). Nothing was signed`); }
191
+ inner += (g > 21000n ? g - 21000n : 0n) + 12000n;
192
+ }
193
+ return inner;
194
+ }
195
+
196
+ bootPubSeed() {
197
+ return bytesToHex(E.bootSeeds(this.master, this.id, this.account).pubSeed);
198
+ }
199
+
200
+ async loadIdentity(progress) {
201
+ const key = `${this.vaultId}:evm:id:${this.factory}`;
202
+ const pubSeed = E.identitySeeds(this.master, this.factory).pubSeed;
203
+ let c = await this.store.get(key);
204
+ if (c) {
205
+ await this.verify(c);
206
+ if (c.pub_seed !== bytesToHex(pubSeed)) throw new Error("the saved EVM identity does not match these words");
207
+ } else {
208
+ const t = await this.run("evmIdentity", {}, progress);
209
+ c = await this.put({ id: key, pub_seed: bytesToHex(t.pubSeed), root: bytesToHex(t.root) });
210
+ }
211
+ this.identity = { pubSeed, root: hexToBytes(c.root) };
212
+ this.account = E.accountAddress(this.identity.pubSeed, this.identity.root, this.factory);
213
+ this.key = `${this.vaultId}:evm:${this.id}:${this.account}`;
214
+ return this.account;
215
+ }
216
+
217
+ async verify(r) {
218
+ try { await V.checkRecord(this.macKey, r); }
219
+ catch (e) {
220
+ if (!this.legacyMac) throw e;
221
+ await V.checkRecord(this.legacyMac, r);
222
+ await this.put(r);
223
+ }
224
+ }
225
+
226
+ async record() {
227
+ const r = await this.store.get(this.key);
228
+ if (!r) return null;
229
+ await this.verify(r);
230
+ return r;
231
+ }
232
+
233
+ async put(r) {
234
+ const old = await this.store.get(r.id);
235
+ if (old && old.epoch === r.epoch && Array.isArray(old.used) && old.used.some((l) => !(r.used || []).includes(l))) throw new Error("refusing to forget a one-time key that was already used");
236
+ await V.signRecord(this.macKey, r);
237
+ await this.store.put(r);
238
+ return r;
239
+ }
240
+
241
+ lock(fn) { return V.withLock(`opeq-evm:${this.key}`, fn); }
242
+
243
+ async chainState() {
244
+ return this.at((b) => this.chainStateAt(b));
245
+ }
246
+
247
+ async chainStateAt(block, rpc = this.rpc) {
248
+ const code = await rpc.code(this.account, block);
249
+ if (code === "0x") return { deployed: false, epoch: 0, next: 0, pubSeed: null, root: null };
250
+ if (lower(code.slice(2)) !== lower(ACCOUNT_RUNTIME)) throw new Error("the code at your account address is not the OPEQ account contract, refusing to use it");
251
+ const [ep, nx, ps, rt] = await Promise.all([SEL.epoch, SEL.next, SEL.pubSeed, SEL.root].map((s) => rpc.ethCall(this.account, s, block)));
252
+ return { deployed: true, epoch: Number(word(ep)), next: Number(word(nx)), pubSeed: ps.slice(0, 34), root: rt.slice(0, 34) };
253
+ }
254
+
255
+ async tidy() {
256
+ try { await this.lock(async () => { const r = await this.record(); if (r && r.pending) await this.resolvePending(r); }); } catch {}
257
+ }
258
+
259
+ async status() {
260
+ const [[st, bal, feeBal], r] = await Promise.all([
261
+ this.at((b) => Promise.all([this.chainStateAt(b), this.rpc.balance(this.account, b), this.rpc.balance(this.feeAddress, b)])),
262
+ this.record(),
263
+ ]);
264
+ let state, next = null;
265
+ const same = r && r.epoch === st.epoch && r.pub_seed === st.pubSeed && r.root === st.root;
266
+ if (!st.deployed || st.epoch === 0) state = "setup";
267
+ else if (same && r.active && st.pubSeed !== this.bootPubSeed()) state = "ready";
268
+ else if (st.epoch === 1 && st.pubSeed === this.bootPubSeed()) state = "finish";
269
+ else if (r && st.epoch === r.epoch + 1 && (next = (r.rekeys || []).find((k) => k.pub_seed === st.pubSeed && k.root === st.root))) state = "adopt";
270
+ else state = "other";
271
+ const left = state === "ready" ? Math.max(0, E.EXEC_LIMIT - Math.max(r.next_leaf, st.next)) : 0;
272
+ return { state, st, r, bal, feeBal, left, next };
273
+ }
274
+
275
+ async fees() {
276
+ const [block, tip] = await Promise.all([this.rpc.call("eth_getBlockByNumber", ["latest", false]), this.rpc.call("eth_maxPriorityFeePerGas").catch(() => "0x0")]);
277
+ const base = BigInt(block.baseFeePerGas || "0x0");
278
+ const prio = BigInt(tip) > 0n ? BigInt(tip) : 1000000n;
279
+ return { maxFeePerGas: base * 2n + prio, maxPriorityFeePerGas: prio, now: Number(block.timestamp) };
280
+ }
281
+
282
+ async estimate(to, data, value = 0n) {
283
+ const g = await this.rpc.call("eth_estimateGas", [{ from: this.feeAddress, to, data: bytesToHex(data), value: "0x" + value.toString(16) }]);
284
+ return (BigInt(g) * GAS_HEADROOM) / 10n;
285
+ }
286
+
287
+ async costOf(gas) {
288
+ const f = await this.fees();
289
+ return gas * f.maxFeePerGas;
290
+ }
291
+
292
+ async sendTx(to, data, value = 0n, gas = null, onHash, opts = {}) {
293
+ this.sent = false;
294
+ const f = await this.fees();
295
+ this.feeCheck(f);
296
+ let maxFee = f.maxFeePerGas, prio = f.maxPriorityFeePerGas;
297
+ if (opts.minFee && maxFee < opts.minFee) maxFee = opts.minFee;
298
+ if (opts.minPrio && prio < opts.minPrio) prio = opts.minPrio;
299
+ if (prio > maxFee) maxFee = prio;
300
+ const g = gas || (await this.estimate(to, data, value));
301
+ const nonce = opts.nonce !== undefined ? opts.nonce : await this.nonceFor();
302
+ const need = g * maxFee + value;
303
+ const have = await this.rpc.balance(this.feeAddress);
304
+ if (have < need) throw new Error(`the gas key needs about ${formatUnits(need, 18)} ${this.chain.symbol} for this step and holds ${formatUnits(have, 18)}. Send a little ${this.chain.symbol} on ${this.chain.name} to ${this.feeAddress}`);
305
+ const { raw, hash } = signTx1559({ chainId: this.id, nonce, maxPriorityFeePerGas: prio, maxFeePerGas: maxFee, gas: g, to, value, data }, this.fee);
306
+ this.sent = true;
307
+ this.lastTx = { nonce, maxFee: maxFee.toString(), prio: prio.toString() };
308
+ if (opts.onSigned) await opts.onSigned(this.lastTx);
309
+ const same = (x) => typeof x === "string" && lower(x) === lower(hash);
310
+ let accepted = false, wrong = false;
311
+ try { const got = await this.rpc.call("eth_sendRawTransaction", [raw]); if (same(got)) accepted = true; else wrong = true; } catch {}
312
+ if (this.check) { try { if (same(await this.check.call("eth_sendRawTransaction", [raw]))) accepted = true; } catch {} }
313
+ if (!accepted || (wrong && !this.check)) { const e = new Error("no network source accepted the transaction"); e.broadcast = true; e.hash = hash; e.pending = true; throw e; }
314
+ this.nextNonce = nonce + 1;
315
+ if (onHash) { try { onHash(hash); } catch {} }
316
+ return this.wait(hash);
317
+ }
318
+
319
+ async nonceFor() {
320
+ return Math.max(Number(await this.rpc.call("eth_getTransactionCount", [this.feeAddress, "pending"])), this.nextNonce || 0);
321
+ }
322
+
323
+ async install(deployer, data, expect, onHash) {
324
+ await this.checkNetwork();
325
+ return this.lock(async () => {
326
+ if ((await this.rpc.code(expect)) !== "0x") return { hash: null, nothing: true };
327
+ await this.idle();
328
+ const r = await this.record();
329
+ if (r && r.pending) await this.resolvePending(r);
330
+ const gas = await this.estimate(deployer, data);
331
+ const res = await this.sendTx(deployer, data, 0n, gas, onHash);
332
+ if ((await this.rpc.code(expect)) === "0x") throw new Error("the contract install did not land; check the explorer and try again");
333
+ return res;
334
+ });
335
+ }
336
+
337
+ async waiting() {
338
+ for (let i = 0; i < 3; i++) {
339
+ let stuck = false;
340
+ for (const rpc of this.check ? [this.rpc, this.check] : [this.rpc]) {
341
+ const [p, l] = await Promise.all(["pending", "latest"].map((t) => rpc.call("eth_getTransactionCount", [this.feeAddress, t])));
342
+ if (Number(p) > Number(l)) stuck = true;
343
+ }
344
+ if (!stuck) return false;
345
+ await new Promise((r) => setTimeout(r, 1500));
346
+ }
347
+ return true;
348
+ }
349
+
350
+ async idle() {
351
+ if (await this.waiting()) { const e = new Error("a transaction from your gas key is still waiting in the network. Wait until it confirms, or use Unstick on the gas key line. Nothing new was signed"); e.gasStuck = true; throw e; }
352
+ }
353
+
354
+ async unstick(onHash) {
355
+ await this.checkNetwork();
356
+ return this.lock(async () => {
357
+ if (!(await this.waiting())) return { hash: null, nothing: true };
358
+ const latest = Number(await this.rpc.call("eth_getTransactionCount", [this.feeAddress, "latest"]));
359
+ const f = await this.fees();
360
+ this.cap = null;
361
+ return this.sendTx(this.feeAddress, new Uint8Array(0), 0n, 21000n, onHash, { nonce: latest, minFee: f.maxFeePerGas * 3n, minPrio: f.maxPriorityFeePerGas * 3n });
362
+ });
363
+ }
364
+
365
+ async wait(hash, ms = 120000) {
366
+ const end = Date.now() + ms;
367
+ let a = null, b = null;
368
+ while (Date.now() < end) {
369
+ a = a || (await this.rpc.call("eth_getTransactionReceipt", [hash]).catch(() => null));
370
+ b = !this.check ? a : b || (await this.check.call("eth_getTransactionReceipt", [hash]).catch(() => null));
371
+ if (a && b) {
372
+ this.minBlock = Math.max(this.minBlock, Number(a.blockNumber));
373
+ const okA = a.status === "0x1", okB = b.status === "0x1";
374
+ if (okA && okB) return { hash, gasUsed: BigInt(a.gasUsed) };
375
+ if (!okA && !okB) { const e = new Error(`the transaction failed on chain (${hash})`); e.hash = hash; e.reverted = true; e.broadcast = true; throw e; }
376
+ break;
377
+ }
378
+ await new Promise((r) => setTimeout(r, 1500));
379
+ }
380
+ const e = new Error(`the transaction was sent but the two network sources have not both confirmed it. Check it on the explorer: ${hash}`);
381
+ e.hash = hash;
382
+ e.pending = true;
383
+ e.broadcast = true;
384
+ throw e;
385
+ }
386
+
387
+ async setup(progress, step = () => {}) {
388
+ await this.checkNetwork();
389
+ return this.lock(async () => {
390
+ if ((await this.rpc.code(E.CREATE2_DEPLOYER)) === "0x") throw new Error("this network has no standard CREATE2 deployer, so OPEQ cannot be set up here");
391
+ const fcode = await this.rpc.code(this.factory);
392
+ if (fcode === "0x") {
393
+ step("deploying the OPEQ factory on this network");
394
+ await this.sendTx(E.CREATE2_DEPLOYER, E.factoryDeployData());
395
+ } else if (lower(fcode.slice(2)) !== lower(FACTORY_RUNTIME)) throw new Error("the code at the OPEQ factory address is not the expected factory, refusing to continue");
396
+ let st = await this.chainState();
397
+ if (!st.deployed) {
398
+ step("creating your account contract");
399
+ await this.sendTx(this.factory, E.encodeCreate(this.identity.pubSeed, this.identity.root));
400
+ st = await this.chainState();
401
+ if (!st.deployed) throw new Error("the account contract did not appear at the expected address");
402
+ }
403
+ let boot = null;
404
+ if (st.epoch === 0) {
405
+ await this.agree(st, ["deployed", "epoch"]);
406
+ await this.gasCheck(REKEY_GAS * 2n);
407
+ step("building the key tree for this network");
408
+ boot = await this.run("evmBoot", { chainId: this.id, account: this.account }, progress && ((p) => progress(p * 0.5)));
409
+ step("moving the account off its identity key");
410
+ const gas = await this.gasFor(boot.calldata.length, boot.cost, [], REKEY_EXTRA);
411
+ try { await this.sendTx(this.account, boot.calldata, 0n, gas); }
412
+ catch (e) { if (!e.reverted || (await this.chainState()).epoch === 0) throw e; }
413
+ st = await this.chainState();
414
+ }
415
+ if (st.epoch === 1 && st.pubSeed === this.bootPubSeed()) {
416
+ step("moving the account to a key tree only this device knows");
417
+ await this._takeover(progress && ((p) => progress(0.5 + p * 0.5)), null, boot);
418
+ }
419
+ return this.status();
420
+ });
421
+ }
422
+
423
+ async adopt(s) {
424
+ await this.put({ id: this.key, epoch: s.r.epoch + 1, next_leaf: 0, pub_seed: s.next.pub_seed, root: s.next.root, leaves: s.next.leaves, active: true });
425
+ }
426
+
427
+ async sign(fn) {
428
+ await this.checkNetwork();
429
+ return this.lock(async () => {
430
+ let s = await this.status();
431
+ if (s.state === "adopt") { await this.agree(s.st, ["epoch", "pubSeed", "root"]); await this.adopt(s); s = await this.status(); }
432
+ if (s.state !== "ready") throw new Error(s.state === "setup" ? "set up this network first" : "this copy of the wallet is not signing on this network. Choose Use on this device first");
433
+ return fn(s);
434
+ });
435
+ }
436
+
437
+ async gasSurplus() {
438
+ const [f, fee] = await Promise.all([this.fees(), this.rpc.balance(this.feeAddress)]);
439
+ const per = (EXEC_GAS * f.maxFeePerGas * 3n) / 2n;
440
+ this.surplusFee = f.maxFeePerGas;
441
+ const amount = fee - per * TOPUP_TARGET - SWEEP_GAS * f.maxFeePerGas;
442
+ return amount > per * TOPUP_TARGET ? amount : 0n;
443
+ }
444
+
445
+ async sweepGas(onHash) {
446
+ await this.checkNetwork();
447
+ return this.lock(async () => {
448
+ await this.idle();
449
+ const amount = await this.gasSurplus();
450
+ if (!amount) throw new Error("the gas key holds no more than it needs for future fees");
451
+ if (!this.cap) this.cap = this.surplusFee * 2n;
452
+ return this.sendTx(this.account, new Uint8Array(0), amount, SWEEP_GAS, onHash);
453
+ });
454
+ }
455
+
456
+ async topupPlan(spend) {
457
+ const [f, [fee, main]] = await Promise.all([this.fees(), this.at((b) => Promise.all([this.rpc.balance(this.feeAddress, b), this.rpc.balance(this.account, b)]))]);
458
+ this.feeCheck(f);
459
+ const seen = this.check ? await this.check.balance(this.account).catch(() => 0n) : main;
460
+ const bal = seen < main ? seen : main;
461
+ const per = (EXEC_GAS * f.maxFeePerGas * 3n) / 2n;
462
+ if (fee >= per * TOPUP_LOW) return 0n;
463
+ const free = bal - spend;
464
+ let amount = per * TOPUP_TARGET - fee;
465
+ if (amount > free / 4n) amount = free / 4n;
466
+ return amount >= per ? amount : 0n;
467
+ }
468
+
469
+ feeCheck(f) {
470
+ if (this.cap && f.maxFeePerGas > this.cap) throw new Error("the network fee more than doubled since you confirmed. Nothing was signed; try again to see the new fee");
471
+ }
472
+
473
+ async gasCheck(gas) {
474
+ const [f, have] = await Promise.all([this.fees(), this.rpc.balance(this.feeAddress)]);
475
+ this.feeCheck(f);
476
+ const need = (gas * f.maxFeePerGas * 3n) / 2n;
477
+ if (have < need) throw new Error(`the gas key needs about ${formatUnits(need, 18)} ${this.chain.symbol} and holds ${formatUnits(have, 18)}. Send a little ${this.chain.symbol} on ${this.chain.name} to ${this.feeAddress}. Nothing was signed`);
478
+ }
479
+
480
+ async reserve(r, st, limit) {
481
+ const leaf = Math.max(r.next_leaf, st.next);
482
+ if (leaf >= limit) throw new Error(limit === E.EXEC_LIMIT ? "this key tree is used up for transactions; rekey first" : "no keys left on this tree");
483
+ r.next_leaf = leaf + 1;
484
+ await this.put(r);
485
+ return leaf;
486
+ }
487
+
488
+ async cancelPending(onHash) {
489
+ return this.execute([], onHash, true);
490
+ }
491
+
492
+ async execute(calls, onHash, cancel = false, limits = {}) {
493
+ return this.sign(async ({ r, st }) => {
494
+ if (cancel && !r.pending) return { hash: null, gasUsed: 0n, nothing: true };
495
+ if (cancel) {
496
+ const { a, b } = await this.seenBoth();
497
+ const gone = (x) => x && (x.epoch !== r.epoch || x.next > r.pending.leaf);
498
+ if (gone(a) && gone(b)) { delete r.pending; await this.put(r); return { hash: null, gasUsed: 0n, nothing: true, landed: true }; }
499
+ }
500
+ if (!cancel) { await this.resolvePending(r); await this.idle(); }
501
+ const inner = limits.inner !== undefined ? limits.inner : await this.callsGas(calls);
502
+ if (limits.maxInner !== undefined && inner > limits.maxInner) throw new Error("this transaction now needs more gas than the fee you confirmed. Nothing was signed; check the proposal and try again");
503
+ await this.gasCheck(EXEC_GAS + inner);
504
+ const nowS = await this.clock();
505
+ const deadline = limits.deadline !== undefined ? Math.min(nowS + DEADLINE_S, Number(limits.deadline)) : nowS + DEADLINE_S;
506
+ if (deadline - nowS < 90) throw new Error("this transaction's time limit is about to run out. Nothing was signed; review it again");
507
+ const leaf = Math.max(r.next_leaf, st.next);
508
+ const prev = cancel ? r.pending : null;
509
+ const opts = {};
510
+ let mined = false;
511
+ if (prev && prev.nonce !== undefined) {
512
+ for (const rpc of this.check ? [this.rpc, this.check] : [this.rpc]) if (Number(await rpc.call("eth_getTransactionCount", [this.feeAddress, "latest"])) > prev.nonce) mined = true;
513
+ }
514
+ if (prev && prev.nonce !== undefined && !mined) {
515
+ opts.nonce = prev.nonce;
516
+ opts.minFee = (BigInt(prev.maxFee || "0") * 5n) / 4n + 1n;
517
+ opts.minPrio = (BigInt(prev.prio || "0") * 5n) / 4n + 1n;
518
+ }
519
+ r.pending = { leaf, deadline, ...(prev ? { prev } : {}) };
520
+ await this.reserve(r, st, E.EXEC_LIMIT);
521
+ const rnd = crypto.getRandomValues(new Uint8Array(16));
522
+ const mine = (p) => p && p.leaf === leaf;
523
+ const done = async () => { const cur = await this.record(); if (cur && mine(cur.pending)) { delete cur.pending; await this.put(cur); } };
524
+ const unsent = async () => { const cur = await this.record(); if (cur && mine(cur.pending)) { if (prev) cur.pending = prev; else delete cur.pending; await this.put(cur); } };
525
+ opts.onSigned = async (tx) => { const cur = await this.record(); if (cur && mine(cur.pending)) { cur.pending = { ...cur.pending, nonce: tx.nonce, maxFee: tx.maxFee, prio: tx.prio }; await this.put(cur); } };
526
+ let res;
527
+ this.sent = false;
528
+ try {
529
+ const out = await this.run("evmSignExec", { chainId: this.id, account: this.account, epoch: r.epoch, pubSeed: hexToBytes(r.pub_seed), root: hexToBytes(r.root), leaves: unb64(r.leaves), leaf, deadline, rnd, calls });
530
+ const gas = await this.gasFor(out.calldata.length, out.cost, [], inner);
531
+ res = await this.sendTx(this.account, out.calldata, 0n, gas, onHash, opts);
532
+ } catch (e) {
533
+ if (!this.sent && !e.broadcast) { await unsent(); throw e; }
534
+ const { a, b } = await this.seenBoth().catch(() => ({}));
535
+ if (a && b && a.epoch === r.epoch && b.epoch === r.epoch && a.next > leaf && b.next > leaf) { await done(); return { hash: e.hash, gasUsed: 0n, byOther: true }; }
536
+ if (e.reverted && limits.settled && (await limits.settled().catch(() => false))) { await done(); e.message = `${e.message}. ${limits.settledText || "It can never go through now, so nothing stays pending"}`; throw e; }
537
+ const t = this.pendingText({ deadline }, true);
538
+ e.message = `${e.message}. ${t[0].toUpperCase()}${t.slice(1)}`;
539
+ throw e;
540
+ }
541
+ await done();
542
+ return res;
543
+ });
544
+ }
545
+
546
+ async rekey(progress, onHash) {
547
+ return this.sign(async ({ r, st }) => {
548
+ await this.idle();
549
+ await this.gasCheck(REKEY_GAS);
550
+ const leaf = await this.reserve(r, st, TAKEOVER_LO);
551
+ return this._rekey(r, leaf, hexToBytes(r.pub_seed), hexToBytes(r.root), unb64(r.leaves), progress, onHash);
552
+ });
553
+ }
554
+
555
+ async takeover(progress, onHash) {
556
+ await this.checkNetwork();
557
+ return this.lock(async () => {
558
+ const s = await this.status();
559
+ if (s.state === "ready") return s;
560
+ if (s.state === "setup") throw new Error("set up this network first");
561
+ if (s.state === "adopt") { await this.agree(s.st, ["epoch", "pubSeed", "root"]); await this.adopt(s); return this.status(); }
562
+ await this._takeover(progress, onHash, null);
563
+ return this.status();
564
+ });
565
+ }
566
+
567
+ async _takeover(progress, onHash, boot) {
568
+ const st = await this.chainState();
569
+ if (st.next > E.EXEC_LIMIT) throw new Error("the network reports an impossible key counter for your account, so nothing was signed");
570
+ await this.agree(st, ["deployed", "epoch", "pubSeed", "root", "next"]);
571
+ await this.idle();
572
+ await this.gasCheck(REKEY_GAS);
573
+ const cur = boot && bytesToHex(boot.pubSeed) === st.pubSeed ? { root: boot.root, leaves: boot.leaves }
574
+ : await this.run("evmTree", { chainId: this.id, account: this.account, epoch: st.epoch, pubSeed: hexToBytes(st.pubSeed) }, progress && ((p) => progress(p * 0.5)));
575
+ if (bytesToHex(cur.root) !== st.root) throw new Error("these words do not control the key tree on chain, refusing to sign");
576
+ const old = await this.record();
577
+ if (old && old.epoch > st.epoch) throw new Error("this browser has a newer key tree than the chain, refusing to go back");
578
+ const mine = old && old.epoch === st.epoch && old.pub_seed === st.pubSeed ? old : null;
579
+ const used = new Set(mine ? mine.used || [] : []);
580
+ const free = [];
581
+ for (let i = Math.max(st.next, TAKEOVER_LO); i < E.LEAVES; i++) if (!used.has(i)) free.push(i);
582
+ if (!free.length) throw new Error("this copy has used all its takeover keys on the current tree. Rekey from the device that signs now, or wait for a pending takeover to confirm");
583
+ const leaf = free[crypto.getRandomValues(new Uint32Array(1))[0] % free.length];
584
+ const r = { id: this.key, epoch: st.epoch, next_leaf: mine ? mine.next_leaf : 0, pub_seed: st.pubSeed, root: st.root, leaves: b64(cur.leaves), active: false, used: [...used, leaf], rekeys: mine ? mine.rekeys || [] : [] };
585
+ await this.put(r);
586
+ return this._rekey(r, leaf, hexToBytes(st.pubSeed), hexToBytes(st.root), cur.leaves, progress && ((p) => progress(0.5 + p * 0.5)), onHash);
587
+ }
588
+
589
+ async _rekey(r, leaf, pubSeed, root, leaves, progress, onHash) {
590
+ const f = await this.fees();
591
+ const newPubSeed = crypto.getRandomValues(new Uint8Array(16));
592
+ const rnd = crypto.getRandomValues(new Uint8Array(16));
593
+ const out = await this.run("evmSignRekey", { chainId: this.id, account: this.account, epoch: r.epoch, pubSeed, root, leaves, leaf, deadline: f.now + DEADLINE_S, rnd, newPubSeed }, progress);
594
+ r.rekeys = [...(r.rekeys || []), { pub_seed: bytesToHex(newPubSeed), root: bytesToHex(out.newRoot), leaves: b64(out.newLeaves) }].slice(-PENDING_REKEYS);
595
+ await this.put(r);
596
+ const gas = await this.gasFor(out.calldata.length, out.cost, [], REKEY_EXTRA);
597
+ const res = await this.sendTx(this.account, out.calldata, 0n, gas, onHash);
598
+ const s = await this.status();
599
+ if (s.state === "adopt") { await this.agree(s.st, ["epoch", "pubSeed", "root"]); await this.adopt(s); }
600
+ return res;
601
+ }
602
+
603
+ async token(addr) {
604
+ const a = checkAddress(addr);
605
+ const code = await this.rpc.code(a);
606
+ if (code === "0x") throw new Error("there is no contract at that token address on this network");
607
+ const [dec, sym, bal] = await Promise.all([
608
+ this.rpc.ethCall(a, SEL.decimals),
609
+ this.rpc.ethCall(a, SEL.symbol).catch(() => "0x"),
610
+ this.rpc.ethCall(a, SEL.balanceOf + "000000000000000000000000" + this.account.slice(2)),
611
+ ]);
612
+ const decimals = Number(word(dec));
613
+ if (this.check) {
614
+ const again = await this.check.ethCall(a, SEL.decimals).catch(() => null);
615
+ if (again === null || Number(word(again)) !== decimals) throw new Error("two independent network sources disagree about this token's decimals, so the amount could be wrong. Nothing was signed");
616
+ }
617
+ if (!(decimals >= 0 && decimals <= 36)) throw new Error("that contract does not look like an ERC-20 token");
618
+ return { address: a, decimals, symbol: decodeSymbol(sym), balance: word(bal) };
619
+ }
620
+
621
+ async tokenBalance(a) {
622
+ return word(await this.rpc.ethCall(a, SEL.balanceOf + "000000000000000000000000" + this.account.slice(2)));
623
+ }
624
+ }
625
+
626
+ function decodeSymbol(h) {
627
+ try {
628
+ const b = hexToBytes(h);
629
+ let s;
630
+ if (b.length >= 96) { const n = Number(BigInt(bytesToHex(b.slice(32, 64)))); s = new TextDecoder().decode(b.slice(64, 64 + Math.min(n, 32))); }
631
+ else if (b.length === 32) s = new TextDecoder().decode(b).replace(/\0+$/, "");
632
+ else return "token";
633
+ s = s.replace(/[^A-Za-z0-9.$_-]/g, "").slice(0, 11);
634
+ return s || "token";
635
+ } catch { return "token"; }
636
+ }