@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.
- package/LICENSE +191 -0
- package/README.md +123 -2
- package/examples/evm-send.mjs +27 -0
- package/examples/solana-send.mjs +34 -0
- package/examples/verify.mjs +29 -0
- package/filestore.js +149 -0
- package/index.js +384 -0
- package/locks.js +28 -0
- package/package.json +17 -4
- package/src/feekey.js +36 -0
- package/src/lib/compute.js +73 -0
- package/src/lib/engine.js +639 -0
- package/src/lib/evmcode.js +4 -0
- package/src/lib/evmtx.js +62 -0
- package/src/lib/evmwallet.js +636 -0
- package/src/lib/evmwots.js +322 -0
- package/src/lib/keccak.js +165 -0
- package/src/lib/mnemonic.js +80 -0
- package/src/lib/protocol.js +129 -0
- package/src/lib/secp256k1.js +123 -0
- package/src/lib/sha256.js +120 -0
- package/src/lib/solana.js +264 -0
- package/src/lib/vaultstore.js +133 -0
- package/src/lib/wordlist.js +1 -0
- package/src/lib/wots.js +128 -0
|
@@ -0,0 +1,639 @@
|
|
|
1
|
+
import * as M from "./mnemonic.js";
|
|
2
|
+
import * as P from "./protocol.js";
|
|
3
|
+
import * as V from "./vaultstore.js";
|
|
4
|
+
import { LEAVES, PATH_LEN, SIG_LEN, verify as wotsVerify } from "./wots.js";
|
|
5
|
+
import {
|
|
6
|
+
COMPUTE_BUDGET, SYSTEM, b58decode, b58encode, compileV0, concat, eq, hex, pubkey, u32, u64, unhex, unsignedTx,
|
|
7
|
+
} from "./solana.js";
|
|
8
|
+
|
|
9
|
+
export const EXPIRY_SLOTS = 150;
|
|
10
|
+
export const RESTORE_GAP = 64;
|
|
11
|
+
export const TAKEOVER_FLOOR = 768;
|
|
12
|
+
export const SIGN_LIMIT = TAKEOVER_FLOOR;
|
|
13
|
+
export const EXEC_LIMIT = TAKEOVER_FLOOR - RESTORE_GAP - 16;
|
|
14
|
+
export const TX_LIMIT = 1232;
|
|
15
|
+
export const BASE_FEE = 5000;
|
|
16
|
+
const RENT_MIN = 1000000, RENT_MAX = 2000000;
|
|
17
|
+
const CONFIRM_MS = 90000;
|
|
18
|
+
const HISTORY_PAGES = 100;
|
|
19
|
+
const FALLBACK_MS = 30 * 60 * 1000;
|
|
20
|
+
const FAILED_MAX = 3000;
|
|
21
|
+
|
|
22
|
+
const b64 = (b) => btoa(String.fromCharCode(...b));
|
|
23
|
+
const rd32 = (d, o) => (d[o] | (d[o + 1] << 8) | (d[o + 2] << 16) | (d[o + 3] << 24)) >>> 0;
|
|
24
|
+
const rd64 = (d, o) => { let v = 0n; for (let i = 7; i >= 0; i--) v = (v << 8n) | BigInt(d[o + i]); return v; };
|
|
25
|
+
|
|
26
|
+
export function signedLeaf(d, keys, program, cur) {
|
|
27
|
+
const SIG_END = P.SIG_OFF + SIG_LEN, PATH_END = SIG_END + PATH_LEN;
|
|
28
|
+
if (d.length < PATH_END || (d[0] !== P.IX_EXEC && d[0] !== P.IX_REKEY)) return null;
|
|
29
|
+
const leaf = rd32(d, 1);
|
|
30
|
+
if (leaf >= LEAVES) return null;
|
|
31
|
+
const expiry = rd64(d, 5), salt = rd32(d, P.SALT_OFF), r = d.slice(P.R_OFF, P.R_OFF + P.R_LEN);
|
|
32
|
+
const sig = d.slice(P.SIG_OFF, SIG_END), path = d.slice(SIG_END, PATH_END);
|
|
33
|
+
let h;
|
|
34
|
+
if (d[0] === P.IX_REKEY) {
|
|
35
|
+
if (d.length !== PATH_END + 32) return null;
|
|
36
|
+
h = P.rekeyHasher(program, cur.aid, cur.epoch, leaf, expiry, d.slice(PATH_END, PATH_END + 16), d.slice(PATH_END + 16, PATH_END + 32), r);
|
|
37
|
+
} else {
|
|
38
|
+
const key = (i) => { if (i >= keys.length) throw new Error("bad key index"); return b58decode(keys[i]); };
|
|
39
|
+
let o = PATH_END;
|
|
40
|
+
const count = d[o++];
|
|
41
|
+
const inner = [];
|
|
42
|
+
for (let k = 0; k < count; k++) {
|
|
43
|
+
const prog = key(d[o++]), ml = d[o++], metas = [];
|
|
44
|
+
for (let j = 0; j < ml; j++) { const idx = d[o++], fl = d[o++]; metas.push([key(idx), !!(fl & P.F_SIGNER), !!(fl & P.F_WRITABLE)]); }
|
|
45
|
+
const dl = d[o] | (d[o + 1] << 8);
|
|
46
|
+
o += 2;
|
|
47
|
+
if (o + dl > d.length) return null;
|
|
48
|
+
inner.push([prog, metas, d.slice(o, o + dl)]);
|
|
49
|
+
o += dl;
|
|
50
|
+
}
|
|
51
|
+
if (o !== d.length) return null;
|
|
52
|
+
h = P.execHasher(program, cur.aid, cur.epoch, leaf, expiry, inner, r);
|
|
53
|
+
}
|
|
54
|
+
return wotsVerify(cur.pubSeed, cur.root, leaf, P.salted(h, salt), sig, path) ? leaf : null;
|
|
55
|
+
}
|
|
56
|
+
const unb64 = (s) => Uint8Array.from(atob(s), (c) => c.charCodeAt(0));
|
|
57
|
+
|
|
58
|
+
export class Failed extends Error {}
|
|
59
|
+
export class Inactive extends Error {}
|
|
60
|
+
|
|
61
|
+
function describe(err) {
|
|
62
|
+
const logs = (err.data && err.data.logs) || [];
|
|
63
|
+
const m = /custom program error: (0x[0-9a-f]+)/.exec(err.message + logs.join("\n"));
|
|
64
|
+
const deepest = logs.map((l) => /Program (\w+) failed: custom program error: (0x[0-9a-f]+)/.exec(l)).find(Boolean);
|
|
65
|
+
let msg = err.message;
|
|
66
|
+
if (deepest) msg += ` (${deepest[1].slice(0, 6)}… error ${parseInt(deepest[2], 16)})`;
|
|
67
|
+
else if (m) msg += ` (error ${parseInt(m[1], 16)})`;
|
|
68
|
+
return { msg, logs, code: deepest ? parseInt(deepest[2], 16) : null, program: deepest ? deepest[1] : null };
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export class Engine {
|
|
72
|
+
constructor({ rpc, program, store, compute, payer }) {
|
|
73
|
+
this.rpc = rpc;
|
|
74
|
+
this.program = typeof program === "string" ? pubkey(program) : program;
|
|
75
|
+
this.store = store;
|
|
76
|
+
this.compute = compute;
|
|
77
|
+
this.refRpc = null;
|
|
78
|
+
this.payer = payer;
|
|
79
|
+
this.priceFor = null;
|
|
80
|
+
this.maxPriority = 100000;
|
|
81
|
+
this.genesis = null;
|
|
82
|
+
this._genesisOk = false;
|
|
83
|
+
this.log = () => {};
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
async create(password, progress) {
|
|
87
|
+
const master = M.newMaster();
|
|
88
|
+
const t = await this.compute.run("treeFor", { master, epoch: 0, program: this.program }, progress);
|
|
89
|
+
const aid = P.accountId(t.pubSeed, t.root);
|
|
90
|
+
const record = await this._record(master, password, aid, 0, t, 0, true);
|
|
91
|
+
return { record, words: M.toWords(master) };
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
async recover(words, password, progress, existing = null) {
|
|
95
|
+
const master = M.fromWords(words);
|
|
96
|
+
const t0 = await this.compute.run("treeFor", { master, epoch: 0, program: this.program }, progress && ((p) => progress(p * 0.5)));
|
|
97
|
+
const aid = P.accountId(t0.pubSeed, t0.root);
|
|
98
|
+
const acc = await this.rpc.account(P.statePda(aid, this.program).key);
|
|
99
|
+
let epoch = 0, next = 0, t = t0;
|
|
100
|
+
if (acc) {
|
|
101
|
+
if (!eq(acc.owner, this.program)) throw new Error("state account is not owned by the opeq program");
|
|
102
|
+
const st = P.unpackState(acc.data);
|
|
103
|
+
epoch = st.epoch;
|
|
104
|
+
next = existing && existing.aid && existing.epoch === epoch ? Math.max(existing.next_leaf, st.next) : st.next;
|
|
105
|
+
if (epoch > 0) t = await this.compute.run("treeFor", { master, epoch, program: this.program, pubSeed: st.pubSeed }, progress && ((p) => progress(0.5 + p * 0.5)));
|
|
106
|
+
if (!eq(t.root, st.root)) throw new Error("these words do not control this wallet's current tree");
|
|
107
|
+
}
|
|
108
|
+
if (existing && existing.epoch > epoch) throw new Error("this browser already holds a newer copy of this wallet");
|
|
109
|
+
const keep = {};
|
|
110
|
+
if (existing && existing.epoch === epoch) for (const k of ["takeover", "inflight", "rekeying"]) if (existing[k]) keep[k] = existing[k];
|
|
111
|
+
const same = !!(existing && existing.epoch === epoch && existing.root === hex(t.root) && existing.pub_seed === hex(t.pubSeed));
|
|
112
|
+
return this._record(master, password, aid, epoch, t, next, same && existing.active === true, keep);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
replace(id, words, password, progress) {
|
|
116
|
+
return V.withLock(`opeq:${id}`, async () => {
|
|
117
|
+
const rec = await this.recover(words, password, progress, await this.store.get(id));
|
|
118
|
+
await this.save(rec);
|
|
119
|
+
return rec;
|
|
120
|
+
});
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
remove(id) {
|
|
124
|
+
return V.withLock(`opeq:${id}`, async () => {
|
|
125
|
+
const r = await this.store.get(id);
|
|
126
|
+
if (r && (r.rekeying || r.inflight)) {
|
|
127
|
+
if (r.rekeying && !(await this.passed(Number(r.rekeying.expiry), Number(r.rekeying.at) || 0))) throw new Error("a rekey or takeover from this browser may still land; wait about a minute, then remove");
|
|
128
|
+
if (r.inflight && !(await this.passed(Number(r.inflight.expiry), Number(r.inflight.at) || 0))) throw new Error("a transaction from this browser may still land; wait about a minute, then remove");
|
|
129
|
+
}
|
|
130
|
+
await this.store.del(id);
|
|
131
|
+
});
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
async _record(master, password, aid, epoch, t, next, active = true, keep = {}) {
|
|
135
|
+
const { blob, macKey } = await V.seal(master, password, aid);
|
|
136
|
+
const wallet = P.walletPda(aid, this.program).key;
|
|
137
|
+
const record = {
|
|
138
|
+
id: b58encode(wallet), v: 1, program: b58encode(this.program), aid: hex(aid), epoch, next_leaf: next,
|
|
139
|
+
pub_seed: hex(t.pubSeed), root: hex(t.root), leaves: b64(t.leaves), enc: blob, created: Date.now(), active, ...keep,
|
|
140
|
+
};
|
|
141
|
+
return V.signRecord(macKey, record);
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
async passed(expiry, at) {
|
|
145
|
+
const a = await this.rpc.slot();
|
|
146
|
+
if (!this.refRpc) return a > expiry + 2;
|
|
147
|
+
try { return Math.min(a, await this.refRpc.slot()) > expiry + 2; }
|
|
148
|
+
catch { return a > expiry + 2 && at > 0 && Date.now() - at > FALLBACK_MS; }
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
async save(record) { await this.store.put(record); }
|
|
152
|
+
|
|
153
|
+
async unlock(record, password) {
|
|
154
|
+
const aid = unhex(record.aid);
|
|
155
|
+
const { master, macKey, macRaw } = await V.unlock(record.enc, password, aid);
|
|
156
|
+
await V.checkRecord(macKey, record);
|
|
157
|
+
if (record.program !== b58encode(this.program)) throw new Error("this wallet belongs to a different opeq program");
|
|
158
|
+
const seeds = M.treeSeeds(master, record.epoch, this.program, unhex(record.pub_seed));
|
|
159
|
+
if (hex(seeds.pubSeed) !== record.pub_seed) throw new Error("wallet tree does not match its master seed");
|
|
160
|
+
const session = new Session(this, record.id, master, macKey);
|
|
161
|
+
session.macRaw = macRaw;
|
|
162
|
+
return session;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
initIx(record) {
|
|
166
|
+
const aid = unhex(record.aid);
|
|
167
|
+
return async () => {
|
|
168
|
+
const lamports = await this.rpc.rent(P.STATE_LEN);
|
|
169
|
+
if (!Number.isSafeInteger(lamports) || lamports < RENT_MIN || lamports > RENT_MAX) throw new Error("the RPC returned an implausible rent amount, refusing to activate");
|
|
170
|
+
return [this.program, [[this.payer.key(), true, true], [P.statePda(aid, this.program).key, false, true],
|
|
171
|
+
[pubkey(record.id), false, false], [SYSTEM, false, false]], P.buildInit(unhex(record.pub_seed), unhex(record.root), lamports)];
|
|
172
|
+
};
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
async init(record) {
|
|
176
|
+
await this.checkCluster();
|
|
177
|
+
const ix = await this.initIx(record)();
|
|
178
|
+
const slot = await this.rpc.slot();
|
|
179
|
+
try {
|
|
180
|
+
return await this.submit(ix, slot + EXPIRY_SLOTS, 60000, [], await this.price(60000, [this.payer.key()]));
|
|
181
|
+
} catch (e) {
|
|
182
|
+
const st = await this.chainState(record);
|
|
183
|
+
if (st && hex(st.root) === record.root) return "already-initialized";
|
|
184
|
+
throw e;
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
async checkCluster() {
|
|
189
|
+
if (!this.genesis || this._genesisOk) return;
|
|
190
|
+
const g = await this.rpc.call("getGenesisHash", []);
|
|
191
|
+
if (g !== this.genesis) throw new Error("the RPC is connected to a different Solana cluster than this wallet expects, refusing to sign");
|
|
192
|
+
this._genesisOk = true;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
async chainState(record) {
|
|
196
|
+
const acc = await this.rpc.account(P.statePda(unhex(record.aid), this.program).key);
|
|
197
|
+
if (!acc) return null;
|
|
198
|
+
if (!eq(acc.owner, this.program)) throw new Error("state account is not owned by the opeq program");
|
|
199
|
+
return P.unpackState(acc.data);
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
priceCap(cuLimit) {
|
|
203
|
+
return Math.floor((this.maxPriority * 1e6) / cuLimit);
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
async price(cuLimit, keys) {
|
|
207
|
+
if (!this.priceFor) return 0;
|
|
208
|
+
let p = 0;
|
|
209
|
+
try { p = await this.priceFor(cuLimit, keys); } catch { p = 0; }
|
|
210
|
+
if (!Number.isSafeInteger(p) || p < 0) return 0;
|
|
211
|
+
return Math.min(p, this.priceCap(cuLimit));
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
feeFor(cuLimit, price) {
|
|
215
|
+
return BASE_FEE + Math.ceil((price * cuLimit) / 1e6);
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
async payerCovers(lamports) {
|
|
219
|
+
const bal = await this.rpc.balance(this.payer.key());
|
|
220
|
+
if (!Number.isSafeInteger(bal) || bal < lamports) {
|
|
221
|
+
const f = new Failed(`the fee wallet cannot pay the network fee (it needs ${lamports} lamports, has ${bal}); nothing was signed`);
|
|
222
|
+
f.feeEmpty = true;
|
|
223
|
+
throw f;
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
budget(cuLimit, price) {
|
|
228
|
+
if (!Number.isSafeInteger(price) || price < 0 || price > this.priceCap(cuLimit)) throw new Error("priority fee out of range");
|
|
229
|
+
const out = [[COMPUTE_BUDGET, [], concat(new Uint8Array([2]), u32(cuLimit))]];
|
|
230
|
+
if (price) out.push([COMPUTE_BUDGET, [], concat(new Uint8Array([3]), u64(price))]);
|
|
231
|
+
return out;
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
txSize(ix, cuLimit, tables = []) {
|
|
235
|
+
const { message } = compileV0(this.payer.key(), [...this.budget(cuLimit, this.priceFor ? 1 : 0), ix], new Uint8Array(32), tables);
|
|
236
|
+
return unsignedTx(message, 1).length;
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
async submit(ix, expiry, cuLimit, tables = [], price = 0, verify = null) {
|
|
240
|
+
await this.checkCluster();
|
|
241
|
+
const { message, signers } = compileV0(this.payer.key(), [...this.budget(cuLimit, price), ix], await this.rpc.blockhash(), tables);
|
|
242
|
+
if (signers.length !== 1) throw new Error("unexpected extra signer");
|
|
243
|
+
const signed = await this.payer.sign(unsignedTx(message, 1));
|
|
244
|
+
if (signed.length > TX_LIMIT) throw new Failed(`transaction too large: ${signed.length} bytes`);
|
|
245
|
+
if (verify) {
|
|
246
|
+
try { await verify(signed); } catch (e) { const f = new Failed(e.message); f.sent = true; f.logs = e.logs; throw f; }
|
|
247
|
+
}
|
|
248
|
+
let sig;
|
|
249
|
+
try {
|
|
250
|
+
sig = await this.rpc.sendRaw(signed);
|
|
251
|
+
if (sig !== b58encode(signed.slice(1, 65))) throw new Error("the RPC returned a signature that is not this transaction's");
|
|
252
|
+
} catch (e) {
|
|
253
|
+
const d = describe(e);
|
|
254
|
+
const f = new Failed(d.msg);
|
|
255
|
+
f.code = d.code; f.program = d.program; f.logs = d.logs; f.sent = true;
|
|
256
|
+
throw f;
|
|
257
|
+
}
|
|
258
|
+
const started = Date.now();
|
|
259
|
+
for (;;) {
|
|
260
|
+
let st = null;
|
|
261
|
+
try { st = await this.rpc.status(sig); } catch { st = null; }
|
|
262
|
+
if (st && st.err) {
|
|
263
|
+
const f = new Failed(`transaction failed: ${JSON.stringify(st.err).slice(0, 200)}`);
|
|
264
|
+
f.sent = true;
|
|
265
|
+
throw f;
|
|
266
|
+
}
|
|
267
|
+
if (st && (st.confirmationStatus === "confirmed" || st.confirmationStatus === "finalized")) return sig;
|
|
268
|
+
let gone = false;
|
|
269
|
+
try { gone = await this.passed(expiry, started); } catch { gone = false; }
|
|
270
|
+
if (gone) {
|
|
271
|
+
let again = null;
|
|
272
|
+
try { again = await this.rpc.status(sig); } catch { again = null; }
|
|
273
|
+
if (again && !again.err && (again.confirmationStatus === "confirmed" || again.confirmationStatus === "finalized")) return sig;
|
|
274
|
+
const f = new Failed("transaction expired without landing");
|
|
275
|
+
f.sent = true; f.expired = true;
|
|
276
|
+
throw f;
|
|
277
|
+
}
|
|
278
|
+
if (Date.now() - started > CONFIRM_MS) {
|
|
279
|
+
const f = new Failed("could not confirm the transaction in time");
|
|
280
|
+
f.sent = true;
|
|
281
|
+
throw f;
|
|
282
|
+
}
|
|
283
|
+
await new Promise((r) => setTimeout(r, 1500));
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
export class Session {
|
|
289
|
+
constructor(engine, id, master, macKey) {
|
|
290
|
+
this.e = engine;
|
|
291
|
+
this.id = id;
|
|
292
|
+
this.master = master;
|
|
293
|
+
this.macKey = macKey;
|
|
294
|
+
this.wallet = pubkey(id);
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
async load() {
|
|
298
|
+
const r = await this.e.store.get(this.id);
|
|
299
|
+
if (!r) throw new Error("wallet not found in this browser");
|
|
300
|
+
await V.checkRecord(this.macKey, r);
|
|
301
|
+
if (r.program !== b58encode(this.e.program)) throw new Error("this wallet belongs to a different opeq program");
|
|
302
|
+
return r;
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
async put(r) {
|
|
306
|
+
await V.signRecord(this.macKey, r);
|
|
307
|
+
await this.e.store.put(r);
|
|
308
|
+
return r;
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
lock(fn, wait = false) {
|
|
312
|
+
return V.withLock(`opeq:${this.id}`, fn, { wait });
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
ownRekey(r, st) {
|
|
316
|
+
if (!r.rekeying || Number(r.rekeying.epoch) !== st.epoch) return false;
|
|
317
|
+
if (!M.chainSeeded(this.e.program, st.epoch)) return false;
|
|
318
|
+
return typeof r.rekeying.pub_seed === "string" && r.rekeying.pub_seed === hex(st.pubSeed);
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
async adopt(r, st) {
|
|
322
|
+
if (st.epoch === r.epoch) return r;
|
|
323
|
+
if (st.epoch < r.epoch) throw new Error("on-chain epoch is behind this wallet, refusing to sign");
|
|
324
|
+
const own = this.ownRekey(r, st);
|
|
325
|
+
if (!own) {
|
|
326
|
+
if (r.active) { r.active = false; await this.put(r); }
|
|
327
|
+
throw new Inactive("This wallet was rekeyed from another device or browser, so this copy stopped signing. To use it here, choose Use on this device.");
|
|
328
|
+
}
|
|
329
|
+
const t = await this.e.compute.run("treeFor", { master: this.master, epoch: st.epoch, program: this.e.program, pubSeed: st.pubSeed });
|
|
330
|
+
if (!eq(t.root, st.root)) throw new Error("on-chain tree is not derived from this wallet's words");
|
|
331
|
+
Object.assign(r, { epoch: st.epoch, next_leaf: 0, pub_seed: hex(t.pubSeed), root: hex(t.root), leaves: b64(t.leaves), active: true });
|
|
332
|
+
delete r.rekeying;
|
|
333
|
+
delete r.takeover;
|
|
334
|
+
delete r.inflight;
|
|
335
|
+
this.e.log(`wallet moved to tree ${st.epoch}`);
|
|
336
|
+
return this.put(r);
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
async preflight(isBurn = false) {
|
|
340
|
+
await this.e.checkCluster();
|
|
341
|
+
let r = await this.load();
|
|
342
|
+
const st = await this.e.chainState(r);
|
|
343
|
+
if (!st) throw new Error("wallet is not initialized on chain yet");
|
|
344
|
+
r = await this.adopt(r, st);
|
|
345
|
+
if (!r.active) throw new Inactive("This copy of the wallet is not active here yet. Choose Use on this device first.");
|
|
346
|
+
if (hex(st.root) !== r.root || hex(st.pubSeed) !== r.pub_seed) throw new Error("on-chain root differs from this wallet, refusing to sign");
|
|
347
|
+
if (r.inflight && !isBurn) {
|
|
348
|
+
const note = await this._ack(st);
|
|
349
|
+
if (note.state !== "expired" && note.state !== "landed") throw new Error(note.message);
|
|
350
|
+
}
|
|
351
|
+
return { r: await this.load(), st };
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
async status() {
|
|
355
|
+
const r = await this.load();
|
|
356
|
+
const st = await this.e.chainState(r);
|
|
357
|
+
if (!st) return { state: "uninitialized", r, st };
|
|
358
|
+
if (st.epoch > r.epoch && !this.ownRekey(r, st)) return { state: "inactive", r, st };
|
|
359
|
+
if (st.epoch === r.epoch && !r.active) return { state: "inactive", r, st };
|
|
360
|
+
return { state: "active", r, st };
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
async ackInflight(st) {
|
|
364
|
+
try {
|
|
365
|
+
return await this.lock(() => this._ack(st));
|
|
366
|
+
} catch (e) {
|
|
367
|
+
if (/busy/.test(e.message)) return { state: "busy" };
|
|
368
|
+
throw e;
|
|
369
|
+
}
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
async _ack(st) {
|
|
373
|
+
const r = await this.load();
|
|
374
|
+
const f = r.inflight;
|
|
375
|
+
if (!f) return { state: "none" };
|
|
376
|
+
const leaf = Number(f.leaf), expiry = Number(f.expiry);
|
|
377
|
+
if (st && st.epoch === r.epoch && st.next > leaf) {
|
|
378
|
+
delete r.inflight;
|
|
379
|
+
await this.put(r);
|
|
380
|
+
return { state: "landed", message: `an interrupted transaction (key ${leaf}) can no longer execute, but it may have gone through; check the balance before sending again` };
|
|
381
|
+
}
|
|
382
|
+
if (!(await this.e.passed(expiry, Number(f.at) || 0))) {
|
|
383
|
+
return { state: "pending", message: `an interrupted transaction (key ${leaf}) may still go through until slot ${expiry}; wait about a minute, refresh and check the balance before sending again` };
|
|
384
|
+
}
|
|
385
|
+
delete r.inflight;
|
|
386
|
+
await this.put(r);
|
|
387
|
+
return { state: "expired", message: `an interrupted transaction (key ${leaf}) expired without landing` };
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
async reserve(r, st, limit) {
|
|
391
|
+
const leaf = Math.max(r.next_leaf, st.next);
|
|
392
|
+
if (leaf >= limit) {
|
|
393
|
+
throw new Error(limit === EXEC_LIMIT ? "this key tree is used up for transactions; rekey now" : "no keys left for a rekey on this tree; choose Use on this device to move to a new tree");
|
|
394
|
+
}
|
|
395
|
+
r.next_leaf = leaf + 1;
|
|
396
|
+
await this.put(r);
|
|
397
|
+
return leaf;
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
exec(inner, cuLimit = 400000, progress, tables = [], verify = null) {
|
|
401
|
+
return this.lock(() => this._exec(inner, cuLimit, progress, true, tables, false, verify));
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
async _exec(inner, cuLimit, progress, burnOnFail, tables = [], isBurn = false, verify = null) {
|
|
405
|
+
const { r, st } = await this.preflight(isBurn);
|
|
406
|
+
const aid = unhex(r.aid);
|
|
407
|
+
const state = P.statePda(aid, this.e.program).key;
|
|
408
|
+
const { outer, metas } = P.outerLayout(state, this.wallet, inner);
|
|
409
|
+
const size = await this.execSize(inner, tables);
|
|
410
|
+
if (size > TX_LIMIT) throw new Failed(`this transaction would be ${size} bytes, over the ${TX_LIMIT} byte limit; nothing was signed`);
|
|
411
|
+
const price = await this.e.price(cuLimit, metas.filter((m) => m[2]).map((m) => m[0]));
|
|
412
|
+
await this.e.payerCovers(2 * this.e.feeFor(cuLimit, price));
|
|
413
|
+
const leaf = await this.reserve(r, st, isBurn ? SIGN_LIMIT : EXEC_LIMIT);
|
|
414
|
+
const expiry = (await this.e.rpc.slot()) + EXPIRY_SLOTS;
|
|
415
|
+
const prev = r.inflight;
|
|
416
|
+
r.inflight = prev ? { leaf: Math.min(Number(prev.leaf), leaf), expiry: Math.max(Number(prev.expiry), expiry), at: Date.now() } : { leaf, expiry, at: Date.now() };
|
|
417
|
+
await this.put(r);
|
|
418
|
+
let sig;
|
|
419
|
+
try {
|
|
420
|
+
const rnd = crypto.getRandomValues(new Uint8Array(P.R_LEN));
|
|
421
|
+
const s = await this.e.compute.run("signExec", {
|
|
422
|
+
master: this.master, epoch: r.epoch, leaves: unb64(r.leaves), pubSeed: unhex(r.pub_seed), leaf, program: this.e.program, aid, expiry, inner, r: rnd,
|
|
423
|
+
}, progress);
|
|
424
|
+
const data = P.buildExec(leaf, expiry, s.salt, rnd, s.sig, s.path, inner, outer);
|
|
425
|
+
this.e.log(`leaf ${leaf}, ${s.cost} on-chain hash steps`);
|
|
426
|
+
sig = await this.e.submit([this.e.program, metas, data], expiry, cuLimit, tables, price, verify);
|
|
427
|
+
} catch (err) {
|
|
428
|
+
if (!err.sent) {
|
|
429
|
+
const back = await this.load();
|
|
430
|
+
if (prev) back.inflight = prev; else delete back.inflight;
|
|
431
|
+
await this.put(back);
|
|
432
|
+
throw err;
|
|
433
|
+
}
|
|
434
|
+
let after = null;
|
|
435
|
+
try { after = await this.e.chainState(await this.load()); } catch { after = null; }
|
|
436
|
+
if (after && after.epoch === r.epoch && after.next > leaf) {
|
|
437
|
+
await this._clearIf(leaf);
|
|
438
|
+
const f = new Failed(`${err.message}. The wallet's key counter moved past this transaction, so it may have gone through; check the balance before sending again`);
|
|
439
|
+
f.maybeLanded = true;
|
|
440
|
+
throw f;
|
|
441
|
+
}
|
|
442
|
+
if (err.expired) {
|
|
443
|
+
await this._clearIf(leaf);
|
|
444
|
+
throw new Failed("the transaction expired without landing; it can no longer execute");
|
|
445
|
+
}
|
|
446
|
+
if (burnOnFail) await this.burnIfLive(leaf);
|
|
447
|
+
throw err;
|
|
448
|
+
}
|
|
449
|
+
await this._clearIf(leaf);
|
|
450
|
+
return sig;
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
async _clearIf(leaf) {
|
|
454
|
+
const r = await this.load();
|
|
455
|
+
if (!r.inflight) return;
|
|
456
|
+
const st = await this.e.chainState(r).catch(() => null);
|
|
457
|
+
if (Number(r.inflight.leaf) === leaf || (st && st.epoch === r.epoch && st.next > Number(r.inflight.leaf))) {
|
|
458
|
+
delete r.inflight;
|
|
459
|
+
await this.put(r);
|
|
460
|
+
}
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
async burnIfLive(leaf) {
|
|
464
|
+
const st = await this.e.chainState(await this.load());
|
|
465
|
+
if (st.next > leaf) return;
|
|
466
|
+
this.e.log(`burning key ${leaf} so the failed signature can never execute`);
|
|
467
|
+
try {
|
|
468
|
+
await this._exec([], 200000, null, false, [], true);
|
|
469
|
+
this.e.log(`key ${leaf} burned`);
|
|
470
|
+
} catch (e) {
|
|
471
|
+
this.e.log(`could not burn key ${leaf} (${e.message}). Do not send again until the wallet shows this transaction as settled.`);
|
|
472
|
+
}
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
async execSize(inner, tables = []) {
|
|
476
|
+
const state = P.statePda(unhex((await this.load()).aid), this.e.program).key;
|
|
477
|
+
const { outer, metas } = P.outerLayout(state, this.wallet, inner);
|
|
478
|
+
const data = P.buildExec(0, 0, 0, new Uint8Array(P.R_LEN), new Uint8Array(SIG_LEN), new Uint8Array(PATH_LEN), inner, outer);
|
|
479
|
+
return this.e.txSize([this.e.program, metas, data], 400000, tables);
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
sendSol(to, lamports, progress) {
|
|
483
|
+
const inner = [[SYSTEM, [[this.wallet, true, true], [to, false, true]], concat(u32(2), u64(lamports))]];
|
|
484
|
+
return this.exec(inner, 400000, progress);
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
rekey(progress) {
|
|
488
|
+
return this.lock(async () => {
|
|
489
|
+
const { r, st } = await this.preflight();
|
|
490
|
+
await this.pendingRekey(r, st);
|
|
491
|
+
return this._rekey(r, st, await this.reserve(r, st, SIGN_LIMIT), progress);
|
|
492
|
+
});
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
takeover(progress, opts = {}) {
|
|
496
|
+
return this.lock(async () => {
|
|
497
|
+
await this.e.checkCluster();
|
|
498
|
+
let r = await this.load();
|
|
499
|
+
const st = await this.e.chainState(r);
|
|
500
|
+
if (!st) throw new Error("wallet is not initialized on chain yet");
|
|
501
|
+
if (st.epoch < r.epoch) throw new Error("on-chain epoch is behind this wallet, refusing to sign");
|
|
502
|
+
if (st.next + RESTORE_GAP >= LEAVES) throw new Error("the current key tree is too far used to take over safely; take over from the device that is active, or wait for it to rekey");
|
|
503
|
+
if (st.epoch !== r.epoch || r.root !== hex(st.root)) {
|
|
504
|
+
const t = await this.e.compute.run("treeFor", { master: this.master, epoch: st.epoch, program: this.e.program, pubSeed: st.pubSeed }, progress && ((p) => progress(p * 0.4)));
|
|
505
|
+
if (!eq(t.root, st.root)) throw new Error("on-chain tree is not derived from this wallet's words");
|
|
506
|
+
const keepNext = st.epoch === r.epoch ? Math.max(r.next_leaf, st.next) : st.next;
|
|
507
|
+
Object.assign(r, { epoch: st.epoch, next_leaf: keepNext, pub_seed: hex(t.pubSeed), root: hex(t.root), leaves: b64(t.leaves), active: false });
|
|
508
|
+
delete r.inflight;
|
|
509
|
+
delete r.rekeying;
|
|
510
|
+
r = await this.put(r);
|
|
511
|
+
}
|
|
512
|
+
await this.pendingRekey(r, st);
|
|
513
|
+
const tk = (await this.e.store.get(`tk:${r.aid}`)) || { id: `tk:${r.aid}`, used: {} };
|
|
514
|
+
const scan = await this.failedLeaves(P.statePda(unhex(r.aid), this.e.program).key, { epoch: st.epoch, pubSeed: unhex(r.pub_seed), root: unhex(r.root), aid: unhex(r.aid) });
|
|
515
|
+
if (!scan.complete && !opts.partialOk) {
|
|
516
|
+
const e = new Error("this wallet's history is too long, or holds too many failed transactions, to check every earlier attempt from another device");
|
|
517
|
+
e.incomplete = true;
|
|
518
|
+
throw e;
|
|
519
|
+
}
|
|
520
|
+
const seen = scan.leaves;
|
|
521
|
+
const used = [...new Set([...(r.takeover && r.takeover.epoch === st.epoch ? r.takeover.used : []), ...(tk.used[st.epoch] || []), ...seen])];
|
|
522
|
+
const lo = Math.max(TAKEOVER_FLOOR, st.next + RESTORE_GAP, r.next_leaf > TAKEOVER_FLOOR ? r.next_leaf : 0);
|
|
523
|
+
const free = [];
|
|
524
|
+
for (let l = lo; l < LEAVES; l++) if (!used.includes(l)) free.push(l);
|
|
525
|
+
if (!free.length) throw new Error("no unused takeover keys left on this tree; take over from the device that is active, or wait for it to rekey");
|
|
526
|
+
const leaf = free[crypto.getRandomValues(new Uint32Array(1))[0] % free.length];
|
|
527
|
+
tk.used = { [st.epoch]: [...used, leaf] };
|
|
528
|
+
await this.e.store.put(tk);
|
|
529
|
+
r.takeover = { epoch: st.epoch, used: [...used, leaf] };
|
|
530
|
+
await this.put(r);
|
|
531
|
+
return this._rekey(r, st, leaf, progress && ((p) => progress(0.4 + p * 0.6)));
|
|
532
|
+
});
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
async failedLeaves(state, cur) {
|
|
536
|
+
const out = new Set();
|
|
537
|
+
let complete = true;
|
|
538
|
+
for (const rpc of this.e.refRpc ? [this.e.rpc, this.e.refRpc] : [this.e.rpc]) {
|
|
539
|
+
const one = await this.scanFailed(rpc, state, cur);
|
|
540
|
+
one.leaves.forEach((l) => out.add(l));
|
|
541
|
+
if (!one.complete) complete = false;
|
|
542
|
+
}
|
|
543
|
+
return { leaves: [...out], complete };
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
async scanFailed(rpc, state, cur) {
|
|
547
|
+
const sigs = [];
|
|
548
|
+
let before = null, complete = false;
|
|
549
|
+
for (let page = 0; page < HISTORY_PAGES; page++) {
|
|
550
|
+
const got = await rpc.call("getSignaturesForAddress", [b58encode(state), { limit: 1000, commitment: "confirmed", ...(before ? { before } : {}) }]);
|
|
551
|
+
if (!Array.isArray(got)) throw new Error("the RPC returned a malformed signature list");
|
|
552
|
+
sigs.push(...got);
|
|
553
|
+
if (got.length < 1000) { complete = true; break; }
|
|
554
|
+
before = got[got.length - 1] && got[got.length - 1].signature;
|
|
555
|
+
if (typeof before !== "string") throw new Error("the RPC returned a malformed signature list");
|
|
556
|
+
}
|
|
557
|
+
let failed = sigs.filter((x) => x && x.err);
|
|
558
|
+
if (failed.length > FAILED_MAX) { failed = failed.slice(0, FAILED_MAX); complete = false; }
|
|
559
|
+
const out = new Set();
|
|
560
|
+
const prog = b58encode(this.e.program);
|
|
561
|
+
for (const s of failed) {
|
|
562
|
+
if (failed.length > 200 && this.e.pace !== 0) await new Promise((r) => setTimeout(r, this.e.pace || 250));
|
|
563
|
+
let tx = null;
|
|
564
|
+
for (let i = 0; i < 4 && !tx; i++) {
|
|
565
|
+
tx = await rpc.call("getTransaction", [s.signature, { encoding: "json", commitment: "confirmed", maxSupportedTransactionVersion: 0 }]).catch(() => null);
|
|
566
|
+
if (!tx) await new Promise((r) => setTimeout(r, 1500));
|
|
567
|
+
}
|
|
568
|
+
if (!tx || !tx.transaction) { complete = false; continue; }
|
|
569
|
+
const keys = [...tx.transaction.message.accountKeys, ...((tx.meta && tx.meta.loadedAddresses && [...tx.meta.loadedAddresses.writable, ...tx.meta.loadedAddresses.readonly]) || [])];
|
|
570
|
+
const ixs = [...tx.transaction.message.instructions, ...((tx.meta && Array.isArray(tx.meta.innerInstructions) && tx.meta.innerInstructions.flatMap((x) => (x && Array.isArray(x.instructions) ? x.instructions : []))) || [])];
|
|
571
|
+
for (const ix of ixs) {
|
|
572
|
+
if (!ix || keys[ix.programIdIndex] !== prog || typeof ix.data !== "string" || ix.data.length > 4000) continue;
|
|
573
|
+
let leaf;
|
|
574
|
+
try { leaf = signedLeaf(b58decode(ix.data), (ix.accounts || []).map((i) => keys[i]), this.e.program, cur); } catch { leaf = null; }
|
|
575
|
+
if (leaf !== null) out.add(leaf);
|
|
576
|
+
}
|
|
577
|
+
}
|
|
578
|
+
return { leaves: [...out], complete };
|
|
579
|
+
}
|
|
580
|
+
|
|
581
|
+
async pendingRekey(r, st) {
|
|
582
|
+
const m = r.rekeying;
|
|
583
|
+
if (m && Number(m.epoch) === st.epoch + 1 && !(await this.e.passed(Number(m.expiry), Number(m.at) || 0)))
|
|
584
|
+
throw new Error("an earlier rekey from this copy may still land; wait about a minute, then try again");
|
|
585
|
+
}
|
|
586
|
+
|
|
587
|
+
async _rekey(r, st, leaf, progress) {
|
|
588
|
+
const expiry = (await this.e.rpc.slot()) + EXPIRY_SLOTS;
|
|
589
|
+
const state = P.statePda(unhex(r.aid), this.e.program).key;
|
|
590
|
+
const metas = [[state, false, true], [this.wallet, false, false]];
|
|
591
|
+
const price = await this.e.price(400000, [state]);
|
|
592
|
+
await this.e.payerCovers(this.e.feeFor(400000, price));
|
|
593
|
+
const newPubSeed = M.chainSeeded(this.e.program, st.epoch + 1) ? crypto.getRandomValues(new Uint8Array(16)) : undefined;
|
|
594
|
+
r.rekeying = { epoch: st.epoch + 1, leaf, expiry, at: Date.now() };
|
|
595
|
+
if (newPubSeed) r.rekeying.pub_seed = hex(newPubSeed);
|
|
596
|
+
await this.put(r);
|
|
597
|
+
const rnd = crypto.getRandomValues(new Uint8Array(P.R_LEN));
|
|
598
|
+
const aid = unhex(r.aid);
|
|
599
|
+
const s = await this.e.compute.run("signRekey", {
|
|
600
|
+
master: this.master, epoch: r.epoch, leaves: unb64(r.leaves), pubSeed: unhex(r.pub_seed), newPubSeed, leaf, program: this.e.program, aid, expiry, r: rnd,
|
|
601
|
+
}, progress);
|
|
602
|
+
const data = P.buildRekey(leaf, expiry, s.salt, rnd, s.sig, s.path, s.newPubSeed, s.newRoot);
|
|
603
|
+
let sig;
|
|
604
|
+
try {
|
|
605
|
+
sig = await this.e.submit([this.e.program, metas, data], expiry, 400000, [], price);
|
|
606
|
+
} catch (err) {
|
|
607
|
+
if (!err.sent) {
|
|
608
|
+
const back = await this.load();
|
|
609
|
+
if (back.rekeying && Number(back.rekeying.leaf) === leaf) { delete back.rekeying; await this.put(back); }
|
|
610
|
+
throw err;
|
|
611
|
+
}
|
|
612
|
+
if (!M.chainSeeded(this.e.program, st.epoch + 1)) throw err;
|
|
613
|
+
const after = await this.e.chainState(await this.load()).catch(() => null);
|
|
614
|
+
if (!(after && after.epoch === st.epoch + 1 && eq(after.root, s.newRoot))) throw err;
|
|
615
|
+
sig = "landed";
|
|
616
|
+
}
|
|
617
|
+
if (sig !== "landed" && !M.chainSeeded(this.e.program, st.epoch + 1)) {
|
|
618
|
+
let tx = null;
|
|
619
|
+
for (let i = 0; i < 6 && !(tx && tx.meta); i++) {
|
|
620
|
+
tx = await this.e.rpc.call("getTransaction", [sig, { encoding: "json", commitment: "confirmed", maxSupportedTransactionVersion: 0 }]).catch(() => null);
|
|
621
|
+
if (!(tx && tx.meta)) await new Promise((r) => setTimeout(r, 3000));
|
|
622
|
+
}
|
|
623
|
+
if (!(tx && tx.meta && tx.meta.err === null)) throw new Error("could not confirm this rekey on chain; refresh in a minute");
|
|
624
|
+
}
|
|
625
|
+
if (sig !== "landed") {
|
|
626
|
+
const after = await this.e.chainState(await this.load()).catch(() => null);
|
|
627
|
+
if (!(after && after.epoch === st.epoch + 1 && eq(after.root, s.newRoot))) throw new Error("the rekey was reported as landed but the chain does not show the new tree yet; refresh in a minute");
|
|
628
|
+
}
|
|
629
|
+
const fresh = await this.load();
|
|
630
|
+
Object.assign(fresh, { epoch: r.epoch + 1, next_leaf: 0, pub_seed: hex(s.newPubSeed), root: hex(s.newRoot), leaves: b64(s.newLeaves), active: true });
|
|
631
|
+
delete fresh.rekeying;
|
|
632
|
+
delete fresh.inflight;
|
|
633
|
+
delete fresh.takeover;
|
|
634
|
+
await this.put(fresh);
|
|
635
|
+
return sig;
|
|
636
|
+
}
|
|
637
|
+
|
|
638
|
+
words() { return M.toWords(this.master); }
|
|
639
|
+
}
|