@qntx/wallet-engine 0.5.2 → 0.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +21 -0
- package/dist/contract-CEsgLme3.mjs +277 -0
- package/dist/contract-CEsgLme3.mjs.map +1 -0
- package/dist/gateway-contract.d.mts +285 -0
- package/dist/gateway-contract.mjs +1 -0
- package/dist/idb.d.mts +0 -4
- package/dist/idb.mjs +3 -6
- package/dist/idb.mjs.map +1 -1
- package/dist/index.d.mts +976 -499
- package/dist/index.mjs +2971 -965
- package/dist/index.mjs.map +1 -1
- package/dist/registry-LcFSPrE1.d.mts +23 -0
- package/package.json +14 -8
package/dist/index.mjs
CHANGED
|
@@ -1,27 +1,29 @@
|
|
|
1
|
+
import { A as rpcPath, C as EVM_RPC_METHODS, I as parseWith, c as networkById, i as servedNetworksResponse, t as networksPath, u as esploraBasePath, v as INSTALL_ID_HEADER } from "./contract-CEsgLme3.mjs";
|
|
1
2
|
import { createStore } from "zustand/vanilla";
|
|
3
|
+
import { bytesToHex, hexToBytes, utf8ToBytes } from "@noble/hashes/utils.js";
|
|
4
|
+
import { ChainError } from "@qntx/wallet-chains";
|
|
5
|
+
import { WalletError, parseBackupHeader } from "@qntx/wallet";
|
|
6
|
+
import { Address, OutScript, Transaction } from "@scure/btc-signer";
|
|
2
7
|
import { SparkInputError, bitcoinAddressScript, createSparkClient, decodeBolt11, decodeSparkAddress, encodeSparkAddress, parseSparkInvoice, uuidv7 } from "@qntx/spark";
|
|
8
|
+
import { BITCOIN_NETWORKS, bitcoinAddressFromXpub, bitcoinNetwork, bitcoinPubkeyFromXpub, decodeBitcoinPsbt } from "@qntx/wallet-chains/bitcoin";
|
|
9
|
+
import { checksumEvmAddress, decodeEvm, isEvmAddress, normalizeEvm } from "@qntx/wallet-chains/evm";
|
|
10
|
+
import { PROGRAM_IDS, decodeSolana, isSolanaAddress, netAfterTransferFee, parseMintExtensions } from "@qntx/wallet-chains/solana";
|
|
11
|
+
import { decodeLnurl, encodeLnurl, lightningAddressUrl, registerLightningAddress, requestInvoice, resolveLnurl, submitWithdraw, unregisterLightningAddress } from "@qntx/spark/lnurl";
|
|
12
|
+
import { signedMatchesUnsigned, verifyEvent } from "@qntx/nostr";
|
|
13
|
+
import { makeZapRequest } from "@qntx/nostr/nips/nip57";
|
|
14
|
+
import { chains } from "@qntx/wallet/chains";
|
|
3
15
|
import { nostr } from "@qntx/wallet/nostr";
|
|
4
16
|
import { spark } from "@qntx/wallet/spark";
|
|
5
17
|
import { createPublicClient, decodeFunctionResult, encodeFunctionData, fallback, http, isAddress, isHex, numberToHex } from "viem";
|
|
6
|
-
import { bitcoinAddressFromXpub, bitcoinNetwork, bitcoinPubkeyFromXpub, decodeBitcoinPsbt } from "@qntx/wallet-chains/bitcoin";
|
|
7
|
-
import { chains } from "@qntx/wallet/chains";
|
|
8
18
|
import { sha256 } from "@noble/hashes/sha2.js";
|
|
9
|
-
import { Address, OutScript, Transaction } from "@scure/btc-signer";
|
|
10
|
-
import { checksumEvmAddress, decodeEvm } from "@qntx/wallet-chains/evm";
|
|
11
|
-
import { bytesToHex, utf8ToBytes } from "@noble/hashes/utils.js";
|
|
12
|
-
import { parseBackupHeader } from "@qntx/wallet";
|
|
13
|
-
import { signedMatchesUnsigned, verifyEvent } from "@qntx/nostr";
|
|
14
19
|
import { base64, hex } from "@scure/base";
|
|
15
20
|
import { p2wpkh } from "@scure/btc-signer/payment.js";
|
|
16
21
|
import { selectUTXO } from "@scure/btc-signer/utxo.js";
|
|
17
|
-
import { decodeLnurl, encodeLnurl, lightningAddressUrl, registerLightningAddress, requestInvoice, resolveLnurl, submitWithdraw, unregisterLightningAddress } from "@qntx/spark/lnurl";
|
|
18
|
-
import { makeZapRequest } from "@qntx/nostr/nips/nip57";
|
|
19
|
-
import { PROGRAM_IDS, decodeSolana, netAfterTransferFee, parseMintExtensions } from "@qntx/wallet-chains/solana";
|
|
20
22
|
import { getSetComputeUnitLimitInstruction, getSetComputeUnitPriceInstruction } from "@solana-program/compute-budget";
|
|
21
23
|
import { getTransferSolInstruction } from "@solana-program/system";
|
|
22
24
|
import { TOKEN_PROGRAM_ADDRESS, findAssociatedTokenPda, getCreateAssociatedTokenIdempotentInstruction, getTransferCheckedInstruction } from "@solana-program/token";
|
|
23
25
|
import { address, appendTransactionMessageInstructions, blockhash, compileTransactionMessage, createNoopSigner, createTransactionMessage, getCompiledTransactionMessageEncoder, setTransactionMessageFeePayer, setTransactionMessageLifetimeUsingBlockhash } from "@solana/kit";
|
|
24
|
-
|
|
26
|
+
// src/controller.ts
|
|
25
27
|
/**
|
|
26
28
|
* Controller base: a zustand vanilla store plus lifecycle. `update` applies a recipe that must
|
|
27
29
|
* return a NEW state object; returning the same reference is a no-op (no set, no notification).
|
|
@@ -41,8 +43,7 @@ var Controller = class {
|
|
|
41
43
|
this.store.setState((state) => recipe(state));
|
|
42
44
|
}
|
|
43
45
|
};
|
|
44
|
-
|
|
45
|
-
//#region src/events.ts
|
|
46
|
+
// src/events.ts
|
|
46
47
|
function createEventBus(logger) {
|
|
47
48
|
const listeners = /* @__PURE__ */ new Map();
|
|
48
49
|
return {
|
|
@@ -67,73 +68,1938 @@ function createEventBus(logger) {
|
|
|
67
68
|
set.delete(stored);
|
|
68
69
|
};
|
|
69
70
|
}
|
|
70
|
-
};
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
// src/errors.ts
|
|
74
|
+
/** Typed engine errors: store codes are `closed` and `integrity` (decrypt or bind failure). */
|
|
75
|
+
var EngineStoreError = class extends Error {
|
|
76
|
+
code;
|
|
77
|
+
constructor(code, message) {
|
|
78
|
+
super(message);
|
|
79
|
+
this.name = "EngineStoreError";
|
|
80
|
+
this.code = code;
|
|
81
|
+
}
|
|
82
|
+
};
|
|
83
|
+
// src/store/guard.ts
|
|
84
|
+
/** NUL is the composite-key/AAD separator — a name or key containing it collides. */
|
|
85
|
+
function checkName(kind, value) {
|
|
86
|
+
if (value.includes("\0")) throw new EngineStoreError("invalid-input", `${kind} must not contain NUL`);
|
|
87
|
+
}
|
|
88
|
+
// src/store/memory.ts
|
|
89
|
+
/** In-memory EngineStore: the Node/test implementation — same contract, no durability. */
|
|
90
|
+
function createMemoryEngineStore(backing) {
|
|
91
|
+
const collections = backing?.collections ?? /* @__PURE__ */ new Map();
|
|
92
|
+
let closed = false;
|
|
93
|
+
const guard = () => {
|
|
94
|
+
if (closed) throw new EngineStoreError("closed", "engine store is closed");
|
|
95
|
+
};
|
|
96
|
+
return {
|
|
97
|
+
collection(name) {
|
|
98
|
+
checkName("collection", name);
|
|
99
|
+
let table = collections.get(name);
|
|
100
|
+
if (table === void 0) {
|
|
101
|
+
table = /* @__PURE__ */ new Map();
|
|
102
|
+
collections.set(name, table);
|
|
103
|
+
}
|
|
104
|
+
return {
|
|
105
|
+
get: async (key) => {
|
|
106
|
+
guard();
|
|
107
|
+
checkName("key", key);
|
|
108
|
+
const value = table.get(key);
|
|
109
|
+
return value === void 0 ? void 0 : new Uint8Array(value);
|
|
110
|
+
},
|
|
111
|
+
put: async (key, bytes) => {
|
|
112
|
+
guard();
|
|
113
|
+
checkName("key", key);
|
|
114
|
+
table.set(key, new Uint8Array(bytes));
|
|
115
|
+
},
|
|
116
|
+
delete: async (key) => {
|
|
117
|
+
guard();
|
|
118
|
+
checkName("key", key);
|
|
119
|
+
table.delete(key);
|
|
120
|
+
},
|
|
121
|
+
list: async () => {
|
|
122
|
+
guard();
|
|
123
|
+
return [...table.keys()];
|
|
124
|
+
}
|
|
125
|
+
};
|
|
126
|
+
},
|
|
127
|
+
close: async () => {
|
|
128
|
+
closed = true;
|
|
129
|
+
collections.clear();
|
|
130
|
+
}
|
|
131
|
+
};
|
|
132
|
+
}
|
|
133
|
+
// src/connect/errors.ts
|
|
134
|
+
/** EIP-1193 / EIP-1474 / EIP-3326 error: `code` is what the page's promise rejects with. */
|
|
135
|
+
var ProviderError = class extends Error {
|
|
136
|
+
code;
|
|
137
|
+
constructor(code, message) {
|
|
138
|
+
super(message);
|
|
139
|
+
this.name = "ProviderError";
|
|
140
|
+
this.code = code;
|
|
141
|
+
}
|
|
142
|
+
};
|
|
143
|
+
const userRejectedRequest = (message = "user rejected the request") => new ProviderError(4001, message);
|
|
144
|
+
const unauthorized = (message = "not authorized") => new ProviderError(4100, message);
|
|
145
|
+
const unsupportedMethod = (method) => new ProviderError(4200, `${method} is not supported`);
|
|
146
|
+
const unrecognizedChain = (message = "unrecognized chain") => new ProviderError(4902, message);
|
|
147
|
+
const invalidParams = (message) => new ProviderError(-32602, message);
|
|
148
|
+
const requestPending = (message) => new ProviderError(-32002, message);
|
|
149
|
+
const internalError = (message) => new ProviderError(-32603, message);
|
|
150
|
+
// src/connect/controller.ts
|
|
151
|
+
/**
|
|
152
|
+
* DApp connections (CAIP-25 style sessions): one session per origin, authorization by network,
|
|
153
|
+
* account and method, persisted in the engine store and cleared from memory on lock. A connection
|
|
154
|
+
* is approved from a pending queue, by id, like any other request; locking rejects all of it.
|
|
155
|
+
*/
|
|
156
|
+
const COLLECTION$4 = "connections";
|
|
157
|
+
/** `lastUsedAt` is rewritten at most this often. */
|
|
158
|
+
const TOUCH_INTERVAL_MS = 6e4;
|
|
159
|
+
/** Granted for every network a session covers. */
|
|
160
|
+
const SESSION_METHODS = [
|
|
161
|
+
"eth_sendTransaction",
|
|
162
|
+
"personal_sign",
|
|
163
|
+
"eth_signTypedData_v4"
|
|
164
|
+
];
|
|
165
|
+
const SESSION_NOTIFICATIONS = ["accountsChanged", "chainChanged"];
|
|
166
|
+
/** `eip155:1` → 1; `undefined` for anything that is not an EVM network. */
|
|
167
|
+
const evmChainId = (network) => {
|
|
168
|
+
const match = /^eip155:(\d+)$/.exec(network);
|
|
169
|
+
const id = match?.[1] === void 0 ? void 0 : Number(match[1]);
|
|
170
|
+
return id !== void 0 && Number.isSafeInteger(id) ? id : void 0;
|
|
171
|
+
};
|
|
172
|
+
const caip10 = (network, address) => `${network}:${address}`;
|
|
173
|
+
const addressOf$1 = (account) => account.slice(account.lastIndexOf(":") + 1);
|
|
174
|
+
const isObject$14 = (v) => typeof v === "object" && v !== null;
|
|
175
|
+
const isSession = (value) => {
|
|
176
|
+
if (!isObject$14(value) || !isObject$14(value["origin"]) || !isObject$14(value["scopes"])) return false;
|
|
177
|
+
return typeof value["id"] === "string" && value["origin"]["kind"] === "browser" && typeof value["origin"]["origin"] === "string" && typeof value["currentNetwork"] === "string" && typeof value["createdAt"] === "number" && typeof value["lastUsedAt"] === "number";
|
|
178
|
+
};
|
|
179
|
+
var ConnectionController = class extends Controller {
|
|
180
|
+
options;
|
|
181
|
+
engineStore;
|
|
182
|
+
waiters = /* @__PURE__ */ new Map();
|
|
183
|
+
group;
|
|
184
|
+
constructor(options) {
|
|
185
|
+
super({
|
|
186
|
+
name: "connections",
|
|
187
|
+
defaultState: () => ({
|
|
188
|
+
sessions: {},
|
|
189
|
+
pending: {}
|
|
190
|
+
})
|
|
191
|
+
});
|
|
192
|
+
this.options = options;
|
|
193
|
+
}
|
|
194
|
+
async init() {
|
|
195
|
+
const store = await this.options.openStore();
|
|
196
|
+
this.engineStore = store;
|
|
197
|
+
this.group = void 0;
|
|
198
|
+
const collection = store.collection(COLLECTION$4);
|
|
199
|
+
const keys = await collection.list();
|
|
200
|
+
const rows = await Promise.all(keys.map(async (key) => collection.get(key)));
|
|
201
|
+
const now = this.options.now();
|
|
202
|
+
const sessions = {};
|
|
203
|
+
const expired = [];
|
|
204
|
+
for (const [index, raw] of rows.entries()) {
|
|
205
|
+
const key = keys[index];
|
|
206
|
+
if (raw === void 0 || key === void 0) continue;
|
|
207
|
+
const parsed = JSON.parse(new TextDecoder().decode(raw));
|
|
208
|
+
if (!isSession(parsed)) {
|
|
209
|
+
this.options.logger.warn("connection session skipped: malformed");
|
|
210
|
+
expired.push(key);
|
|
211
|
+
} else if (now - parsed.lastUsedAt > 7776e6) expired.push(key);
|
|
212
|
+
else sessions[parsed.id] = parsed;
|
|
213
|
+
}
|
|
214
|
+
await Promise.all(expired.map(async (key) => collection.delete(key)));
|
|
215
|
+
this.update(() => ({
|
|
216
|
+
sessions,
|
|
217
|
+
pending: {}
|
|
218
|
+
}));
|
|
219
|
+
}
|
|
220
|
+
async destroy() {
|
|
221
|
+
this.lock();
|
|
222
|
+
await Promise.resolve();
|
|
223
|
+
}
|
|
224
|
+
/** Locking is not consent: every pending connection is rejected. */
|
|
225
|
+
lock() {
|
|
226
|
+
this.engineStore = void 0;
|
|
227
|
+
this.group = void 0;
|
|
228
|
+
for (const waiter of this.waiters.values()) waiter.reject(userRejectedRequest("the wallet was locked"));
|
|
229
|
+
this.waiters.clear();
|
|
230
|
+
this.update(() => ({
|
|
231
|
+
sessions: {},
|
|
232
|
+
pending: {}
|
|
233
|
+
}));
|
|
234
|
+
}
|
|
235
|
+
/** Throws `4100` unless `origin` is `https://host[:port]` (or localhost in development). */
|
|
236
|
+
checkOrigin(origin) {
|
|
237
|
+
let url;
|
|
238
|
+
try {
|
|
239
|
+
url = new URL(origin);
|
|
240
|
+
} catch {
|
|
241
|
+
throw unauthorized("invalid origin");
|
|
242
|
+
}
|
|
243
|
+
const localhost = url.protocol === "http:" && url.hostname === "localhost";
|
|
244
|
+
if (url.origin !== origin || url.protocol !== "https:" && !(localhost && this.options.allowLocalhost)) throw unauthorized("origin not allowed");
|
|
245
|
+
return {
|
|
246
|
+
kind: "browser",
|
|
247
|
+
origin
|
|
248
|
+
};
|
|
249
|
+
}
|
|
250
|
+
session(origin) {
|
|
251
|
+
return Object.values(this.getState().sessions).find((s) => s.origin.origin === origin);
|
|
252
|
+
}
|
|
253
|
+
pending() {
|
|
254
|
+
return Object.values(this.getState().pending);
|
|
255
|
+
}
|
|
256
|
+
/** The group sessions are bound to: the host's last `setActiveGroup`, else the app's selection. */
|
|
257
|
+
activeGroup() {
|
|
258
|
+
return this.group ?? this.options.selectedGroup();
|
|
259
|
+
}
|
|
260
|
+
/** Whether `network` is an EVM network the wallet has enabled. */
|
|
261
|
+
isEnabled(network) {
|
|
262
|
+
return this.options.enabledNetworks().includes(network);
|
|
263
|
+
}
|
|
264
|
+
/** The origin's authorized addresses on its current network; empty when not connected. */
|
|
265
|
+
accounts(origin) {
|
|
266
|
+
const session = this.session(origin);
|
|
267
|
+
return session?.scopes[session.currentNetwork]?.accounts.map(addressOf$1) ?? [];
|
|
268
|
+
}
|
|
269
|
+
/** Throws `4100` unless the origin's session covers `method` on `network`. */
|
|
270
|
+
authorize(origin, network, method) {
|
|
271
|
+
const session = this.session(origin);
|
|
272
|
+
const scope = session?.scopes[network];
|
|
273
|
+
if (session === void 0 || scope === void 0 || !scope.methods.includes(method)) throw unauthorized();
|
|
274
|
+
return session;
|
|
275
|
+
}
|
|
276
|
+
/** Resolves with the session once the host approves; rejects with `4001` when it refuses. */
|
|
277
|
+
async request(origin, kind, network) {
|
|
278
|
+
if (this.engineStore === void 0) throw userRejectedRequest("the wallet is locked");
|
|
279
|
+
if (this.pending().some((p) => p.origin.origin === origin)) throw requestPending("a connection request is already pending");
|
|
280
|
+
const id = `conn-${[...this.options.random(8)].map((b) => b.toString(16).padStart(2, "0")).join("")}`;
|
|
281
|
+
const entry = {
|
|
282
|
+
id,
|
|
283
|
+
origin: {
|
|
284
|
+
kind: "browser",
|
|
285
|
+
origin
|
|
286
|
+
},
|
|
287
|
+
kind,
|
|
288
|
+
...network === void 0 ? {} : { network },
|
|
289
|
+
createdAt: this.options.now()
|
|
290
|
+
};
|
|
291
|
+
return new Promise((resolve, reject) => {
|
|
292
|
+
this.waiters.set(id, {
|
|
293
|
+
resolve,
|
|
294
|
+
reject
|
|
295
|
+
});
|
|
296
|
+
this.update((s) => ({
|
|
297
|
+
...s,
|
|
298
|
+
pending: {
|
|
299
|
+
...s.pending,
|
|
300
|
+
[id]: entry
|
|
301
|
+
}
|
|
302
|
+
}));
|
|
303
|
+
});
|
|
304
|
+
}
|
|
305
|
+
/**
|
|
306
|
+
* The host's approval, by id: the accounts granted (EVM addresses of the active group) and the
|
|
307
|
+
* network.
|
|
308
|
+
*/
|
|
309
|
+
async approveConnection(id, grant) {
|
|
310
|
+
const store = this.requireStore();
|
|
311
|
+
const entry = this.getState().pending[id];
|
|
312
|
+
const waiter = this.waiters.get(id);
|
|
313
|
+
if (entry === void 0 || waiter === void 0) throw new Error(`unknown connection request ${id}`);
|
|
314
|
+
if (entry.kind === "network" && entry.network !== grant.network) throw new Error(`connection request ${id} is for ${String(entry.network)}`);
|
|
315
|
+
if (evmChainId(grant.network) === void 0 || !this.options.enabledNetworks().includes(grant.network)) throw invalidParams(`${grant.network} is not an enabled EVM network`);
|
|
316
|
+
const group = this.activeGroup();
|
|
317
|
+
if (group === void 0) throw new Error("no account group is selected");
|
|
318
|
+
const own = (await this.options.evmAddress(group, grant.network)).toLowerCase();
|
|
319
|
+
if (grant.accounts.length === 0 || grant.accounts.some((a) => a.toLowerCase() !== own)) throw invalidParams("only the active group's account can be granted");
|
|
320
|
+
if (this.getState().pending[id] === void 0) throw new Error(`connection request ${id} is no longer pending`);
|
|
321
|
+
const now = this.options.now();
|
|
322
|
+
const existing = this.session(entry.origin.origin);
|
|
323
|
+
const scope = {
|
|
324
|
+
accounts: [caip10(grant.network, own)],
|
|
325
|
+
methods: SESSION_METHODS,
|
|
326
|
+
notifications: SESSION_NOTIFICATIONS
|
|
327
|
+
};
|
|
328
|
+
const session = {
|
|
329
|
+
id: existing?.id ?? `session-${[...this.options.random(8)].map((b) => b.toString(16).padStart(2, "0")).join("")}`,
|
|
330
|
+
origin: entry.origin,
|
|
331
|
+
scopes: {
|
|
332
|
+
...existing?.scopes,
|
|
333
|
+
[grant.network]: scope
|
|
334
|
+
},
|
|
335
|
+
currentNetwork: grant.network,
|
|
336
|
+
createdAt: existing?.createdAt ?? now,
|
|
337
|
+
lastUsedAt: now
|
|
338
|
+
};
|
|
339
|
+
await this.persist(store, session);
|
|
340
|
+
this.dropPending(id);
|
|
341
|
+
if (existing !== void 0 && existing.currentNetwork !== grant.network) this.options.invalidate(entry.origin.origin);
|
|
342
|
+
waiter.resolve(session);
|
|
343
|
+
return session;
|
|
344
|
+
}
|
|
345
|
+
rejectConnection(id) {
|
|
346
|
+
const waiter = this.waiters.get(id);
|
|
347
|
+
if (this.getState().pending[id] === void 0 || waiter === void 0) throw new Error(`unknown connection request ${id}`);
|
|
348
|
+
this.dropPending(id);
|
|
349
|
+
waiter.reject(userRejectedRequest());
|
|
350
|
+
}
|
|
351
|
+
/** Ends the origin's session; its pending connection and unfinished requests are void. */
|
|
352
|
+
async revoke(origin) {
|
|
353
|
+
const store = this.requireStore();
|
|
354
|
+
for (const p of this.pending().filter((x) => x.origin.origin === origin)) {
|
|
355
|
+
const waiter = this.waiters.get(p.id);
|
|
356
|
+
this.dropPending(p.id);
|
|
357
|
+
waiter?.reject(userRejectedRequest("the connection was revoked"));
|
|
358
|
+
}
|
|
359
|
+
const session = this.session(origin);
|
|
360
|
+
if (session !== void 0) {
|
|
361
|
+
await store.collection(COLLECTION$4).delete(session.id);
|
|
362
|
+
this.update((s) => {
|
|
363
|
+
const { [session.id]: _gone, ...sessions } = s.sessions;
|
|
364
|
+
return {
|
|
365
|
+
...s,
|
|
366
|
+
sessions
|
|
367
|
+
};
|
|
368
|
+
});
|
|
369
|
+
}
|
|
370
|
+
this.options.invalidate(origin);
|
|
371
|
+
}
|
|
372
|
+
/** Switches within the session's scope; the origin's unfinished requests are void. */
|
|
373
|
+
async setCurrentNetwork(origin, network) {
|
|
374
|
+
const store = this.requireStore();
|
|
375
|
+
const session = this.session(origin);
|
|
376
|
+
if (session === void 0 || session.scopes[network] === void 0) throw unauthorized();
|
|
377
|
+
await this.persist(store, {
|
|
378
|
+
...session,
|
|
379
|
+
currentNetwork: network
|
|
380
|
+
});
|
|
381
|
+
this.options.invalidate(origin);
|
|
382
|
+
}
|
|
383
|
+
async touch(origin) {
|
|
384
|
+
const store = this.engineStore;
|
|
385
|
+
const session = this.session(origin);
|
|
386
|
+
const now = this.options.now();
|
|
387
|
+
if (store === void 0 || session === void 0 || now - session.lastUsedAt < TOUCH_INTERVAL_MS) return;
|
|
388
|
+
await this.persist(store, {
|
|
389
|
+
...session,
|
|
390
|
+
lastUsedAt: now
|
|
391
|
+
});
|
|
392
|
+
}
|
|
393
|
+
/**
|
|
394
|
+
* The host switched account group: every session's accounts become the new group's addresses. A
|
|
395
|
+
* session that cannot get one (no address derivable) is left with an empty list and has to be
|
|
396
|
+
* authorized again.
|
|
397
|
+
*/
|
|
398
|
+
async setActiveGroup(walletId, groupIndex) {
|
|
399
|
+
const store = this.requireStore();
|
|
400
|
+
const group = {
|
|
401
|
+
walletId,
|
|
402
|
+
groupIndex
|
|
403
|
+
};
|
|
404
|
+
this.group = group;
|
|
405
|
+
await Promise.all(Object.values(this.getState().sessions).map(async (session) => {
|
|
406
|
+
const entries = await Promise.all(Object.entries(session.scopes).map(async ([network, scope]) => {
|
|
407
|
+
if (scope.accounts.length === 0) return [network, scope];
|
|
408
|
+
try {
|
|
409
|
+
const address = await this.options.evmAddress(group, network);
|
|
410
|
+
return [network, {
|
|
411
|
+
...scope,
|
|
412
|
+
accounts: [caip10(network, address.toLowerCase())]
|
|
413
|
+
}];
|
|
414
|
+
} catch (error) {
|
|
415
|
+
this.options.logger.warn("connection account unavailable in the new group", error);
|
|
416
|
+
return [network, {
|
|
417
|
+
...scope,
|
|
418
|
+
accounts: []
|
|
419
|
+
}];
|
|
420
|
+
}
|
|
421
|
+
}));
|
|
422
|
+
await this.persist(store, {
|
|
423
|
+
...session,
|
|
424
|
+
scopes: Object.fromEntries(entries)
|
|
425
|
+
});
|
|
426
|
+
}));
|
|
427
|
+
}
|
|
428
|
+
dropPending(id) {
|
|
429
|
+
this.waiters.delete(id);
|
|
430
|
+
this.update((s) => {
|
|
431
|
+
const { [id]: _gone, ...pending } = s.pending;
|
|
432
|
+
return {
|
|
433
|
+
...s,
|
|
434
|
+
pending
|
|
435
|
+
};
|
|
436
|
+
});
|
|
437
|
+
}
|
|
438
|
+
async persist(store, session) {
|
|
439
|
+
try {
|
|
440
|
+
await store.collection(COLLECTION$4).put(session.id, new TextEncoder().encode(JSON.stringify(session)));
|
|
441
|
+
} catch (error) {
|
|
442
|
+
throw internalError(error instanceof Error ? error.message : String(error));
|
|
443
|
+
}
|
|
444
|
+
this.update((s) => ({
|
|
445
|
+
...s,
|
|
446
|
+
sessions: {
|
|
447
|
+
...s.sessions,
|
|
448
|
+
[session.id]: session
|
|
449
|
+
}
|
|
450
|
+
}));
|
|
451
|
+
}
|
|
452
|
+
requireStore() {
|
|
453
|
+
if (this.engineStore === void 0) throw userRejectedRequest("the wallet is locked");
|
|
454
|
+
return this.engineStore;
|
|
455
|
+
}
|
|
456
|
+
};
|
|
457
|
+
// src/addresses/normalize.ts
|
|
458
|
+
/**
|
|
459
|
+
* The canonical form of `address` for its family — EIP-55 for EVM, the re-encoded form for bitcoin
|
|
460
|
+
* (accepted on any known bitcoin network, since a contact is not tied to the wallet's network) and
|
|
461
|
+
* Spark (identity key only, no invoice fields) — or `undefined` when it is not a valid address.
|
|
462
|
+
*/
|
|
463
|
+
function normalizeAddress(family, address) {
|
|
464
|
+
try {
|
|
465
|
+
if (family === "evm") return isEvmAddress(address) ? checksumEvmAddress(address) : void 0;
|
|
466
|
+
if (family === "solana") return isSolanaAddress(address) ? address : void 0;
|
|
467
|
+
if (family === "spark") {
|
|
468
|
+
const decoded = decodeSparkAddress(address);
|
|
469
|
+
return encodeSparkAddress({
|
|
470
|
+
network: decoded.network,
|
|
471
|
+
identityPublicKey: decoded.identityPublicKey
|
|
472
|
+
});
|
|
473
|
+
}
|
|
474
|
+
for (const network of Object.values(BITCOIN_NETWORKS)) try {
|
|
475
|
+
const codec = Address(network.params);
|
|
476
|
+
return codec.encode(codec.decode(address));
|
|
477
|
+
} catch {}
|
|
478
|
+
return;
|
|
479
|
+
} catch {
|
|
480
|
+
return;
|
|
481
|
+
}
|
|
482
|
+
}
|
|
483
|
+
// src/spark/errors.ts
|
|
484
|
+
var SparkControllerError = class extends Error {
|
|
485
|
+
category;
|
|
486
|
+
constructor(category, message, cause) {
|
|
487
|
+
super(message, { cause });
|
|
488
|
+
this.name = "SparkControllerError";
|
|
489
|
+
this.category = category;
|
|
490
|
+
}
|
|
491
|
+
};
|
|
492
|
+
/** Maps a thrown client/kernel error into a category without leaking payloads. */
|
|
493
|
+
function categorize(error) {
|
|
494
|
+
const name = error instanceof Error ? error.name : "";
|
|
495
|
+
const message = error instanceof Error ? error.message : "";
|
|
496
|
+
if (/approval|denied|rejected/i.test(message)) return /required/i.test(message) ? "approval-required" : "rejected";
|
|
497
|
+
if (/insufficient|not enough|balance/i.test(message)) return "insufficient-funds";
|
|
498
|
+
if (name === "SparkRequestError" || /fetch|network|timeout|unavailable|closed/i.test(message)) return "network";
|
|
499
|
+
return "internal";
|
|
500
|
+
}
|
|
501
|
+
const feeCheck = (amountSats, maxFeeSats) => maxFeeSats !== void 0 && maxFeeSats >= amountSats ? ["fee-exceeds-amount"] : [];
|
|
502
|
+
// src/payments/errors.ts
|
|
503
|
+
var PaymentError = class extends Error {
|
|
504
|
+
category;
|
|
505
|
+
constructor(category, message, cause) {
|
|
506
|
+
super(message, { cause });
|
|
507
|
+
this.name = "PaymentError";
|
|
508
|
+
this.category = category;
|
|
509
|
+
}
|
|
510
|
+
};
|
|
511
|
+
// src/payments/pay.ts
|
|
512
|
+
const lightningDetails = (invoice) => {
|
|
513
|
+
const decoded = decodeBolt11(invoice);
|
|
514
|
+
return {
|
|
515
|
+
payee: bytesToHex(decoded.payee),
|
|
516
|
+
expiresAt: (decoded.timestamp + decoded.expirySeconds) * 1e3
|
|
517
|
+
};
|
|
518
|
+
};
|
|
519
|
+
/**
|
|
520
|
+
* Validates one parsed payment option and fixes everything the approval needs — including the
|
|
521
|
+
* LNURL-pay invoice, fetched here exactly once. Every input validation happens before the approval
|
|
522
|
+
* round, so a `PaymentError` here never consumed a verification. `lnurl-withdraw` receives funds
|
|
523
|
+
* and has its own entry, {@link withdrawLnurl}.
|
|
524
|
+
*/
|
|
525
|
+
async function preparePay(context, params) {
|
|
526
|
+
const ref = params.account;
|
|
527
|
+
const { network } = ref;
|
|
528
|
+
if (params.option.kind === "lnurl-withdraw") throw new PaymentError("invalid-input", "lnurl-withdraw receives funds: use payments.withdrawLnurl");
|
|
529
|
+
switch (params.option.kind) {
|
|
530
|
+
case "spark": {
|
|
531
|
+
if (params.amountSats === void 0) throw new PaymentError("invalid-input", "spark payment: amountSats is required");
|
|
532
|
+
const { address } = params.option;
|
|
533
|
+
const { amountSats } = params;
|
|
534
|
+
return {
|
|
535
|
+
display: {
|
|
536
|
+
family: "spark",
|
|
537
|
+
network,
|
|
538
|
+
kind: "transfer",
|
|
539
|
+
amountSats,
|
|
540
|
+
receiver: address
|
|
541
|
+
},
|
|
542
|
+
checks: [],
|
|
543
|
+
staleReason: () => void 0,
|
|
544
|
+
execute: async (spark, verify) => ({
|
|
545
|
+
kind: "spark",
|
|
546
|
+
transfer: await spark.send(ref, {
|
|
547
|
+
receiver: address,
|
|
548
|
+
amountSats,
|
|
549
|
+
verify
|
|
550
|
+
})
|
|
551
|
+
})
|
|
552
|
+
};
|
|
553
|
+
}
|
|
554
|
+
case "spark-invoice": {
|
|
555
|
+
const { option } = params;
|
|
556
|
+
if (option.expiresAt !== void 0 && option.expiresAt <= context.now()) throw new PaymentError("rejected", "spark invoice expired");
|
|
557
|
+
if (option.amountSats !== void 0 && params.amountSats !== void 0 && params.amountSats !== option.amountSats) throw new PaymentError("invalid-input", "spark invoice: amountSats conflicts with the invoice amount");
|
|
558
|
+
const amountSats = option.amountSats ?? params.amountSats;
|
|
559
|
+
if (amountSats === void 0) throw new PaymentError("invalid-input", "spark invoice: amountless invoice requires amountSats");
|
|
560
|
+
const openAmount = option.amountSats === void 0 ? params.amountSats : void 0;
|
|
561
|
+
const parsed = parseSparkInvoice(option.invoice, {
|
|
562
|
+
network,
|
|
563
|
+
now: context.now()
|
|
564
|
+
});
|
|
565
|
+
const { expiresAt } = option;
|
|
566
|
+
return {
|
|
567
|
+
display: {
|
|
568
|
+
family: "spark",
|
|
569
|
+
network,
|
|
570
|
+
kind: "transfer",
|
|
571
|
+
amountSats,
|
|
572
|
+
receiver: encodeSparkAddress({
|
|
573
|
+
network,
|
|
574
|
+
identityPublicKey: parsed.receiverIdentityPublicKey
|
|
575
|
+
}),
|
|
576
|
+
...option.memo === void 0 ? {} : { description: option.memo }
|
|
577
|
+
},
|
|
578
|
+
checks: [],
|
|
579
|
+
staleReason: (now) => expiresAt !== void 0 && expiresAt <= now ? "spark invoice expired" : void 0,
|
|
580
|
+
execute: async (spark, verify) => ({
|
|
581
|
+
kind: "spark",
|
|
582
|
+
transfer: await spark.paySparkInvoice(ref, {
|
|
583
|
+
invoice: option.invoice,
|
|
584
|
+
...openAmount === void 0 ? {} : { amountSats: openAmount },
|
|
585
|
+
verify
|
|
586
|
+
})
|
|
587
|
+
})
|
|
588
|
+
};
|
|
589
|
+
}
|
|
590
|
+
case "lightning": {
|
|
591
|
+
const { option } = params;
|
|
592
|
+
if (option.amountSats === void 0 && params.amountSats === void 0) throw new PaymentError("invalid-input", "amountless invoice: amountSats is required");
|
|
593
|
+
if (params.maxFeeSats === void 0) throw new PaymentError("invalid-input", "lightning payment: maxFeeSats is required");
|
|
594
|
+
if (option.expiresAt <= context.now() + 6e4) throw new PaymentError("rejected", "lightning invoice expired");
|
|
595
|
+
if (option.amountSats !== void 0 && params.amountSats !== void 0 && params.amountSats !== option.amountSats) throw new PaymentError("invalid-input", "amountSats conflicts with the invoice amount");
|
|
596
|
+
const { maxFeeSats } = params;
|
|
597
|
+
const openAmount = option.amountSats === void 0 ? params.amountSats : void 0;
|
|
598
|
+
const amountSats = option.amountSats ?? params.amountSats ?? 0;
|
|
599
|
+
const { payee } = lightningDetails(option.invoice);
|
|
600
|
+
const { expiresAt } = option;
|
|
601
|
+
return {
|
|
602
|
+
display: {
|
|
603
|
+
family: "spark",
|
|
604
|
+
network,
|
|
605
|
+
kind: "lightning",
|
|
606
|
+
amountSats,
|
|
607
|
+
maxFeeSats,
|
|
608
|
+
receiver: payee,
|
|
609
|
+
...option.description === void 0 ? {} : { description: option.description }
|
|
610
|
+
},
|
|
611
|
+
checks: feeCheck(amountSats, maxFeeSats),
|
|
612
|
+
staleReason: (now) => expiresAt <= now + 6e4 ? "lightning invoice expired" : void 0,
|
|
613
|
+
execute: async (spark, verify) => ({
|
|
614
|
+
kind: "lightning",
|
|
615
|
+
payment: await spark.payInvoice(ref, {
|
|
616
|
+
invoice: option.invoice,
|
|
617
|
+
maxFeeSats,
|
|
618
|
+
...openAmount === void 0 ? {} : { amountSats: openAmount },
|
|
619
|
+
verify
|
|
620
|
+
})
|
|
621
|
+
})
|
|
622
|
+
};
|
|
623
|
+
}
|
|
624
|
+
case "lnurl-pay": {
|
|
625
|
+
const { request } = params.option;
|
|
626
|
+
if (params.amountSats === void 0) throw new PaymentError("invalid-input", "lnurl-pay: amountSats is required");
|
|
627
|
+
if (params.maxFeeSats === void 0) throw new PaymentError("invalid-input", "lnurl-pay: maxFeeSats is required");
|
|
628
|
+
const { amountSats, maxFeeSats } = params;
|
|
629
|
+
const amountMsat = amountSats * 1e3;
|
|
630
|
+
if (amountMsat < request.minSendableMsat || amountMsat > request.maxSendableMsat) throw new PaymentError("rejected", "lnurl-pay: amount outside the service's range");
|
|
631
|
+
if (request.commentAllowed === 0 && params.comment !== void 0) throw new PaymentError("rejected", "lnurl-pay: comments not accepted by this service");
|
|
632
|
+
if (params.comment !== void 0 && params.comment.length > request.commentAllowed) throw new PaymentError("rejected", "lnurl-pay: comment exceeds the service's limit");
|
|
633
|
+
let requested;
|
|
634
|
+
try {
|
|
635
|
+
requested = await requestInvoice(request, {
|
|
636
|
+
fetch: context.fetch,
|
|
637
|
+
network,
|
|
638
|
+
amountMsat,
|
|
639
|
+
...params.comment === void 0 ? {} : { comment: params.comment }
|
|
640
|
+
});
|
|
641
|
+
} catch (error) {
|
|
642
|
+
if (error instanceof PaymentError || error instanceof SparkControllerError) throw error;
|
|
643
|
+
throw new PaymentError("network", "lnurl-pay: invoice request failed", error);
|
|
644
|
+
}
|
|
645
|
+
const { invoice } = requested;
|
|
646
|
+
const { payee, expiresAt } = lightningDetails(invoice);
|
|
647
|
+
return {
|
|
648
|
+
display: {
|
|
649
|
+
family: "spark",
|
|
650
|
+
network,
|
|
651
|
+
kind: "lightning",
|
|
652
|
+
amountSats,
|
|
653
|
+
maxFeeSats,
|
|
654
|
+
receiver: payee,
|
|
655
|
+
description: request.description
|
|
656
|
+
},
|
|
657
|
+
checks: feeCheck(amountSats, maxFeeSats),
|
|
658
|
+
staleReason: (now) => expiresAt <= now + 6e4 ? "lightning invoice expired" : void 0,
|
|
659
|
+
execute: async (spark, verify) => ({
|
|
660
|
+
kind: "lightning",
|
|
661
|
+
payment: await spark.payInvoice(ref, {
|
|
662
|
+
invoice,
|
|
663
|
+
maxFeeSats,
|
|
664
|
+
verify
|
|
665
|
+
})
|
|
666
|
+
})
|
|
667
|
+
};
|
|
668
|
+
}
|
|
669
|
+
default: {
|
|
670
|
+
if (params.amountSats === void 0) throw new PaymentError("invalid-input", "bitcoin payment: amountSats is required");
|
|
671
|
+
const { address } = params.option;
|
|
672
|
+
const { amountSats, maxFeeSats } = params;
|
|
673
|
+
const speed = params.speed ?? "MEDIUM";
|
|
674
|
+
return {
|
|
675
|
+
display: {
|
|
676
|
+
family: "spark",
|
|
677
|
+
network,
|
|
678
|
+
kind: "exit",
|
|
679
|
+
amountSats,
|
|
680
|
+
...maxFeeSats === void 0 ? {} : { maxFeeSats },
|
|
681
|
+
receiver: address
|
|
682
|
+
},
|
|
683
|
+
checks: feeCheck(amountSats, maxFeeSats),
|
|
684
|
+
staleReason: () => void 0,
|
|
685
|
+
execute: async (spark, verify) => ({
|
|
686
|
+
kind: "withdraw",
|
|
687
|
+
exit: await spark.withdraw(ref, {
|
|
688
|
+
address,
|
|
689
|
+
speed,
|
|
690
|
+
verify,
|
|
691
|
+
amountSats,
|
|
692
|
+
...maxFeeSats === void 0 ? {} : { maxFeeSats }
|
|
693
|
+
})
|
|
694
|
+
})
|
|
695
|
+
};
|
|
696
|
+
}
|
|
697
|
+
}
|
|
698
|
+
}
|
|
699
|
+
/**
|
|
700
|
+
* LNURL-withdraw: asks the service to pay an invoice this wallet creates. It receives funds, so it
|
|
701
|
+
* needs no verification factor and does not go through the request pipeline.
|
|
702
|
+
*/
|
|
703
|
+
async function withdrawLnurl(context, params) {
|
|
704
|
+
const { request } = params.option;
|
|
705
|
+
const amountMsat = params.amountSats * 1e3;
|
|
706
|
+
if (amountMsat < request.minWithdrawableMsat || amountMsat > request.maxWithdrawableMsat) throw new PaymentError("rejected", "lnurl-withdraw: amount outside the service's range");
|
|
707
|
+
const invoice = await context.spark.createInvoice(params.account, {
|
|
708
|
+
amountSats: params.amountSats,
|
|
709
|
+
memo: request.defaultDescription
|
|
710
|
+
});
|
|
711
|
+
try {
|
|
712
|
+
await submitWithdraw(request, {
|
|
713
|
+
fetch: context.fetch,
|
|
714
|
+
invoice: invoice.invoice
|
|
715
|
+
});
|
|
716
|
+
} catch (error) {
|
|
717
|
+
if (error instanceof PaymentError || error instanceof SparkControllerError) throw error;
|
|
718
|
+
throw new PaymentError("network", "lnurl-withdraw: submission failed", error);
|
|
719
|
+
}
|
|
720
|
+
return {
|
|
721
|
+
kind: "lnurl-withdraw",
|
|
722
|
+
invoice
|
|
723
|
+
};
|
|
724
|
+
}
|
|
725
|
+
// src/payments/zap.ts
|
|
726
|
+
/** Everything up to payment: resolve LNURL, sign the 9734, request the invoice. */
|
|
727
|
+
async function prepareZap(params, context) {
|
|
728
|
+
const amountMsat = params.amountSats * 1e3;
|
|
729
|
+
if (!Number.isSafeInteger(params.amountSats) || params.amountSats <= 0 || !Number.isSafeInteger(amountMsat)) throw new PaymentError("invalid-input", "zap amount is not a positive safe integer");
|
|
730
|
+
try {
|
|
731
|
+
const resolved = await resolveLnurl(params.recipient.lightning, { fetch: context.fetch });
|
|
732
|
+
if (resolved.tag !== "payRequest") throw new PaymentError("rejected", "recipient is not an LNURL-pay endpoint");
|
|
733
|
+
if (!resolved.allowsNostr || resolved.nostrPubkey === void 0) throw new PaymentError("rejected", "LNURL service does not accept nostr zaps");
|
|
734
|
+
if (amountMsat < resolved.minSendableMsat || amountMsat > resolved.maxSendableMsat) throw new PaymentError("rejected", "zap amount outside the service's bounds");
|
|
735
|
+
if (params.comment !== void 0) {
|
|
736
|
+
if (resolved.commentAllowed === 0) throw new PaymentError("rejected", "the service does not accept comments");
|
|
737
|
+
if (params.comment.length > resolved.commentAllowed) throw new PaymentError("rejected", "comment exceeds the service's limit");
|
|
738
|
+
}
|
|
739
|
+
const lnurl = params.recipient.lightning.toLowerCase().startsWith("lnurl1") ? params.recipient.lightning.toLowerCase() : encodeLnurl(lightningAddressUrl(params.recipient.lightning));
|
|
740
|
+
const base = {
|
|
741
|
+
amount: amountMsat,
|
|
742
|
+
relays: [...params.relays],
|
|
743
|
+
comment: params.comment,
|
|
744
|
+
lnurl
|
|
745
|
+
};
|
|
746
|
+
const unsigned = {
|
|
747
|
+
..."event" in params.recipient ? makeZapRequest({
|
|
748
|
+
event: params.recipient.event,
|
|
749
|
+
...base
|
|
750
|
+
}) : makeZapRequest({
|
|
751
|
+
pubkey: params.recipient.pubkey,
|
|
752
|
+
...base
|
|
753
|
+
}),
|
|
754
|
+
pubkey: await params.signer.getPublicKey()
|
|
755
|
+
};
|
|
756
|
+
const zapRequest = await params.signer.signEvent(unsigned);
|
|
757
|
+
if (!verifyEvent(zapRequest) || !signedMatchesUnsigned(zapRequest, unsigned)) throw new PaymentError("rejected", "signer returned a zap request that does not match");
|
|
758
|
+
const { invoice } = await requestInvoice(resolved, {
|
|
759
|
+
fetch: context.fetch,
|
|
760
|
+
network: params.network,
|
|
761
|
+
amountMsat,
|
|
762
|
+
...params.comment === void 0 ? {} : { comment: params.comment },
|
|
763
|
+
zapRequest: JSON.stringify(zapRequest)
|
|
764
|
+
});
|
|
765
|
+
const decoded = decodeBolt11(invoice);
|
|
766
|
+
const expiresAt = (decoded.timestamp + decoded.expirySeconds) * 1e3;
|
|
767
|
+
if (expiresAt <= context.now() + 6e4) throw new PaymentError("rejected", "zap invoice expires too soon");
|
|
768
|
+
const eventId = "event" in params.recipient ? params.recipient.event.id : void 0;
|
|
769
|
+
return {
|
|
770
|
+
zapRequest,
|
|
771
|
+
invoice,
|
|
772
|
+
paymentHash: bytesToHex(decoded.paymentHash),
|
|
773
|
+
expiresAt,
|
|
774
|
+
amountSats: params.amountSats,
|
|
775
|
+
receiptPubkey: bytesToHex(resolved.nostrPubkey),
|
|
776
|
+
recipientPubkey: "event" in params.recipient ? params.recipient.event.pubkey : params.recipient.pubkey,
|
|
777
|
+
...eventId === void 0 ? {} : { eventId },
|
|
778
|
+
...params.comment === void 0 ? {} : { comment: params.comment },
|
|
779
|
+
lightning: params.recipient.lightning
|
|
780
|
+
};
|
|
781
|
+
} catch (error) {
|
|
782
|
+
if (error instanceof PaymentError) throw error;
|
|
783
|
+
if (error instanceof SparkInputError || error instanceof Error && error.cause instanceof SparkInputError) throw new PaymentError("rejected", "zap failed invoice validation");
|
|
784
|
+
throw new PaymentError(categorizePayment(error), "zap failed");
|
|
785
|
+
}
|
|
786
|
+
}
|
|
787
|
+
/**
|
|
788
|
+
* Fixes a prepared zap for approval: the invoice (fetched by {@link prepareZap}) is paid as-is, and
|
|
789
|
+
* the zap note is written under the payment's transfer id afterwards. A nearly-expired invoice is
|
|
790
|
+
* refused here and again at approval — the user may sit on the confirm screen.
|
|
791
|
+
*/
|
|
792
|
+
function prepareZapAction(account, prepared, options) {
|
|
793
|
+
if (prepared.expiresAt <= options.now() + 6e4) throw new PaymentError("rejected", "zap invoice expired");
|
|
794
|
+
const { maxFeeSats } = options;
|
|
795
|
+
const { payee } = lightningInvoiceDetails(prepared.invoice);
|
|
796
|
+
return {
|
|
797
|
+
display: {
|
|
798
|
+
family: "spark",
|
|
799
|
+
network: account.network,
|
|
800
|
+
kind: "lightning",
|
|
801
|
+
amountSats: prepared.amountSats,
|
|
802
|
+
maxFeeSats,
|
|
803
|
+
receiver: payee,
|
|
804
|
+
...prepared.comment === void 0 ? {} : { description: prepared.comment }
|
|
805
|
+
},
|
|
806
|
+
checks: feeCheck(prepared.amountSats, maxFeeSats),
|
|
807
|
+
staleReason: (now) => prepared.expiresAt <= now + 6e4 ? "zap invoice expired" : void 0,
|
|
808
|
+
execute: async (spark, verify) => ({
|
|
809
|
+
kind: "lightning",
|
|
810
|
+
payment: await spark.payInvoice(account, {
|
|
811
|
+
invoice: prepared.invoice,
|
|
812
|
+
maxFeeSats,
|
|
813
|
+
verify,
|
|
814
|
+
note: {
|
|
815
|
+
kind: "zap",
|
|
816
|
+
recipientPubkey: prepared.recipientPubkey,
|
|
817
|
+
...prepared.eventId === void 0 ? {} : { eventId: prepared.eventId },
|
|
818
|
+
...prepared.comment === void 0 ? {} : { comment: prepared.comment },
|
|
819
|
+
lightning: prepared.lightning
|
|
820
|
+
}
|
|
821
|
+
})
|
|
822
|
+
})
|
|
823
|
+
};
|
|
824
|
+
}
|
|
825
|
+
const lightningInvoiceDetails = (invoice) => ({ payee: bytesToHex(decodeBolt11(invoice).payee) });
|
|
826
|
+
function categorizePayment(error) {
|
|
827
|
+
return categorize(error) === "network" ? "network" : "rejected";
|
|
828
|
+
}
|
|
829
|
+
// src/spark/l1.ts
|
|
830
|
+
/** The bitcoin network each Spark network settles on; deposits and exits must agree with it. */
|
|
831
|
+
const SPARK_L1_NETWORK = {
|
|
832
|
+
mainnet: "bip122:000000000019d6689c085ae165831e93",
|
|
833
|
+
regtest: "bip122:0f9188f13cb7b2c71f2a335e3a4fc328"
|
|
834
|
+
};
|
|
835
|
+
// src/transactions/route.ts
|
|
836
|
+
const hexId = (prefix, random) => `${prefix}-${[...random(8)].map((b) => b.toString(16).padStart(2, "0")).join("")}`;
|
|
837
|
+
const newId = (random) => hexId("tx", random);
|
|
838
|
+
const newRequestId = (random) => hexId("req", random);
|
|
839
|
+
const errorMessage$1 = (error) => error instanceof Error ? error.message : String(error);
|
|
840
|
+
const LIVE_REPLACEMENT_STATES = /* @__PURE__ */ new Set([
|
|
841
|
+
"draft",
|
|
842
|
+
"signed",
|
|
843
|
+
"submitted",
|
|
844
|
+
"pending"
|
|
845
|
+
]);
|
|
846
|
+
/**
|
|
847
|
+
* Approval-time check for a replacement: the original must still be unconfirmed and no other
|
|
848
|
+
* replacement of it may already be on its way.
|
|
849
|
+
*/
|
|
850
|
+
const replacementStaleReason = (ctx, originalId) => {
|
|
851
|
+
const txs = ctx.txs();
|
|
852
|
+
const original = txs[originalId];
|
|
853
|
+
if (original?.state !== "submitted" && original?.state !== "pending") return "original transaction already finished — nothing to replace";
|
|
854
|
+
return Object.values(txs).some((t) => t.replaces === originalId && LIVE_REPLACEMENT_STATES.has(t.state)) ? "original transaction was already replaced — start over" : void 0;
|
|
855
|
+
};
|
|
856
|
+
const submitTx = async (ctx, tx, hash) => {
|
|
857
|
+
const tracked = await ctx.transition(tx, {
|
|
858
|
+
state: "submitted",
|
|
859
|
+
hash,
|
|
860
|
+
submittedAt: ctx.options.now()
|
|
861
|
+
});
|
|
862
|
+
ctx.track(tracked.id, hash);
|
|
863
|
+
return tracked;
|
|
864
|
+
};
|
|
865
|
+
const failTx = async (ctx, tx, error) => {
|
|
866
|
+
const failed = tx.state === "signed" ? "failed" : "rejected";
|
|
867
|
+
await ctx.transition(tx, {
|
|
868
|
+
state: failed,
|
|
869
|
+
error: errorMessage$1(error)
|
|
870
|
+
});
|
|
871
|
+
};
|
|
872
|
+
// src/requests/checks.ts
|
|
873
|
+
/** Two reads of one fee differ by more than this share of the smaller one. */
|
|
874
|
+
const FEE_MISMATCH_PERCENT = 25n;
|
|
875
|
+
/** A fee ceiling above this multiple of the independent estimate is flagged. */
|
|
876
|
+
const FEE_CAP_MULTIPLIER = 2n;
|
|
877
|
+
/**
|
|
878
|
+
* `|a − b| / min(a, b)` is strictly above {@link FEE_MISMATCH_PERCENT}; a zero against a non-zero
|
|
879
|
+
* always is.
|
|
880
|
+
*/
|
|
881
|
+
const feesDiffer = (a, b) => {
|
|
882
|
+
const low = a < b ? a : b;
|
|
883
|
+
return ((a < b ? b : a) - low) * 100n > low * FEE_MISMATCH_PERCENT;
|
|
884
|
+
};
|
|
885
|
+
/**
|
|
886
|
+
* The final ceiling exceeds {@link FEE_CAP_MULTIPLIER} times the independent estimate of the same
|
|
887
|
+
* tier.
|
|
888
|
+
*/
|
|
889
|
+
const exceedsNetworkCap = (final, independent) => final > independent * FEE_CAP_MULTIPLIER;
|
|
890
|
+
/** The worst-case fee is not below the amount sent (native-asset sends only). */
|
|
891
|
+
const feeReachesAmount = (maxFee, amount) => maxFee >= amount;
|
|
892
|
+
const min = (a, b) => a < b ? a : b;
|
|
893
|
+
const max = (a, b) => a > b ? a : b;
|
|
894
|
+
/** EIP-1559 quotes are `maxFee = baseFee × 2 + priority`; this recovers the base fee. */
|
|
895
|
+
const baseFeeOf = (quote) => (quote.maxFeePerGas - (quote.maxPriorityFeePerGas ?? 0n)) / 2n;
|
|
896
|
+
/**
|
|
897
|
+
* EIP-1559: priority fee is the lower of the two, `maxFeePerGas = max(baseFee) × 2 + priority` (a
|
|
898
|
+
* high ceiling only prevents a stuck transaction, the base fee actually paid is the chain's).
|
|
899
|
+
* Legacy: the lower gas price. Two different shapes cannot be merged — the independent quote wins.
|
|
900
|
+
*/
|
|
901
|
+
const mergeEvmQuote = (primary, independent) => {
|
|
902
|
+
const a = primary.maxPriorityFeePerGas;
|
|
903
|
+
const b = independent.maxPriorityFeePerGas;
|
|
904
|
+
if (a !== void 0 && b !== void 0) {
|
|
905
|
+
const priority = min(a, b);
|
|
906
|
+
const maxFeePerGas = max(baseFeeOf(primary), baseFeeOf(independent)) * 2n + priority;
|
|
907
|
+
return {
|
|
908
|
+
quote: {
|
|
909
|
+
tier: primary.tier,
|
|
910
|
+
wei: maxFeePerGas,
|
|
911
|
+
maxFeePerGas,
|
|
912
|
+
maxPriorityFeePerGas: priority
|
|
913
|
+
},
|
|
914
|
+
mismatch: feesDiffer(a, b) || feesDiffer(baseFeeOf(primary), baseFeeOf(independent))
|
|
915
|
+
};
|
|
916
|
+
}
|
|
917
|
+
if (a === void 0 && b === void 0) {
|
|
918
|
+
const price = min(primary.maxFeePerGas, independent.maxFeePerGas);
|
|
919
|
+
return {
|
|
920
|
+
quote: {
|
|
921
|
+
tier: primary.tier,
|
|
922
|
+
wei: price,
|
|
923
|
+
maxFeePerGas: price,
|
|
924
|
+
maxPriorityFeePerGas: void 0
|
|
925
|
+
},
|
|
926
|
+
mismatch: feesDiffer(primary.maxFeePerGas, independent.maxFeePerGas)
|
|
927
|
+
};
|
|
928
|
+
}
|
|
929
|
+
return {
|
|
930
|
+
quote: independent,
|
|
931
|
+
mismatch: true
|
|
932
|
+
};
|
|
933
|
+
};
|
|
934
|
+
const mergeSolanaQuote = (primary, independent) => ({
|
|
935
|
+
quote: {
|
|
936
|
+
tier: primary.tier,
|
|
937
|
+
microLamportsPerCu: min(primary.microLamportsPerCu, independent.microLamportsPerCu)
|
|
938
|
+
},
|
|
939
|
+
mismatch: feesDiffer(primary.microLamportsPerCu, independent.microLamportsPerCu)
|
|
940
|
+
});
|
|
941
|
+
/** Bitcoin fee rates (sat/vB) per confirmation target. */
|
|
942
|
+
const mergeSatPerByte = (primary, independent) => ({
|
|
943
|
+
rate: Math.min(primary, independent),
|
|
944
|
+
mismatch: feesDiffer(BigInt(Math.ceil(primary)), BigInt(Math.ceil(independent)))
|
|
945
|
+
});
|
|
946
|
+
/** The checks a prepared build carries, in a stable order and without duplicates. */
|
|
947
|
+
const collectChecks = (flags) => [...new Set(flags.filter((f) => f !== void 0))];
|
|
948
|
+
/** EVM addresses compare without the `0x` prefix and case-insensitively. */
|
|
949
|
+
const comparable = (family, address) => family === "evm" ? address.replace(/^0x/i, "").toLowerCase() : address;
|
|
950
|
+
const looksLike = (a, b) => a !== b && a.length >= 8 && b.length >= 8 && a.slice(0, 4) === b.slice(0, 4) && a.slice(-4) === b.slice(-4);
|
|
951
|
+
/** Findings for every recipient of one request, deduplicated, `danger` first. */
|
|
952
|
+
function assessRecipients(family, recipients, known) {
|
|
953
|
+
const knownSet = new Set(known.map((a) => comparable(family, a)));
|
|
954
|
+
const found = /* @__PURE__ */ new Map();
|
|
955
|
+
for (const recipient of recipients) {
|
|
956
|
+
const candidate = comparable(family, recipient);
|
|
957
|
+
if (knownSet.has(candidate)) continue;
|
|
958
|
+
found.set("first-recipient", {
|
|
959
|
+
code: "first-recipient",
|
|
960
|
+
level: "warning"
|
|
961
|
+
});
|
|
962
|
+
if ([...knownSet].some((k) => looksLike(candidate, k))) found.set("address-poisoning", {
|
|
963
|
+
code: "address-poisoning",
|
|
964
|
+
level: "danger"
|
|
965
|
+
});
|
|
966
|
+
}
|
|
967
|
+
return [...found.values()].sort((a, b) => Number(b.level === "danger") - Number(a.level === "danger"));
|
|
968
|
+
}
|
|
969
|
+
// src/requests/sign-log.ts
|
|
970
|
+
const SIGN_LOG_LIMIT = 1e3;
|
|
971
|
+
const COLLECTION$3 = "sign-log";
|
|
972
|
+
const isObject$13 = (v) => typeof v === "object" && v !== null;
|
|
973
|
+
const METHODS = /* @__PURE__ */ new Set([
|
|
974
|
+
"send",
|
|
975
|
+
"speed-up",
|
|
976
|
+
"cancel",
|
|
977
|
+
"cpfp",
|
|
978
|
+
"pay",
|
|
979
|
+
"zap",
|
|
980
|
+
"evm-sign",
|
|
981
|
+
"spark-deposit",
|
|
982
|
+
"spark-exit"
|
|
983
|
+
]);
|
|
984
|
+
const OUTCOMES = /* @__PURE__ */ new Set([
|
|
985
|
+
"submitted",
|
|
986
|
+
"rejected",
|
|
987
|
+
"failed",
|
|
988
|
+
"expired",
|
|
989
|
+
"invalidated"
|
|
990
|
+
]);
|
|
991
|
+
const FAMILIES = /* @__PURE__ */ new Set([
|
|
992
|
+
"evm",
|
|
993
|
+
"solana",
|
|
994
|
+
"bitcoin"
|
|
995
|
+
]);
|
|
996
|
+
const isAccount = (value) => {
|
|
997
|
+
if (!isObject$13(value)) return false;
|
|
998
|
+
if (value["kind"] === "hd") return typeof value["walletId"] === "string" && typeof value["groupIndex"] === "number" && typeof value["template"] === "string";
|
|
999
|
+
if (value["kind"] === "imported") return typeof value["keyId"] === "string" && FAMILIES.has(value["family"]);
|
|
1000
|
+
return value["kind"] === void 0 && typeof value["walletId"] === "string" && typeof value["groupIndex"] === "number" && typeof value["network"] === "string";
|
|
1001
|
+
};
|
|
1002
|
+
const isOrigin = (value) => isObject$13(value) && (value["kind"] === "internal" || value["kind"] === "browser" && typeof value["origin"] === "string");
|
|
1003
|
+
const isEntry = (value) => isObject$13(value) && typeof value["id"] === "string" && typeof value["at"] === "number" && isOrigin(value["origin"]) && METHODS.has(value["method"]) && OUTCOMES.has(value["outcome"]) && isAccount(value["account"]);
|
|
1004
|
+
/** Sortable key: time first, request id as the tiebreak. */
|
|
1005
|
+
const keyOf = (entry) => `${String(entry.at).padStart(16, "0")}-${entry.id}`;
|
|
1006
|
+
function createSignLog(logger) {
|
|
1007
|
+
let store;
|
|
1008
|
+
return {
|
|
1009
|
+
open: (next) => {
|
|
1010
|
+
store = next;
|
|
1011
|
+
},
|
|
1012
|
+
close: () => {
|
|
1013
|
+
store = void 0;
|
|
1014
|
+
},
|
|
1015
|
+
append: async (entry) => {
|
|
1016
|
+
const current = store;
|
|
1017
|
+
if (current === void 0) {
|
|
1018
|
+
logger.warn(`sign log entry for request ${entry.id} (${entry.outcome}) not written: vault locked`);
|
|
1019
|
+
return;
|
|
1020
|
+
}
|
|
1021
|
+
const collection = current.collection(COLLECTION$3);
|
|
1022
|
+
await collection.put(keyOf(entry), new TextEncoder().encode(JSON.stringify(entry)));
|
|
1023
|
+
const keys = [...await collection.list()].sort();
|
|
1024
|
+
await Promise.all(keys.slice(0, Math.max(0, keys.length - SIGN_LOG_LIMIT)).map(async (k) => collection.delete(k)));
|
|
1025
|
+
},
|
|
1026
|
+
list: async () => {
|
|
1027
|
+
if (store === void 0) return [];
|
|
1028
|
+
const collection = store.collection(COLLECTION$3);
|
|
1029
|
+
const keys = [...await collection.list()].sort();
|
|
1030
|
+
const rows = await Promise.all(keys.map(async (k) => collection.get(k)));
|
|
1031
|
+
const entries = [];
|
|
1032
|
+
for (const raw of rows) {
|
|
1033
|
+
if (raw === void 0) continue;
|
|
1034
|
+
const parsed = JSON.parse(new TextDecoder().decode(raw));
|
|
1035
|
+
if (isEntry(parsed)) entries.push(parsed);
|
|
1036
|
+
}
|
|
1037
|
+
return entries.reverse();
|
|
1038
|
+
},
|
|
1039
|
+
clear: async () => {
|
|
1040
|
+
if (store === void 0) return;
|
|
1041
|
+
const collection = store.collection(COLLECTION$3);
|
|
1042
|
+
const keys = [...await collection.list()].sort();
|
|
1043
|
+
await Promise.all(keys.map(async (k) => collection.delete(k)));
|
|
1044
|
+
}
|
|
1045
|
+
};
|
|
1046
|
+
}
|
|
1047
|
+
// src/requests/controller.ts
|
|
1048
|
+
/** A request that ended without a result: `stage` says how, `message` is the failure if any. */
|
|
1049
|
+
var RequestEndedError = class extends Error {
|
|
1050
|
+
stage;
|
|
1051
|
+
constructor(stage, message) {
|
|
1052
|
+
super(message ?? `request ${stage}`);
|
|
1053
|
+
this.name = "RequestEndedError";
|
|
1054
|
+
this.stage = stage;
|
|
1055
|
+
}
|
|
1056
|
+
};
|
|
1057
|
+
/** The origin already has a request in flight; a dApp gets one at a time. */
|
|
1058
|
+
var OriginBusyError = class extends Error {
|
|
1059
|
+
constructor(origin) {
|
|
1060
|
+
super(`${origin} already has an unfinished request`);
|
|
1061
|
+
this.name = "OriginBusyError";
|
|
1062
|
+
}
|
|
1063
|
+
};
|
|
1064
|
+
/** Requests expire this long after creation. */
|
|
1065
|
+
const REQUEST_TTL_MS = 6e5;
|
|
1066
|
+
const originKey = (origin) => `origin:${origin.origin}`;
|
|
1067
|
+
const LIVE = /* @__PURE__ */ new Set(["awaiting-approval", "signing"]);
|
|
1068
|
+
const errorMessage = (error) => error instanceof Error ? error.message : String(error);
|
|
1069
|
+
var RequestController = class extends Controller {
|
|
1070
|
+
options;
|
|
1071
|
+
entries = /* @__PURE__ */ new Map();
|
|
1072
|
+
log;
|
|
1073
|
+
settlers = /* @__PURE__ */ new Map();
|
|
1074
|
+
/** Original transaction id → the unfinished request replacing or accelerating it. */
|
|
1075
|
+
targets = /* @__PURE__ */ new Map();
|
|
1076
|
+
locked = true;
|
|
1077
|
+
/** Bumped on every unlock and lock so work that outlives a session never touches the next one. */
|
|
1078
|
+
session = 0;
|
|
1079
|
+
constructor(options) {
|
|
1080
|
+
super({
|
|
1081
|
+
name: "requests",
|
|
1082
|
+
defaultState: () => ({ requests: {} })
|
|
1083
|
+
});
|
|
1084
|
+
this.options = options;
|
|
1085
|
+
this.log = createSignLog(options.logger);
|
|
1086
|
+
}
|
|
1087
|
+
async init() {
|
|
1088
|
+
this.session += 1;
|
|
1089
|
+
this.locked = false;
|
|
1090
|
+
this.entries.clear();
|
|
1091
|
+
this.targets.clear();
|
|
1092
|
+
this.settlers.clear();
|
|
1093
|
+
this.update(() => ({ requests: {} }));
|
|
1094
|
+
this.log.open(await this.options.openStore());
|
|
1095
|
+
}
|
|
1096
|
+
async destroy() {
|
|
1097
|
+
this.lock();
|
|
1098
|
+
await Promise.resolve();
|
|
1099
|
+
}
|
|
1100
|
+
/** Locking is not consent: every unfinished request becomes `invalidated` and frees its resources. */
|
|
1101
|
+
lock() {
|
|
1102
|
+
this.session += 1;
|
|
1103
|
+
this.locked = true;
|
|
1104
|
+
for (const [id, request] of Object.entries(this.getState().requests)) if (LIVE.has(request.stage)) this.finish(id, "invalidated");
|
|
1105
|
+
this.log.close();
|
|
1106
|
+
}
|
|
1107
|
+
/** A page navigated away, was revoked or changed network: its unfinished requests are void. */
|
|
1108
|
+
invalidateOrigin(origin) {
|
|
1109
|
+
for (const [id, request] of Object.entries(this.getState().requests)) if (LIVE.has(request.stage) && request.origin.kind === "browser" && request.origin.origin === origin) this.finish(id, "invalidated");
|
|
1110
|
+
}
|
|
1111
|
+
/** The signature record, newest first. */
|
|
1112
|
+
async signLog() {
|
|
1113
|
+
return this.log.list();
|
|
1114
|
+
}
|
|
1115
|
+
async clearSignLog() {
|
|
1116
|
+
await this.log.clear();
|
|
1117
|
+
}
|
|
1118
|
+
async prepare(input) {
|
|
1119
|
+
if (this.locked) throw new Error("requests require an unlocked vault");
|
|
1120
|
+
const { session } = this;
|
|
1121
|
+
const id = newRequestId(this.options.random);
|
|
1122
|
+
const target = "txId" in input ? input.txId : void 0;
|
|
1123
|
+
if (input.method === "evm-sign") {
|
|
1124
|
+
const key = originKey(input.origin);
|
|
1125
|
+
if (this.targets.has(key)) throw new OriginBusyError(input.origin.origin);
|
|
1126
|
+
this.targets.set(key, id);
|
|
1127
|
+
}
|
|
1128
|
+
if (target !== void 0) {
|
|
1129
|
+
if (this.targets.has(target)) throw new Error(`transaction ${target} already has an unfinished replacement request`);
|
|
1130
|
+
this.targets.set(target, id);
|
|
1131
|
+
}
|
|
1132
|
+
let built;
|
|
1133
|
+
try {
|
|
1134
|
+
built = await this.build(input, id);
|
|
1135
|
+
} catch (error) {
|
|
1136
|
+
this.unclaim(id);
|
|
1137
|
+
throw error;
|
|
1138
|
+
}
|
|
1139
|
+
if (session !== this.session) {
|
|
1140
|
+
built.run.release();
|
|
1141
|
+
this.unclaim(id);
|
|
1142
|
+
throw new Error("the vault was locked while the request was being prepared");
|
|
1143
|
+
}
|
|
1144
|
+
const { checks } = built;
|
|
1145
|
+
const createdAt = this.options.now();
|
|
1146
|
+
const request = {
|
|
1147
|
+
id,
|
|
1148
|
+
origin: input.method === "evm-sign" ? input.origin : { kind: "internal" },
|
|
1149
|
+
method: input.method,
|
|
1150
|
+
account: built.account,
|
|
1151
|
+
network: built.network,
|
|
1152
|
+
...target === void 0 ? {} : { target },
|
|
1153
|
+
stage: "awaiting-approval",
|
|
1154
|
+
display: built.display,
|
|
1155
|
+
verification: checks.length > 0 ? "unverifiable" : "verified",
|
|
1156
|
+
checks,
|
|
1157
|
+
risk: built.risk,
|
|
1158
|
+
createdAt,
|
|
1159
|
+
expiresAt: createdAt + REQUEST_TTL_MS
|
|
1160
|
+
};
|
|
1161
|
+
const stopTimer = this.options.setTimer(REQUEST_TTL_MS, () => {
|
|
1162
|
+
if (this.getState().requests[id]?.stage === "awaiting-approval") this.finish(id, "expired");
|
|
1163
|
+
});
|
|
1164
|
+
this.entries.set(id, {
|
|
1165
|
+
prepared: built.run,
|
|
1166
|
+
stopTimer
|
|
1167
|
+
});
|
|
1168
|
+
this.put(request);
|
|
1169
|
+
this.openSettler(id);
|
|
1170
|
+
return request;
|
|
1171
|
+
}
|
|
1172
|
+
/**
|
|
1173
|
+
* Resolves with what `approve` returned, or rejects with `RequestEndedError` when the request
|
|
1174
|
+
* ends any other way — how a dApp's pending call learns the outcome.
|
|
1175
|
+
*/
|
|
1176
|
+
async outcome(id) {
|
|
1177
|
+
const settler = this.settlers.get(id);
|
|
1178
|
+
if (settler === void 0) throw new Error(`unknown request ${id}`);
|
|
1179
|
+
return settler.promise;
|
|
1180
|
+
}
|
|
1181
|
+
openSettler(id) {
|
|
1182
|
+
const holder = {};
|
|
1183
|
+
const promise = new Promise((resolve, reject) => {
|
|
1184
|
+
holder.resolve = resolve;
|
|
1185
|
+
holder.reject = reject;
|
|
1186
|
+
});
|
|
1187
|
+
Promise.allSettled([promise]);
|
|
1188
|
+
this.settlers.set(id, {
|
|
1189
|
+
promise,
|
|
1190
|
+
resolve: (value) => holder.resolve?.(value),
|
|
1191
|
+
reject: (error) => holder.reject?.(error)
|
|
1192
|
+
});
|
|
1193
|
+
}
|
|
1194
|
+
settle(id, result) {
|
|
1195
|
+
const settler = this.settlers.get(id);
|
|
1196
|
+
if (settler === void 0) return;
|
|
1197
|
+
if (result instanceof Error) settler.reject(result);
|
|
1198
|
+
else settler.resolve(result);
|
|
1199
|
+
}
|
|
1200
|
+
/** The bitcoin network `account`'s Spark network settles on; any other pairing is refused. */
|
|
1201
|
+
l1Network(account) {
|
|
1202
|
+
const network = SPARK_L1_NETWORK[account.network];
|
|
1203
|
+
if (network === void 0) throw new Error(`unknown Spark network ${account.network}`);
|
|
1204
|
+
return network;
|
|
1205
|
+
}
|
|
1206
|
+
sparkBuilt(account, action) {
|
|
1207
|
+
return {
|
|
1208
|
+
account,
|
|
1209
|
+
network: action.display.network,
|
|
1210
|
+
display: action.display,
|
|
1211
|
+
checks: collectChecks(action.checks),
|
|
1212
|
+
risk: this.sparkRisk(action.display),
|
|
1213
|
+
run: {
|
|
1214
|
+
staleReason: () => action.staleReason(this.options.now()),
|
|
1215
|
+
submit: async (verify) => action.execute(this.options.spark, verify),
|
|
1216
|
+
release: () => void 0
|
|
1217
|
+
}
|
|
1218
|
+
};
|
|
1219
|
+
}
|
|
1220
|
+
async build(input, id) {
|
|
1221
|
+
if (input.method === "pay" || input.method === "zap") {
|
|
1222
|
+
const action = input.method === "pay" ? await preparePay({
|
|
1223
|
+
fetch: this.options.fetch,
|
|
1224
|
+
now: this.options.now
|
|
1225
|
+
}, input) : prepareZapAction(input.account, input.zap, {
|
|
1226
|
+
maxFeeSats: input.maxFeeSats,
|
|
1227
|
+
now: this.options.now
|
|
1228
|
+
});
|
|
1229
|
+
return this.sparkBuilt(input.account, action);
|
|
1230
|
+
}
|
|
1231
|
+
if (input.method === "spark-deposit") {
|
|
1232
|
+
const l1 = this.l1Network(input.account);
|
|
1233
|
+
if (input.from.kind !== "hd" || input.from.walletId !== input.account.walletId) throw new Error("a Spark deposit must come from the same wallet");
|
|
1234
|
+
const to = await this.options.spark.newDepositAddress(input.account);
|
|
1235
|
+
const prepared = await this.options.transactions.prepareSend({
|
|
1236
|
+
account: input.from,
|
|
1237
|
+
network: l1,
|
|
1238
|
+
to,
|
|
1239
|
+
amount: BigInt(input.amountSats),
|
|
1240
|
+
fee: input.fee
|
|
1241
|
+
}, id, {
|
|
1242
|
+
walletId: input.account.walletId,
|
|
1243
|
+
groupIndex: input.account.groupIndex,
|
|
1244
|
+
network: input.account.network
|
|
1245
|
+
});
|
|
1246
|
+
return {
|
|
1247
|
+
account: prepared.account,
|
|
1248
|
+
network: prepared.network,
|
|
1249
|
+
display: prepared.intent,
|
|
1250
|
+
checks: collectChecks([prepared.intent.verifiability === "full" ? void 0 : "decode-partial", ...prepared.checks]),
|
|
1251
|
+
risk: [],
|
|
1252
|
+
run: prepared
|
|
1253
|
+
};
|
|
1254
|
+
}
|
|
1255
|
+
if (input.method === "spark-exit") {
|
|
1256
|
+
const l1 = this.l1Network(input.account);
|
|
1257
|
+
const address = await this.options.transactions.nextReceiveAddress(input.account.walletId, input.account.groupIndex, l1);
|
|
1258
|
+
const action = await preparePay({
|
|
1259
|
+
fetch: this.options.fetch,
|
|
1260
|
+
now: this.options.now
|
|
1261
|
+
}, {
|
|
1262
|
+
account: input.account,
|
|
1263
|
+
option: {
|
|
1264
|
+
kind: "bitcoin",
|
|
1265
|
+
address
|
|
1266
|
+
},
|
|
1267
|
+
amountSats: input.amountSats,
|
|
1268
|
+
maxFeeSats: input.maxFeeSats,
|
|
1269
|
+
...input.speed === void 0 ? {} : { speed: input.speed }
|
|
1270
|
+
});
|
|
1271
|
+
return this.sparkBuilt(input.account, action);
|
|
1272
|
+
}
|
|
1273
|
+
if (input.method === "evm-sign") {
|
|
1274
|
+
const prepared = await this.options.transactions.prepareDapp(input.account, input.request);
|
|
1275
|
+
const isTransaction = input.request.kind === "evm.transaction";
|
|
1276
|
+
return {
|
|
1277
|
+
account: prepared.account,
|
|
1278
|
+
network: prepared.network,
|
|
1279
|
+
display: prepared.intent,
|
|
1280
|
+
checks: collectChecks([isTransaction && prepared.intent.verifiability !== "full" ? "decode-partial" : void 0, ...prepared.checks]),
|
|
1281
|
+
risk: isTransaction ? this.chainRisk(prepared.intent) : [],
|
|
1282
|
+
run: prepared
|
|
1283
|
+
};
|
|
1284
|
+
}
|
|
1285
|
+
const prepared = input.method === "send" ? await this.options.transactions.prepareSend(input, id) : await this.options.transactions.prepareReplacement(input, id);
|
|
1286
|
+
return {
|
|
1287
|
+
account: prepared.account,
|
|
1288
|
+
network: prepared.network,
|
|
1289
|
+
display: prepared.intent,
|
|
1290
|
+
checks: collectChecks([prepared.intent.verifiability === "full" ? void 0 : "decode-partial", ...prepared.checks]),
|
|
1291
|
+
risk: input.method === "send" ? this.chainRisk(prepared.intent) : [],
|
|
1292
|
+
run: prepared
|
|
1293
|
+
};
|
|
1294
|
+
}
|
|
1295
|
+
async approve(id, method, verify) {
|
|
1296
|
+
const request = this.getState().requests[id];
|
|
1297
|
+
if (request === void 0) throw new Error(`unknown request ${id}`);
|
|
1298
|
+
if (request.method !== method) throw new Error(`request ${id} is a ${request.method} request, not ${method}`);
|
|
1299
|
+
const entry = this.entries.get(id);
|
|
1300
|
+
if (request.stage !== "awaiting-approval" || entry === void 0) throw new Error(`request ${id} is ${request.stage}, not awaiting approval`);
|
|
1301
|
+
if (this.options.now() >= request.expiresAt) {
|
|
1302
|
+
this.finish(id, "expired");
|
|
1303
|
+
throw new Error(`request ${id} expired`);
|
|
1304
|
+
}
|
|
1305
|
+
const { session } = this;
|
|
1306
|
+
this.put({
|
|
1307
|
+
...request,
|
|
1308
|
+
stage: "signing"
|
|
1309
|
+
});
|
|
1310
|
+
let stale;
|
|
1311
|
+
try {
|
|
1312
|
+
stale = await entry.prepared.staleReason();
|
|
1313
|
+
} catch (error) {
|
|
1314
|
+
stale = errorMessage(error);
|
|
1315
|
+
}
|
|
1316
|
+
if (session !== this.session) throw new Error(`request ${id} was invalidated`);
|
|
1317
|
+
if (stale !== void 0) {
|
|
1318
|
+
this.finish(id, "failed", stale);
|
|
1319
|
+
throw new Error(stale);
|
|
1320
|
+
}
|
|
1321
|
+
try {
|
|
1322
|
+
const result = await entry.prepared.submit(verify);
|
|
1323
|
+
if (session === this.session) {
|
|
1324
|
+
entry.stopTimer();
|
|
1325
|
+
this.entries.delete(id);
|
|
1326
|
+
this.unclaim(id);
|
|
1327
|
+
const done = {
|
|
1328
|
+
...this.requireRequest(id),
|
|
1329
|
+
stage: "submitted",
|
|
1330
|
+
..."id" in result ? { txId: result.id } : {}
|
|
1331
|
+
};
|
|
1332
|
+
this.put(done);
|
|
1333
|
+
this.record(done, "submitted");
|
|
1334
|
+
} else this.record(request, "submitted");
|
|
1335
|
+
this.settle(id, result);
|
|
1336
|
+
return result;
|
|
1337
|
+
} catch (error) {
|
|
1338
|
+
if (session === this.session) this.finish(id, this.failureStage(entry.prepared, error), errorMessage(error));
|
|
1339
|
+
else this.record(request, "failed", errorMessage(error));
|
|
1340
|
+
throw error;
|
|
1341
|
+
}
|
|
1342
|
+
}
|
|
1343
|
+
/**
|
|
1344
|
+
* A cancelled or refused verification is `rejected`; anything after it is `failed`. Chain
|
|
1345
|
+
* requests record which step failed on the tracked transaction; for Spark, the approval round is
|
|
1346
|
+
* the only source of `WalletError`.
|
|
1347
|
+
*/
|
|
1348
|
+
failureStage(run, error) {
|
|
1349
|
+
if (run.txId === void 0) return error instanceof WalletError ? "rejected" : "failed";
|
|
1350
|
+
return this.options.transactions.getState().txs[run.txId]?.state === "rejected" ? "rejected" : "failed";
|
|
1351
|
+
}
|
|
1352
|
+
reject(id) {
|
|
1353
|
+
const request = this.getState().requests[id];
|
|
1354
|
+
if (request === void 0) throw new Error(`unknown request ${id}`);
|
|
1355
|
+
if (request.stage !== "awaiting-approval") throw new Error(`request ${id} is ${request.stage}, not awaiting approval`);
|
|
1356
|
+
this.finish(id, "rejected");
|
|
1357
|
+
}
|
|
1358
|
+
requireRequest(id) {
|
|
1359
|
+
const request = this.getState().requests[id];
|
|
1360
|
+
if (request === void 0) throw new Error(`unknown request ${id}`);
|
|
1361
|
+
return request;
|
|
1362
|
+
}
|
|
1363
|
+
put(request) {
|
|
1364
|
+
this.update((s) => ({
|
|
1365
|
+
...s,
|
|
1366
|
+
requests: {
|
|
1367
|
+
...s.requests,
|
|
1368
|
+
[request.id]: request
|
|
1369
|
+
}
|
|
1370
|
+
}));
|
|
1371
|
+
}
|
|
1372
|
+
unclaim(requestId) {
|
|
1373
|
+
for (const [target, holder] of this.targets) if (holder === requestId) this.targets.delete(target);
|
|
1374
|
+
}
|
|
1375
|
+
/** Ends a request in a terminal stage and frees what it reserved. */
|
|
1376
|
+
finish(id, stage, error) {
|
|
1377
|
+
this.unclaim(id);
|
|
1378
|
+
const entry = this.entries.get(id);
|
|
1379
|
+
if (entry !== void 0) {
|
|
1380
|
+
entry.stopTimer();
|
|
1381
|
+
this.entries.delete(id);
|
|
1382
|
+
try {
|
|
1383
|
+
entry.prepared.release();
|
|
1384
|
+
} catch (releaseError) {
|
|
1385
|
+
this.options.logger.warn("request release failed", releaseError);
|
|
1386
|
+
}
|
|
1387
|
+
}
|
|
1388
|
+
const request = this.getState().requests[id];
|
|
1389
|
+
if (request !== void 0) {
|
|
1390
|
+
const ended = {
|
|
1391
|
+
...request,
|
|
1392
|
+
stage,
|
|
1393
|
+
...error === void 0 ? {} : { error }
|
|
1394
|
+
};
|
|
1395
|
+
this.put(ended);
|
|
1396
|
+
this.record(ended, stage, error);
|
|
1397
|
+
this.settle(id, new RequestEndedError(stage, error));
|
|
1398
|
+
}
|
|
1399
|
+
}
|
|
1400
|
+
/** Appends the final stage to the signature record; a failed write is a warning, not an error. */
|
|
1401
|
+
record(request, outcome, error) {
|
|
1402
|
+
const { display } = request;
|
|
1403
|
+
const entry = {
|
|
1404
|
+
id: request.id,
|
|
1405
|
+
at: this.options.now(),
|
|
1406
|
+
origin: request.origin,
|
|
1407
|
+
method: request.method,
|
|
1408
|
+
account: request.account,
|
|
1409
|
+
network: request.network,
|
|
1410
|
+
summary: "summary" in display ? display.summary : `${display.kind} ${display.amountSats}`,
|
|
1411
|
+
verification: request.verification,
|
|
1412
|
+
checks: request.checks,
|
|
1413
|
+
risk: request.risk,
|
|
1414
|
+
outcome,
|
|
1415
|
+
...error === void 0 ? {} : { error }
|
|
1416
|
+
};
|
|
1417
|
+
this.appendLog(entry);
|
|
1418
|
+
}
|
|
1419
|
+
async appendLog(entry) {
|
|
1420
|
+
try {
|
|
1421
|
+
await this.log.append(entry);
|
|
1422
|
+
} catch (error) {
|
|
1423
|
+
this.options.logger.warn("sign log write failed", error);
|
|
1424
|
+
}
|
|
1425
|
+
}
|
|
1426
|
+
/**
|
|
1427
|
+
* Addresses the wallet already knows for a family: contacts, plus recipients of tracked chain
|
|
1428
|
+
* sends (Spark payments are not tracked).
|
|
1429
|
+
*/
|
|
1430
|
+
knownAddresses(family) {
|
|
1431
|
+
const contacts = Object.values(this.options.addressBook.getState().entries).filter((e) => e.family === family).map((e) => e.address);
|
|
1432
|
+
if (family === "spark") return contacts;
|
|
1433
|
+
const paid = Object.values(this.options.transactions.getState().txs).filter((tx) => tx.family === family && (tx.state === "submitted" || tx.state === "pending" || tx.state === "confirmed")).flatMap((tx) => tx.intent?.outflows.map((o) => o.to) ?? []);
|
|
1434
|
+
return [...contacts, ...paid];
|
|
1435
|
+
}
|
|
1436
|
+
chainRisk(intent) {
|
|
1437
|
+
return assessRecipients(intent.family, intent.outflows.map((o) => o.to), this.knownAddresses(intent.family));
|
|
1438
|
+
}
|
|
1439
|
+
/** Only a Spark transfer has a recipient to check; Lightning and exits are not assessed. */
|
|
1440
|
+
sparkRisk(display) {
|
|
1441
|
+
if (display.kind !== "transfer") return [];
|
|
1442
|
+
return assessRecipients("spark", [normalizeAddress("spark", display.receiver) ?? display.receiver], this.knownAddresses("spark"));
|
|
1443
|
+
}
|
|
1444
|
+
};
|
|
1445
|
+
// src/connect/eip1193.ts
|
|
1446
|
+
/**
|
|
1447
|
+
* Platform-neutral EIP-1193 provider logic: it knows nothing about WebViews. The host hands it a
|
|
1448
|
+
* page's origin (computed from the WebView's top-level URL, never from the page) and a JSON-RPC
|
|
1449
|
+
* call; signing goes through the request pipeline and waits for the host's approval by id.
|
|
1450
|
+
*/
|
|
1451
|
+
/** Refused outright, whatever the session. */
|
|
1452
|
+
const REFUSED = /* @__PURE__ */ new Set([
|
|
1453
|
+
"eth_sign",
|
|
1454
|
+
"eth_signTypedData",
|
|
1455
|
+
"eth_signTypedData_v3",
|
|
1456
|
+
"eth_signTransaction",
|
|
1457
|
+
"eth_sendRawTransaction"
|
|
1458
|
+
]);
|
|
1459
|
+
/** Planned for later layers; answered `4200` until then. */
|
|
1460
|
+
const DEFERRED = /* @__PURE__ */ new Set([
|
|
1461
|
+
"wallet_addEthereumChain",
|
|
1462
|
+
"wallet_watchAsset",
|
|
1463
|
+
"wallet_sendCalls",
|
|
1464
|
+
"wallet_getCallsStatus",
|
|
1465
|
+
"wallet_getCapabilities",
|
|
1466
|
+
"wallet_showCallsStatus"
|
|
1467
|
+
]);
|
|
1468
|
+
const READ_ONLY = new Set(EVM_RPC_METHODS.filter((m) => m !== "eth_sendRawTransaction" && m !== "eth_chainId"));
|
|
1469
|
+
const hexChainId = (network) => `0x${(evmChainId(network) ?? 0).toString(16)}`;
|
|
1470
|
+
const isObject$12 = (v) => typeof v === "object" && v !== null && !Array.isArray(v);
|
|
1471
|
+
const paramList = (params) => {
|
|
1472
|
+
if (params === void 0) return [];
|
|
1473
|
+
if (!Array.isArray(params)) throw invalidParams("params must be an array");
|
|
1474
|
+
return params;
|
|
1475
|
+
};
|
|
1476
|
+
const textBytes = (value) => {
|
|
1477
|
+
if (typeof value !== "string") throw invalidParams("message must be a string");
|
|
1478
|
+
if (/^0x(?:[0-9a-fA-F]{2})*$/.test(value)) {
|
|
1479
|
+
const bytes = hexToBytes(value.slice(2));
|
|
1480
|
+
try {
|
|
1481
|
+
new TextDecoder("utf-8", { fatal: true }).decode(bytes);
|
|
1482
|
+
} catch {
|
|
1483
|
+
throw new ProviderError(4200, "blind signing is not enabled: the message is not UTF-8 text");
|
|
1484
|
+
}
|
|
1485
|
+
return bytes;
|
|
1486
|
+
}
|
|
1487
|
+
return new TextEncoder().encode(value);
|
|
1488
|
+
};
|
|
1489
|
+
const quantityOf = (value, field, fallback) => {
|
|
1490
|
+
if (value === void 0) return fallback;
|
|
1491
|
+
if (typeof value !== "string" || !/^0x[0-9a-fA-F]+$/.test(value)) throw invalidParams(`${field} must be a 0x quantity`);
|
|
1492
|
+
return value;
|
|
1493
|
+
};
|
|
1494
|
+
var Eip1193Handler = class {
|
|
1495
|
+
options;
|
|
1496
|
+
listeners = /* @__PURE__ */ new Set();
|
|
1497
|
+
unsubscribe;
|
|
1498
|
+
constructor(options) {
|
|
1499
|
+
this.options = options;
|
|
1500
|
+
}
|
|
1501
|
+
/** Events for the host to forward to pages; derived from changes to the sessions. */
|
|
1502
|
+
subscribe(listener) {
|
|
1503
|
+
this.listeners.add(listener);
|
|
1504
|
+
if (this.unsubscribe === void 0) {
|
|
1505
|
+
let previous = this.options.connections.getState().sessions;
|
|
1506
|
+
this.unsubscribe = this.options.connections.store.subscribe((state) => {
|
|
1507
|
+
for (const event of diffSessions(previous, state.sessions)) for (const l of this.listeners) l(event);
|
|
1508
|
+
previous = state.sessions;
|
|
1509
|
+
});
|
|
1510
|
+
}
|
|
1511
|
+
return () => {
|
|
1512
|
+
this.listeners.delete(listener);
|
|
1513
|
+
if (this.listeners.size === 0) {
|
|
1514
|
+
this.unsubscribe?.();
|
|
1515
|
+
this.unsubscribe = void 0;
|
|
1516
|
+
}
|
|
1517
|
+
};
|
|
1518
|
+
}
|
|
1519
|
+
async handle(origin, request) {
|
|
1520
|
+
const { connections } = this.options;
|
|
1521
|
+
connections.checkOrigin(origin);
|
|
1522
|
+
const { method } = request;
|
|
1523
|
+
const params = paramList(request.params);
|
|
1524
|
+
if (REFUSED.has(method) || DEFERRED.has(method)) throw unsupportedMethod(method);
|
|
1525
|
+
switch (method) {
|
|
1526
|
+
case "eth_accounts": return connections.accounts(origin);
|
|
1527
|
+
case "eth_chainId": return hexChainId(connections.session(origin)?.currentNetwork ?? this.options.defaultNetwork());
|
|
1528
|
+
case "eth_requestAccounts": return this.requestAccounts(origin);
|
|
1529
|
+
case "wallet_requestPermissions":
|
|
1530
|
+
await this.requestAccounts(origin);
|
|
1531
|
+
return this.permissions(origin);
|
|
1532
|
+
case "wallet_getPermissions": return this.permissions(origin);
|
|
1533
|
+
case "wallet_revokePermissions":
|
|
1534
|
+
await connections.revoke(origin);
|
|
1535
|
+
return null;
|
|
1536
|
+
case "wallet_switchEthereumChain": return this.switchChain(origin, params[0]);
|
|
1537
|
+
case "personal_sign": return this.personalSign(origin, params);
|
|
1538
|
+
case "eth_signTypedData_v4": return this.typedData(origin, params);
|
|
1539
|
+
case "eth_sendTransaction": return this.sendTransaction(origin, params[0]);
|
|
1540
|
+
}
|
|
1541
|
+
if (READ_ONLY.has(method)) return this.read(origin, method, params);
|
|
1542
|
+
throw new ProviderError(-32601, `${method} does not exist`);
|
|
1543
|
+
}
|
|
1544
|
+
async requestAccounts(origin) {
|
|
1545
|
+
const { connections } = this.options;
|
|
1546
|
+
if (connections.accounts(origin).length === 0) await connections.request(origin, "connect");
|
|
1547
|
+
await connections.touch(origin);
|
|
1548
|
+
return connections.accounts(origin);
|
|
1549
|
+
}
|
|
1550
|
+
permissions(origin) {
|
|
1551
|
+
const accounts = this.options.connections.accounts(origin);
|
|
1552
|
+
if (accounts.length === 0) return [];
|
|
1553
|
+
return [{
|
|
1554
|
+
parentCapability: "eth_accounts",
|
|
1555
|
+
invoker: origin,
|
|
1556
|
+
caveats: [{
|
|
1557
|
+
type: "restrictReturnedAccounts",
|
|
1558
|
+
value: accounts
|
|
1559
|
+
}]
|
|
1560
|
+
}];
|
|
1561
|
+
}
|
|
1562
|
+
async switchChain(origin, arg) {
|
|
1563
|
+
const { connections } = this.options;
|
|
1564
|
+
const chainId = isObject$12(arg) ? arg["chainId"] : void 0;
|
|
1565
|
+
if (typeof chainId !== "string" || !/^0x[0-9a-fA-F]+$/.test(chainId)) throw invalidParams("chainId must be a 0x quantity");
|
|
1566
|
+
const network = `eip155:${Number.parseInt(chainId, 16)}`;
|
|
1567
|
+
if (!connections.isEnabled(network)) throw unrecognizedChain(`chain ${chainId} is not enabled`);
|
|
1568
|
+
const session = connections.session(origin);
|
|
1569
|
+
if (session === void 0) throw unauthorized();
|
|
1570
|
+
if (session.scopes[network] === void 0) await connections.request(origin, "network", network);
|
|
1571
|
+
else if (session.currentNetwork !== network) await connections.setCurrentNetwork(origin, network);
|
|
1572
|
+
return null;
|
|
1573
|
+
}
|
|
1574
|
+
authorizedAccount(session, address) {
|
|
1575
|
+
const accounts = this.options.connections.accounts(session.origin.origin);
|
|
1576
|
+
const wanted = typeof address === "string" ? address.toLowerCase() : accounts[0];
|
|
1577
|
+
if (wanted === void 0 || !accounts.some((a) => a.toLowerCase() === wanted.toLowerCase())) throw unauthorized("account not authorized");
|
|
1578
|
+
return wanted;
|
|
1579
|
+
}
|
|
1580
|
+
async personalSign(origin, params) {
|
|
1581
|
+
const session = this.signingSession(origin, "personal_sign");
|
|
1582
|
+
this.authorizedAccount(session, params[1]);
|
|
1583
|
+
const message = textBytes(params[0]);
|
|
1584
|
+
return this.sign(origin, session, (account) => ({
|
|
1585
|
+
kind: "evm.personal-message",
|
|
1586
|
+
network: session.currentNetwork,
|
|
1587
|
+
account,
|
|
1588
|
+
message
|
|
1589
|
+
}));
|
|
1590
|
+
}
|
|
1591
|
+
async typedData(origin, params) {
|
|
1592
|
+
const session = this.signingSession(origin, "eth_signTypedData_v4");
|
|
1593
|
+
this.authorizedAccount(session, params[0]);
|
|
1594
|
+
const [, source] = params;
|
|
1595
|
+
const typedData = typeof source === "string" ? source : JSON.stringify(source);
|
|
1596
|
+
return this.sign(origin, session, (account) => ({
|
|
1597
|
+
kind: "evm.typed-data",
|
|
1598
|
+
network: session.currentNetwork,
|
|
1599
|
+
account,
|
|
1600
|
+
typedData
|
|
1601
|
+
}));
|
|
1602
|
+
}
|
|
1603
|
+
async sendTransaction(origin, arg) {
|
|
1604
|
+
const session = this.signingSession(origin, "eth_sendTransaction");
|
|
1605
|
+
if (!isObject$12(arg)) throw invalidParams("transaction must be an object");
|
|
1606
|
+
this.authorizedAccount(session, arg["from"]);
|
|
1607
|
+
const chainId = evmChainId(session.currentNetwork);
|
|
1608
|
+
if (chainId === void 0) throw internalError("session network is not an EVM network");
|
|
1609
|
+
if (arg["chainId"] !== void 0 && Number(arg["chainId"]) !== chainId) throw invalidParams("transaction chainId does not match the session network");
|
|
1610
|
+
const data = arg["data"] ?? arg["input"] ?? "0x";
|
|
1611
|
+
if (typeof data !== "string") throw invalidParams("data must be a hex string");
|
|
1612
|
+
const { to } = arg;
|
|
1613
|
+
if (to !== void 0 && typeof to !== "string") throw invalidParams("to must be an address");
|
|
1614
|
+
return this.sign(origin, session, (account) => ({
|
|
1615
|
+
kind: "evm.transaction",
|
|
1616
|
+
network: session.currentNetwork,
|
|
1617
|
+
account,
|
|
1618
|
+
tx: {
|
|
1619
|
+
type: "eip1559",
|
|
1620
|
+
chainId,
|
|
1621
|
+
nonce: 0,
|
|
1622
|
+
gas: quantityOf(arg["gas"], "gas", "0x0"),
|
|
1623
|
+
value: quantityOf(arg["value"], "value", "0x0"),
|
|
1624
|
+
data,
|
|
1625
|
+
...to === void 0 ? {} : { to },
|
|
1626
|
+
accessList: [],
|
|
1627
|
+
maxFeePerGas: "0x0",
|
|
1628
|
+
maxPriorityFeePerGas: "0x0"
|
|
1629
|
+
}
|
|
1630
|
+
}));
|
|
1631
|
+
}
|
|
1632
|
+
signingSession(origin, method) {
|
|
1633
|
+
const { connections } = this.options;
|
|
1634
|
+
const session = connections.session(origin);
|
|
1635
|
+
if (session === void 0) throw unauthorized();
|
|
1636
|
+
return connections.authorize(origin, session.currentNetwork, method);
|
|
1637
|
+
}
|
|
1638
|
+
async sign(origin, session, build) {
|
|
1639
|
+
const { connections, requests } = this.options;
|
|
1640
|
+
const group = connections.activeGroup();
|
|
1641
|
+
if (group === void 0) throw internalError("no account group is selected");
|
|
1642
|
+
const account = {
|
|
1643
|
+
kind: "hd",
|
|
1644
|
+
...group,
|
|
1645
|
+
template: "evm.bip44"
|
|
1646
|
+
};
|
|
1647
|
+
try {
|
|
1648
|
+
const pending = await requests.prepare({
|
|
1649
|
+
method: "evm-sign",
|
|
1650
|
+
origin: {
|
|
1651
|
+
kind: "browser",
|
|
1652
|
+
origin
|
|
1653
|
+
},
|
|
1654
|
+
account,
|
|
1655
|
+
network: session.currentNetwork,
|
|
1656
|
+
request: build(account)
|
|
1657
|
+
});
|
|
1658
|
+
const result = await requests.outcome(pending.id);
|
|
1659
|
+
await connections.touch(origin);
|
|
1660
|
+
if ("signature" in result && result.signature instanceof Uint8Array) return `0x${bytesToHex(result.signature)}`;
|
|
1661
|
+
if (!("hash" in result) || typeof result.hash !== "string") throw internalError("the transaction has no hash");
|
|
1662
|
+
return result.hash;
|
|
1663
|
+
} catch (error) {
|
|
1664
|
+
throw toProviderError(error);
|
|
1665
|
+
}
|
|
1666
|
+
}
|
|
1667
|
+
async read(origin, method, params) {
|
|
1668
|
+
const { connections, networks } = this.options;
|
|
1669
|
+
const network = connections.session(origin)?.currentNetwork ?? this.options.defaultNetwork();
|
|
1670
|
+
try {
|
|
1671
|
+
return await networks.client(network).transport.request({
|
|
1672
|
+
method,
|
|
1673
|
+
params: [...params]
|
|
1674
|
+
});
|
|
1675
|
+
} catch (error) {
|
|
1676
|
+
throw toProviderError(error);
|
|
1677
|
+
}
|
|
1678
|
+
}
|
|
1679
|
+
};
|
|
1680
|
+
function toProviderError(error) {
|
|
1681
|
+
if (error instanceof ProviderError) return error;
|
|
1682
|
+
if (error instanceof OriginBusyError) return userRejectedRequest(error.message);
|
|
1683
|
+
if (error instanceof RequestEndedError) return error.stage === "failed" ? internalError(error.message) : userRejectedRequest(error.message);
|
|
1684
|
+
if (error instanceof ChainError) return invalidParams(error.message);
|
|
1685
|
+
return internalError(error instanceof Error ? error.message : String(error));
|
|
71
1686
|
}
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
1687
|
+
function currentAccounts(session) {
|
|
1688
|
+
return session.scopes[session.currentNetwork]?.accounts.map((a) => a.slice(a.lastIndexOf(":") + 1)) ?? [];
|
|
1689
|
+
}
|
|
1690
|
+
const byOrigin = (sessions) => new Map(Object.values(sessions).map((s) => [s.origin.origin, s]));
|
|
1691
|
+
function diffSessions(before, after) {
|
|
1692
|
+
const events = [];
|
|
1693
|
+
const prev = byOrigin(before);
|
|
1694
|
+
const next = byOrigin(after);
|
|
1695
|
+
for (const [origin, session] of prev) {
|
|
1696
|
+
const now = next.get(origin);
|
|
1697
|
+
if (now === void 0) {
|
|
1698
|
+
events.push({
|
|
1699
|
+
name: "disconnect",
|
|
1700
|
+
origin
|
|
1701
|
+
});
|
|
1702
|
+
continue;
|
|
1703
|
+
}
|
|
1704
|
+
if (now.currentNetwork !== session.currentNetwork) events.push({
|
|
1705
|
+
name: "chainChanged",
|
|
1706
|
+
origin,
|
|
1707
|
+
chainId: hexChainId(now.currentNetwork)
|
|
1708
|
+
});
|
|
1709
|
+
if (currentAccounts(session).join(",") !== currentAccounts(now).join(",")) events.push({
|
|
1710
|
+
name: "accountsChanged",
|
|
1711
|
+
origin,
|
|
1712
|
+
accounts: currentAccounts(now)
|
|
1713
|
+
});
|
|
1714
|
+
}
|
|
1715
|
+
for (const [origin, session] of next) if (!prev.has(origin)) events.push({
|
|
1716
|
+
name: "connect",
|
|
1717
|
+
origin,
|
|
1718
|
+
chainId: hexChainId(session.currentNetwork)
|
|
1719
|
+
});
|
|
1720
|
+
return events;
|
|
1721
|
+
}
|
|
1722
|
+
// src/addresses/errors.ts
|
|
1723
|
+
var AddressBookError = class extends Error {
|
|
1724
|
+
category;
|
|
1725
|
+
constructor(category, message) {
|
|
78
1726
|
super(message);
|
|
79
|
-
this.name = "
|
|
80
|
-
this.
|
|
1727
|
+
this.name = "AddressBookError";
|
|
1728
|
+
this.category = category;
|
|
81
1729
|
}
|
|
82
1730
|
};
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
1731
|
+
// src/addresses/record.ts
|
|
1732
|
+
/**
|
|
1733
|
+
* The address-book record shared by the controller, the sync merge and the metadata document: a
|
|
1734
|
+
* live entry, or a tombstone that carries a deletion to the other devices.
|
|
1735
|
+
*/
|
|
1736
|
+
const isTombstone = (record) => "deleted" in record;
|
|
1737
|
+
const isFamily = (v) => v === "evm" || v === "solana" || v === "bitcoin" || v === "spark";
|
|
1738
|
+
const ENTRY_KEYS = /* @__PURE__ */ new Set([
|
|
1739
|
+
"id",
|
|
1740
|
+
"name",
|
|
1741
|
+
"family",
|
|
1742
|
+
"address",
|
|
1743
|
+
"note",
|
|
1744
|
+
"createdAt",
|
|
1745
|
+
"updatedAt"
|
|
1746
|
+
]);
|
|
1747
|
+
const TOMBSTONE_KEYS = /* @__PURE__ */ new Set([
|
|
1748
|
+
"id",
|
|
1749
|
+
"deleted",
|
|
1750
|
+
"updatedAt"
|
|
1751
|
+
]);
|
|
1752
|
+
const isObject$11 = (v) => typeof v === "object" && v !== null && !Array.isArray(v);
|
|
1753
|
+
const isNonNegativeInt$2 = (v) => typeof v === "number" && Number.isSafeInteger(v) && v >= 0;
|
|
1754
|
+
/** Trimmed name, or `undefined` when it is not 1 to {@link NAME_MAX} characters. */
|
|
1755
|
+
const cleanName = (raw) => {
|
|
1756
|
+
const name = raw.trim();
|
|
1757
|
+
return name.length > 0 && name.length <= 32 ? name : void 0;
|
|
1758
|
+
};
|
|
1759
|
+
/** Trimmed note; an empty one means no note. `undefined` return with `ok: false` when too long. */
|
|
1760
|
+
const cleanNote = (raw) => {
|
|
1761
|
+
const note = raw?.trim();
|
|
1762
|
+
if (note === void 0 || note.length === 0) return {
|
|
1763
|
+
ok: true,
|
|
1764
|
+
note: void 0
|
|
97
1765
|
};
|
|
1766
|
+
return note.length <= 120 ? {
|
|
1767
|
+
ok: true,
|
|
1768
|
+
note
|
|
1769
|
+
} : { ok: false };
|
|
1770
|
+
};
|
|
1771
|
+
const hasOnly = (value, keys) => Object.keys(value).every((k) => keys.has(k));
|
|
1772
|
+
/**
|
|
1773
|
+
* Strict decode with the controller's rules; the address is re-normalized. Anything else is
|
|
1774
|
+
* `undefined`.
|
|
1775
|
+
*/
|
|
1776
|
+
function decodeRecord(value) {
|
|
1777
|
+
if (!isObject$11(value) || typeof value["id"] !== "string" || !isNonNegativeInt$2(value["updatedAt"])) return;
|
|
1778
|
+
if ("deleted" in value) return value["deleted"] === true && hasOnly(value, TOMBSTONE_KEYS) ? {
|
|
1779
|
+
id: value["id"],
|
|
1780
|
+
deleted: true,
|
|
1781
|
+
updatedAt: value["updatedAt"]
|
|
1782
|
+
} : void 0;
|
|
1783
|
+
const { family, name, address, note } = value;
|
|
1784
|
+
if (!hasOnly(value, ENTRY_KEYS) || !isFamily(family) || typeof name !== "string" || typeof address !== "string" || !isNonNegativeInt$2(value["createdAt"]) || note !== void 0 && typeof note !== "string") return;
|
|
1785
|
+
const cleanedName = cleanName(name);
|
|
1786
|
+
const normalized = normalizeAddress(family, address);
|
|
1787
|
+
const cleanedNote = cleanNote(note);
|
|
1788
|
+
if (cleanedName === void 0 || normalized === void 0 || !cleanedNote.ok || note !== void 0 && cleanedNote.note === void 0) return;
|
|
98
1789
|
return {
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
1790
|
+
id: value["id"],
|
|
1791
|
+
name: cleanedName,
|
|
1792
|
+
family,
|
|
1793
|
+
address: normalized,
|
|
1794
|
+
...cleanedNote.note === void 0 ? {} : { note: cleanedNote.note },
|
|
1795
|
+
createdAt: value["createdAt"],
|
|
1796
|
+
updatedAt: value["updatedAt"]
|
|
1797
|
+
};
|
|
1798
|
+
}
|
|
1799
|
+
const serialized = (record) => JSON.stringify(record);
|
|
1800
|
+
/** The winner of two versions of one id: larger `updatedAt`, then the larger serialization. */
|
|
1801
|
+
function pickWinner(a, b) {
|
|
1802
|
+
if (a.updatedAt !== b.updatedAt) return a.updatedAt > b.updatedAt ? a : b;
|
|
1803
|
+
return serialized(a) >= serialized(b) ? a : b;
|
|
1804
|
+
}
|
|
1805
|
+
const pruneTombstones = (records, now) => records.filter((r) => !isTombstone(r) || now - r.updatedAt <= 7776e6);
|
|
1806
|
+
/** Newest first, id as the tiebreak, so every device orders (and truncates) the same way. */
|
|
1807
|
+
const newestFirst = (a, b) => b.updatedAt - a.updatedAt || (a.id < b.id ? -1 : 1);
|
|
1808
|
+
/** Drops the oldest tombstones until at most `max` records remain, or no tombstone is left. */
|
|
1809
|
+
function evictTombstones(records, max) {
|
|
1810
|
+
const excess = records.length - max;
|
|
1811
|
+
if (excess <= 0) return records;
|
|
1812
|
+
const oldest = new Set(records.filter(isTombstone).sort((a, b) => a.updatedAt - b.updatedAt || (a.id < b.id ? -1 : 1)).slice(0, excess).map((r) => r.id));
|
|
1813
|
+
return records.filter((r) => !oldest.has(r.id));
|
|
1814
|
+
}
|
|
1815
|
+
/** {@link mergeRecords}, also counting the live entries the cap forced out. */
|
|
1816
|
+
function mergeWithReport(local, remote, now) {
|
|
1817
|
+
const byId = /* @__PURE__ */ new Map();
|
|
1818
|
+
for (const record of [...local, ...remote]) {
|
|
1819
|
+
const existing = byId.get(record.id);
|
|
1820
|
+
byId.set(record.id, existing === void 0 ? record : pickWinner(existing, record));
|
|
1821
|
+
}
|
|
1822
|
+
const live = /* @__PURE__ */ new Map();
|
|
1823
|
+
const kept = [];
|
|
1824
|
+
for (const record of [...pruneTombstones([...byId.values()], now)].sort(newestFirst)) {
|
|
1825
|
+
if (isTombstone(record)) {
|
|
1826
|
+
kept.push(record);
|
|
1827
|
+
continue;
|
|
132
1828
|
}
|
|
1829
|
+
const key = `${record.family}:${record.address}`;
|
|
1830
|
+
if (!live.has(key)) {
|
|
1831
|
+
live.set(key, record);
|
|
1832
|
+
kept.push(record);
|
|
1833
|
+
}
|
|
1834
|
+
}
|
|
1835
|
+
const evicted = evictTombstones(kept, 128);
|
|
1836
|
+
return {
|
|
1837
|
+
records: [...evicted.slice(0, 128)].sort((a, b) => a.id < b.id ? -1 : 1),
|
|
1838
|
+
droppedLive: evicted.slice(128).filter((r) => !isTombstone(r)).length
|
|
133
1839
|
};
|
|
134
1840
|
}
|
|
135
|
-
|
|
136
|
-
|
|
1841
|
+
// src/addresses/controller.ts
|
|
1842
|
+
/**
|
|
1843
|
+
* Address book: named recipients, validated and normalized per chain family, persisted in the
|
|
1844
|
+
* engine store and cleared from memory on lock. Deletions are kept as tombstones so they reach the
|
|
1845
|
+
* other devices; `syncEntries`/`merge` are the whole surface the metadata sync needs. Request risk
|
|
1846
|
+
* checks treat its addresses as known.
|
|
1847
|
+
*/
|
|
1848
|
+
const COLLECTION$2 = "contacts";
|
|
1849
|
+
const entriesOf = (records) => Object.fromEntries(records.flatMap((r) => isTombstone(r) ? [] : [[r.id, r]]));
|
|
1850
|
+
var AddressBookController = class extends Controller {
|
|
1851
|
+
options;
|
|
1852
|
+
engineStore;
|
|
1853
|
+
/** Every record, tombstones included, by id. */
|
|
1854
|
+
records = /* @__PURE__ */ new Map();
|
|
1855
|
+
constructor(options) {
|
|
1856
|
+
super({
|
|
1857
|
+
name: "address-book",
|
|
1858
|
+
defaultState: () => ({ entries: {} })
|
|
1859
|
+
});
|
|
1860
|
+
this.options = options;
|
|
1861
|
+
}
|
|
1862
|
+
async init() {
|
|
1863
|
+
const store = await this.options.openStore();
|
|
1864
|
+
this.engineStore = store;
|
|
1865
|
+
const collection = store.collection(COLLECTION$2);
|
|
1866
|
+
const keys = await collection.list();
|
|
1867
|
+
const rows = await Promise.all(keys.map(async (key) => collection.get(key)));
|
|
1868
|
+
const records = /* @__PURE__ */ new Map();
|
|
1869
|
+
for (const raw of rows) {
|
|
1870
|
+
if (raw === void 0) continue;
|
|
1871
|
+
const record = decodeRecord(JSON.parse(new TextDecoder().decode(raw)));
|
|
1872
|
+
if (record === void 0) this.options.logger.warn("address book record skipped: malformed");
|
|
1873
|
+
else records.set(record.id, record);
|
|
1874
|
+
}
|
|
1875
|
+
this.records = records;
|
|
1876
|
+
this.update(() => ({ entries: entriesOf([...records.values()]) }));
|
|
1877
|
+
}
|
|
1878
|
+
async destroy() {
|
|
1879
|
+
this.lock();
|
|
1880
|
+
await Promise.resolve();
|
|
1881
|
+
}
|
|
1882
|
+
lock() {
|
|
1883
|
+
this.engineStore = void 0;
|
|
1884
|
+
this.records = /* @__PURE__ */ new Map();
|
|
1885
|
+
this.update(() => ({ entries: {} }));
|
|
1886
|
+
}
|
|
1887
|
+
/** Entries and tombstones, for the metadata document. */
|
|
1888
|
+
syncEntries() {
|
|
1889
|
+
return [...this.records.values()].sort((a, b) => a.id < b.id ? -1 : 1);
|
|
1890
|
+
}
|
|
1891
|
+
/**
|
|
1892
|
+
* Merges records from another device (last writer wins, tombstones win over older entries).
|
|
1893
|
+
* Returns whether anything local changed and whether the merged set holds something `remote`
|
|
1894
|
+
* lacks, i.e. whether the remote copy needs a newer publish.
|
|
1895
|
+
*/
|
|
1896
|
+
async merge(remote) {
|
|
1897
|
+
const store = this.requireStore();
|
|
1898
|
+
const before = JSON.stringify(this.syncEntries());
|
|
1899
|
+
const { records: merged, droppedLive } = mergeWithReport(this.syncEntries(), remote, this.options.now());
|
|
1900
|
+
if (droppedLive > 0) this.options.logger.warn(`address book sync: ${droppedLive} contacts dropped, over the 128-record cap`);
|
|
1901
|
+
const remoteSorted = JSON.stringify([...remote].sort((a, b) => a.id < b.id ? -1 : 1));
|
|
1902
|
+
const localNewer = JSON.stringify(merged) !== remoteSorted;
|
|
1903
|
+
if (JSON.stringify(merged) === before) return {
|
|
1904
|
+
changed: false,
|
|
1905
|
+
localNewer
|
|
1906
|
+
};
|
|
1907
|
+
const collection = store.collection(COLLECTION$2);
|
|
1908
|
+
const next = new Map(merged.map((r) => [r.id, r]));
|
|
1909
|
+
await Promise.all([...merged.map(async (r) => collection.put(r.id, new TextEncoder().encode(JSON.stringify(r)))), ...[...this.records.keys()].filter((id) => !next.has(id)).map(async (id) => collection.delete(id))]);
|
|
1910
|
+
this.records = next;
|
|
1911
|
+
this.update(() => ({ entries: entriesOf(merged) }));
|
|
1912
|
+
return {
|
|
1913
|
+
changed: true,
|
|
1914
|
+
localNewer
|
|
1915
|
+
};
|
|
1916
|
+
}
|
|
1917
|
+
async add(params) {
|
|
1918
|
+
const store = this.requireStore();
|
|
1919
|
+
const name = cleanName(params.name);
|
|
1920
|
+
if (name === void 0) throw new AddressBookError("invalid-input", `name must be 1 to 32 characters`);
|
|
1921
|
+
const note = this.checkedNote(params.note);
|
|
1922
|
+
const address = normalizeAddress(params.family, params.address.trim());
|
|
1923
|
+
if (address === void 0) throw new AddressBookError("invalid-input", `not a valid ${params.family} address`);
|
|
1924
|
+
if (Object.values(this.getState().entries).some((e) => e.family === params.family && e.address === address)) throw new AddressBookError("duplicate", "this address is already in the address book");
|
|
1925
|
+
if (this.records.size >= 128) {
|
|
1926
|
+
const kept = evictTombstones([...this.records.values()], 127);
|
|
1927
|
+
if (kept.length >= 128) throw new AddressBookError("limit", `the address book holds at most 128 contacts`);
|
|
1928
|
+
const keptIds = new Set(kept.map((r) => r.id));
|
|
1929
|
+
await Promise.all([...this.records.keys()].filter((id) => !keptIds.has(id)).map(async (id) => store.collection(COLLECTION$2).delete(id)));
|
|
1930
|
+
this.records = new Map(kept.map((r) => [r.id, r]));
|
|
1931
|
+
}
|
|
1932
|
+
const now = this.options.now();
|
|
1933
|
+
const entry = {
|
|
1934
|
+
id: `contact-${[...this.options.random(8)].map((b) => b.toString(16).padStart(2, "0")).join("")}`,
|
|
1935
|
+
name,
|
|
1936
|
+
family: params.family,
|
|
1937
|
+
address,
|
|
1938
|
+
...note === void 0 ? {} : { note },
|
|
1939
|
+
createdAt: now,
|
|
1940
|
+
updatedAt: now
|
|
1941
|
+
};
|
|
1942
|
+
await this.persist(store, entry);
|
|
1943
|
+
return entry;
|
|
1944
|
+
}
|
|
1945
|
+
async rename(id, name) {
|
|
1946
|
+
const store = this.requireStore();
|
|
1947
|
+
const existing = this.requireEntry(id);
|
|
1948
|
+
const cleaned = cleanName(name);
|
|
1949
|
+
if (cleaned === void 0) throw new AddressBookError("invalid-input", `name must be 1 to 32 characters`);
|
|
1950
|
+
const entry = {
|
|
1951
|
+
...existing,
|
|
1952
|
+
name: cleaned,
|
|
1953
|
+
updatedAt: this.options.now()
|
|
1954
|
+
};
|
|
1955
|
+
await this.persist(store, entry);
|
|
1956
|
+
return entry;
|
|
1957
|
+
}
|
|
1958
|
+
/** Sets or clears (empty or `undefined`) the note. */
|
|
1959
|
+
async setNote(id, note) {
|
|
1960
|
+
const store = this.requireStore();
|
|
1961
|
+
const existing = this.requireEntry(id);
|
|
1962
|
+
const cleaned = this.checkedNote(note);
|
|
1963
|
+
const { note: _previous, ...rest } = existing;
|
|
1964
|
+
const entry = {
|
|
1965
|
+
...rest,
|
|
1966
|
+
...cleaned === void 0 ? {} : { note: cleaned },
|
|
1967
|
+
updatedAt: this.options.now()
|
|
1968
|
+
};
|
|
1969
|
+
await this.persist(store, entry);
|
|
1970
|
+
return entry;
|
|
1971
|
+
}
|
|
1972
|
+
/** Removes the entry, leaving a tombstone so other devices drop it too. */
|
|
1973
|
+
async remove(id) {
|
|
1974
|
+
const store = this.requireStore();
|
|
1975
|
+
this.requireEntry(id);
|
|
1976
|
+
await this.persist(store, {
|
|
1977
|
+
id,
|
|
1978
|
+
deleted: true,
|
|
1979
|
+
updatedAt: this.options.now()
|
|
1980
|
+
});
|
|
1981
|
+
}
|
|
1982
|
+
checkedNote(note) {
|
|
1983
|
+
const cleaned = cleanNote(note);
|
|
1984
|
+
if (!cleaned.ok) throw new AddressBookError("invalid-input", `note must be at most 120 characters`);
|
|
1985
|
+
return cleaned.note;
|
|
1986
|
+
}
|
|
1987
|
+
requireEntry(id) {
|
|
1988
|
+
const entry = this.getState().entries[id];
|
|
1989
|
+
if (entry === void 0) throw new AddressBookError("not-found", `unknown contact ${id}`);
|
|
1990
|
+
return entry;
|
|
1991
|
+
}
|
|
1992
|
+
async persist(store, record) {
|
|
1993
|
+
await store.collection(COLLECTION$2).put(record.id, new TextEncoder().encode(JSON.stringify(record)));
|
|
1994
|
+
this.records.set(record.id, record);
|
|
1995
|
+
this.update(() => ({ entries: entriesOf([...this.records.values()]) }));
|
|
1996
|
+
}
|
|
1997
|
+
requireStore() {
|
|
1998
|
+
if (this.engineStore === void 0) throw new AddressBookError("locked", "the address book requires an unlocked vault");
|
|
1999
|
+
return this.engineStore;
|
|
2000
|
+
}
|
|
2001
|
+
};
|
|
2002
|
+
// src/chains/bitcoin-scan.ts
|
|
137
2003
|
/**
|
|
138
2004
|
* Gap-20 BIP-44/86 account scanning over Esplora — derives /0/* and /1/* from the account xpub,
|
|
139
2005
|
* walks each chain in windows until 20 consecutive unused addresses, persists the discovered
|
|
@@ -141,6 +2007,7 @@ function createMemoryEngineStore(backing) {
|
|
|
141
2007
|
* receive/change address.
|
|
142
2008
|
*/
|
|
143
2009
|
const GAP = 20;
|
|
2010
|
+
const COLL = "btc-scan";
|
|
144
2011
|
const isObject$10 = (v) => typeof v === "object" && v !== null;
|
|
145
2012
|
const isUnknownArray = (v) => Array.isArray(v);
|
|
146
2013
|
const asArray = (v) => isUnknownArray(v) ? v : [];
|
|
@@ -193,8 +2060,45 @@ const scanUtxos = async (es, addresses, params) => {
|
|
|
193
2060
|
const candidates = addresses.filter((a) => a.used || a.chain === 1 || a.index < GAP);
|
|
194
2061
|
return Promise.all(candidates.map(async (a) => scanAddressUtxos(es, a, params))).then((r) => r.flat());
|
|
195
2062
|
};
|
|
2063
|
+
var BitcoinScanStore = class {
|
|
2064
|
+
engineStore;
|
|
2065
|
+
async unlock(store) {
|
|
2066
|
+
this.engineStore = store;
|
|
2067
|
+
const raw = await store.collection(COLL).get("state");
|
|
2068
|
+
const parsed = raw === void 0 ? void 0 : JSON.parse(new TextDecoder().decode(raw));
|
|
2069
|
+
if (!isObject$10(parsed) || !Array.isArray(parsed["addresses"]) || !Array.isArray(parsed["utxos"])) return;
|
|
2070
|
+
return {
|
|
2071
|
+
addresses: asArray(parsed["addresses"]).flatMap((a) => toScanAddress(a)),
|
|
2072
|
+
utxos: asArray(parsed["utxos"]).flatMap((u) => toScanUtxo(u)),
|
|
2073
|
+
scannedAt: typeof parsed["scannedAt"] === "number" ? parsed["scannedAt"] : 0
|
|
2074
|
+
};
|
|
2075
|
+
}
|
|
2076
|
+
async persist(state) {
|
|
2077
|
+
const store = this.engineStore;
|
|
2078
|
+
if (store !== void 0) await store.collection(COLL).put("state", new TextEncoder().encode(JSON.stringify(state)));
|
|
2079
|
+
}
|
|
2080
|
+
nextUnused(state, chain) {
|
|
2081
|
+
return state.addresses.find((a) => a.chain === chain && !a.used);
|
|
2082
|
+
}
|
|
2083
|
+
lock() {
|
|
2084
|
+
this.engineStore = void 0;
|
|
2085
|
+
}
|
|
2086
|
+
};
|
|
196
2087
|
const str$1 = (v) => typeof v === "string" ? v : void 0;
|
|
197
2088
|
const num = (v) => typeof v === "number" ? v : void 0;
|
|
2089
|
+
const toScanAddress = (v) => {
|
|
2090
|
+
if (!isObject$10(v)) return [];
|
|
2091
|
+
const chain = num(v["chain"]);
|
|
2092
|
+
const index = num(v["index"]);
|
|
2093
|
+
const address = str$1(v["address"]);
|
|
2094
|
+
const used = v["used"] === true;
|
|
2095
|
+
return chain !== void 0 && index !== void 0 && address !== void 0 && (chain === 0 || chain === 1) ? [{
|
|
2096
|
+
chain: chain === 0 ? 0 : 1,
|
|
2097
|
+
index,
|
|
2098
|
+
address,
|
|
2099
|
+
used
|
|
2100
|
+
}] : [];
|
|
2101
|
+
};
|
|
198
2102
|
const toScanUtxo = (v) => {
|
|
199
2103
|
if (!isObject$10(v)) return [];
|
|
200
2104
|
const txid = str$1(v["txid"]);
|
|
@@ -280,6 +2184,10 @@ var BtcUtxoStore = class BtcUtxoStore {
|
|
|
280
2184
|
});
|
|
281
2185
|
this.persist();
|
|
282
2186
|
}
|
|
2187
|
+
/** True while a pending request or an unconfirmed transaction holds this output. */
|
|
2188
|
+
isLocked(u) {
|
|
2189
|
+
return this.utxos.get(BtcUtxoStore.key(u))?.status === "locked";
|
|
2190
|
+
}
|
|
283
2191
|
/** Released on failed/rejected/dropped; spent-removed on confirmed. */
|
|
284
2192
|
endTx(txId, final) {
|
|
285
2193
|
if (final === "confirmed") {
|
|
@@ -307,12 +2215,7 @@ var BtcUtxoStore = class BtcUtxoStore {
|
|
|
307
2215
|
this.engineStore = void 0;
|
|
308
2216
|
}
|
|
309
2217
|
};
|
|
310
|
-
|
|
311
|
-
//#region src/transactions/route.ts
|
|
312
|
-
const newId = (random) => `tx-${[...random(8)].map((b) => b.toString(16).padStart(2, "0")).join("")}`;
|
|
313
|
-
const errorMessage = (error) => error instanceof Error ? error.message : String(error);
|
|
314
|
-
//#endregion
|
|
315
|
-
//#region src/accounts/errors.ts
|
|
2218
|
+
// src/accounts/errors.ts
|
|
316
2219
|
var AccountTreeError = class extends Error {
|
|
317
2220
|
category;
|
|
318
2221
|
constructor(category, message, cause) {
|
|
@@ -321,8 +2224,7 @@ var AccountTreeError = class extends Error {
|
|
|
321
2224
|
this.category = category;
|
|
322
2225
|
}
|
|
323
2226
|
};
|
|
324
|
-
|
|
325
|
-
//#region src/accounts/controller.ts
|
|
2227
|
+
// src/accounts/controller.ts
|
|
326
2228
|
const EMPTY_TREE = {
|
|
327
2229
|
wallets: {},
|
|
328
2230
|
groups: {},
|
|
@@ -461,7 +2363,7 @@ const check = async (family, run) => {
|
|
|
461
2363
|
} catch (error) {
|
|
462
2364
|
return [family, {
|
|
463
2365
|
used: false,
|
|
464
|
-
error: errorMessage(error)
|
|
2366
|
+
error: errorMessage$1(error)
|
|
465
2367
|
}];
|
|
466
2368
|
}
|
|
467
2369
|
};
|
|
@@ -726,7 +2628,6 @@ var AccountTreeController = class extends Controller {
|
|
|
726
2628
|
...g.boundPubkey === void 0 ? {} : { boundPubkey: g.boundPubkey }
|
|
727
2629
|
});
|
|
728
2630
|
return {
|
|
729
|
-
v: 1,
|
|
730
2631
|
wallet,
|
|
731
2632
|
groups
|
|
732
2633
|
};
|
|
@@ -1138,14 +3039,14 @@ function persistedToState(tree) {
|
|
|
1138
3039
|
totals: tree.totals
|
|
1139
3040
|
};
|
|
1140
3041
|
}
|
|
1141
|
-
|
|
1142
|
-
//#region src/chains/evm.ts
|
|
3042
|
+
// src/chains/evm.ts
|
|
1143
3043
|
/**
|
|
1144
3044
|
* EVM chain adapter: native transfers through a viem PublicClient injected by NetworkController.
|
|
1145
3045
|
* Fee tiers come from eth_feeHistory (20 blocks, p10/p50/p90 → slow/normal/fast, maxFee = 2×baseFee +
|
|
1146
3046
|
* priority); networks that cannot answer feeHistory fall back to eth_gasPrice. gas = estimateGas ×
|
|
1147
|
-
* 1.2 (ceil).
|
|
1148
|
-
*
|
|
3047
|
+
* 1.2 (ceil). The next nonce is read on a per-(network, from) serial queue as max(pending count,
|
|
3048
|
+
* last broadcast + 1) and is only recorded once a broadcast succeeds. Finalization uses the
|
|
3049
|
+
* `finalized` block tag; receipts with status 0 fail.
|
|
1149
3050
|
*/
|
|
1150
3051
|
const ERC20_ABI = [
|
|
1151
3052
|
{
|
|
@@ -1203,6 +3104,15 @@ const checkedAddress = (v) => {
|
|
|
1203
3104
|
return v;
|
|
1204
3105
|
};
|
|
1205
3106
|
const hex$1 = (v) => numberToHex(v);
|
|
3107
|
+
const evmRpc = async (client, method, params) => client.transport.request({
|
|
3108
|
+
method,
|
|
3109
|
+
params
|
|
3110
|
+
});
|
|
3111
|
+
const evmRpcHex = async (client, method, params) => {
|
|
3112
|
+
const result = await evmRpc(client, method, params);
|
|
3113
|
+
if (typeof result !== "string") throw new TypeError(`${method} returned a malformed result`);
|
|
3114
|
+
return result;
|
|
3115
|
+
};
|
|
1206
3116
|
var EvmChainAdapter = class {
|
|
1207
3117
|
family = "evm";
|
|
1208
3118
|
nonces = /* @__PURE__ */ new Map();
|
|
@@ -1218,31 +3128,35 @@ var EvmChainAdapter = class {
|
|
|
1218
3128
|
this.nonces.set(key, next);
|
|
1219
3129
|
return next;
|
|
1220
3130
|
}
|
|
1221
|
-
|
|
1222
|
-
|
|
1223
|
-
|
|
1224
|
-
params
|
|
1225
|
-
});
|
|
1226
|
-
}
|
|
1227
|
-
async rpcHex(client, method, params) {
|
|
1228
|
-
const result = await this.rpc(client, method, params);
|
|
1229
|
-
if (typeof result !== "string") throw new TypeError(`${method} returned a malformed result`);
|
|
1230
|
-
return result;
|
|
3131
|
+
/** The endpoint's pending transaction count for `from`. */
|
|
3132
|
+
async pendingCount(client, from) {
|
|
3133
|
+
return Number(await evmRpcHex(client, "eth_getTransactionCount", [from, "pending"]));
|
|
1231
3134
|
}
|
|
1232
|
-
/**
|
|
1233
|
-
|
|
3135
|
+
/**
|
|
3136
|
+
* The next nonce for (network, from): the larger of the endpoint's pending count and one past the
|
|
3137
|
+
* highest nonce this engine already broadcast. Reading it reserves nothing — only
|
|
3138
|
+
* {@link commitNonce} after a successful broadcast advances the local record.
|
|
3139
|
+
*/
|
|
3140
|
+
async nextNonce(client, network, from) {
|
|
1234
3141
|
const key = `${network}:${from}`;
|
|
1235
3142
|
return this.serial(key, async () => {
|
|
1236
|
-
const pending =
|
|
3143
|
+
const pending = await this.pendingCount(client, from);
|
|
1237
3144
|
const local = this.lastNonce.get(key);
|
|
1238
|
-
|
|
1239
|
-
this.lastNonce.set(key, nonce);
|
|
1240
|
-
return nonce;
|
|
3145
|
+
return Math.max(pending, local === void 0 ? 0 : local + 1);
|
|
1241
3146
|
});
|
|
1242
3147
|
}
|
|
3148
|
+
/** True once a broadcast at `nonce` or later is recorded for (network, from). */
|
|
3149
|
+
nonceConsumed(network, from, nonce) {
|
|
3150
|
+
const local = this.lastNonce.get(`${network}:${from}`);
|
|
3151
|
+
return local !== void 0 && local >= nonce;
|
|
3152
|
+
}
|
|
3153
|
+
commitNonce(network, from, nonce) {
|
|
3154
|
+
const key = `${network}:${from}`;
|
|
3155
|
+
this.lastNonce.set(key, Math.max(this.lastNonce.get(key) ?? nonce, nonce));
|
|
3156
|
+
}
|
|
1243
3157
|
/** ERC-20 gas is buffered ×1.1 instead of the native ×1.2 (chains.mdx "EVM"). */
|
|
1244
|
-
async build(client, network, from, intent) {
|
|
1245
|
-
const nonce = await this.
|
|
3158
|
+
async build(client, network, from, intent, nonceOverride) {
|
|
3159
|
+
const nonce = nonceOverride ?? await this.nextNonce(client, network, from);
|
|
1246
3160
|
const erc20 = intent.kind === "erc20";
|
|
1247
3161
|
const to = checksumEvmAddress(erc20 ? intent.token : intent.to);
|
|
1248
3162
|
const value = erc20 ? 0n : intent.value;
|
|
@@ -1250,14 +3164,16 @@ var EvmChainAdapter = class {
|
|
|
1250
3164
|
abi: ERC20_ABI,
|
|
1251
3165
|
functionName: "transfer",
|
|
1252
3166
|
args: [checkedAddress(intent.to), intent.value]
|
|
1253
|
-
}) : "0x";
|
|
1254
|
-
const estimate = BigInt(await
|
|
3167
|
+
}) : intent.kind === "call" ? intent.data : "0x";
|
|
3168
|
+
const estimate = BigInt(await evmRpcHex(client, "eth_estimateGas", [{
|
|
1255
3169
|
from,
|
|
1256
3170
|
to,
|
|
1257
3171
|
value: hex$1(value),
|
|
1258
3172
|
data
|
|
1259
3173
|
}]));
|
|
1260
3174
|
const scale = erc20 ? 11n : 12n;
|
|
3175
|
+
const buffered = estimate * scale / 10n + (estimate * scale % 10n === 0n ? 0n : 1n);
|
|
3176
|
+
const minGas = intent.kind === "call" ? intent.minGas ?? 0n : 0n;
|
|
1261
3177
|
return {
|
|
1262
3178
|
family: "evm",
|
|
1263
3179
|
network,
|
|
@@ -1266,12 +3182,12 @@ var EvmChainAdapter = class {
|
|
|
1266
3182
|
value,
|
|
1267
3183
|
data,
|
|
1268
3184
|
nonce,
|
|
1269
|
-
gas:
|
|
3185
|
+
gas: buffered > minGas ? buffered : minGas
|
|
1270
3186
|
};
|
|
1271
3187
|
}
|
|
1272
3188
|
async estimateFee(client) {
|
|
1273
3189
|
try {
|
|
1274
|
-
const history = await
|
|
3190
|
+
const history = await evmRpc(client, "eth_feeHistory", [
|
|
1275
3191
|
"0x14",
|
|
1276
3192
|
"latest",
|
|
1277
3193
|
[
|
|
@@ -1301,7 +3217,7 @@ var EvmChainAdapter = class {
|
|
|
1301
3217
|
tier(2, "fast")
|
|
1302
3218
|
];
|
|
1303
3219
|
} catch {
|
|
1304
|
-
const price = BigInt(await
|
|
3220
|
+
const price = BigInt(await evmRpcHex(client, "eth_gasPrice", []));
|
|
1305
3221
|
return [
|
|
1306
3222
|
"slow",
|
|
1307
3223
|
"normal",
|
|
@@ -1358,21 +3274,20 @@ var EvmChainAdapter = class {
|
|
|
1358
3274
|
return `0x${[...bytes].map((b) => b.toString(16).padStart(2, "0")).join("")}`;
|
|
1359
3275
|
}
|
|
1360
3276
|
async broadcast(client, raw) {
|
|
1361
|
-
return
|
|
3277
|
+
return evmRpcHex(client, "eth_sendRawTransaction", [raw]);
|
|
1362
3278
|
}
|
|
1363
3279
|
async status(client, hash) {
|
|
1364
|
-
const receipt = await
|
|
3280
|
+
const receipt = await evmRpc(client, "eth_getTransactionReceipt", [hash]);
|
|
1365
3281
|
if (receipt === null || typeof receipt !== "object") return "pending";
|
|
1366
3282
|
if ("status" in receipt && typeof receipt.status === "string" && BigInt(receipt.status) === 0n) return "failed";
|
|
1367
3283
|
try {
|
|
1368
|
-
const finalized = await
|
|
3284
|
+
const finalized = await evmRpc(client, "eth_getBlockByNumber", ["finalized", false]);
|
|
1369
3285
|
if (finalized !== null && typeof finalized === "object" && "number" in finalized && typeof finalized.number === "string" && "blockNumber" in receipt && typeof receipt.blockNumber === "string" && BigInt(receipt.blockNumber) <= BigInt(finalized.number)) return "finalized";
|
|
1370
3286
|
} catch {}
|
|
1371
3287
|
return "confirmed";
|
|
1372
3288
|
}
|
|
1373
3289
|
};
|
|
1374
|
-
|
|
1375
|
-
//#region src/assets/controller.ts
|
|
3290
|
+
// src/assets/controller.ts
|
|
1376
3291
|
/**
|
|
1377
3292
|
* EVM balances per (account address, network). Native via eth_getBalance; token balanceOf calls go
|
|
1378
3293
|
* through the network's Multicall3 (one aggregate eth_call) when the registry declares it,
|
|
@@ -1504,7 +3419,7 @@ var AssetsController = class extends Controller {
|
|
|
1504
3419
|
return;
|
|
1505
3420
|
}
|
|
1506
3421
|
const client = this.options.networks.client(network);
|
|
1507
|
-
const native = BigInt(await
|
|
3422
|
+
const native = BigInt(await evmRpcHex(client, "eth_getBalance", [address, "latest"])).toString();
|
|
1508
3423
|
const custom = this.customTokens.get(network) ?? [];
|
|
1509
3424
|
const tokens = await this.readTokens(client, info.multicall3, address, custom.map((t) => ({
|
|
1510
3425
|
address: t.address,
|
|
@@ -1556,8 +3471,8 @@ var AssetsController = class extends Controller {
|
|
|
1556
3471
|
const balances = multicall3 === void 0 ? await Promise.all(tokens.map(async (t) => this.balanceOf(client, t.address, owner))) : await this.balanceOfBatch(client, multicall3, owner, tokens.map((t) => t.address));
|
|
1557
3472
|
return Promise.all(tokens.map(async (t, i) => ({
|
|
1558
3473
|
address: t.address,
|
|
1559
|
-
symbol: await this.
|
|
1560
|
-
decimals: await this.
|
|
3474
|
+
symbol: await this.callToken(client, t.address, "symbol") ?? "?",
|
|
3475
|
+
decimals: await this.callToken(client, t.address, "decimals") ?? 18,
|
|
1561
3476
|
balance: balances[i]?.toString() ?? "0",
|
|
1562
3477
|
verified: t.verified
|
|
1563
3478
|
})));
|
|
@@ -1576,7 +3491,7 @@ var AssetsController = class extends Controller {
|
|
|
1576
3491
|
})
|
|
1577
3492
|
}))]
|
|
1578
3493
|
});
|
|
1579
|
-
const result = await
|
|
3494
|
+
const result = await evmRpcHex(client, "eth_call", [{
|
|
1580
3495
|
to: multicall3,
|
|
1581
3496
|
data
|
|
1582
3497
|
}, "latest"]);
|
|
@@ -1597,7 +3512,7 @@ var AssetsController = class extends Controller {
|
|
|
1597
3512
|
functionName: "balanceOf",
|
|
1598
3513
|
args: [checked(owner)]
|
|
1599
3514
|
});
|
|
1600
|
-
const result = await
|
|
3515
|
+
const result = await evmRpcHex(client, "eth_call", [{
|
|
1601
3516
|
to: token,
|
|
1602
3517
|
data
|
|
1603
3518
|
}, "latest"]);
|
|
@@ -1608,32 +3523,12 @@ var AssetsController = class extends Controller {
|
|
|
1608
3523
|
data: result
|
|
1609
3524
|
});
|
|
1610
3525
|
}
|
|
1611
|
-
async
|
|
1612
|
-
const data = encodeFunctionData({
|
|
1613
|
-
abi: ERC20_ABI,
|
|
1614
|
-
functionName: fn
|
|
1615
|
-
});
|
|
1616
|
-
const result = await this.rpcHex(client, "eth_call", [{
|
|
1617
|
-
to: token,
|
|
1618
|
-
data
|
|
1619
|
-
}, "latest"]);
|
|
1620
|
-
if (!isHex(result)) return;
|
|
1621
|
-
try {
|
|
1622
|
-
return decodeFunctionResult({
|
|
1623
|
-
abi: ERC20_ABI,
|
|
1624
|
-
functionName: fn,
|
|
1625
|
-
data: result
|
|
1626
|
-
});
|
|
1627
|
-
} catch {
|
|
1628
|
-
return;
|
|
1629
|
-
}
|
|
1630
|
-
}
|
|
1631
|
-
async callNumber(client, token, fn) {
|
|
3526
|
+
async callToken(client, token, fn) {
|
|
1632
3527
|
const data = encodeFunctionData({
|
|
1633
3528
|
abi: ERC20_ABI,
|
|
1634
3529
|
functionName: fn
|
|
1635
3530
|
});
|
|
1636
|
-
const result = await
|
|
3531
|
+
const result = await evmRpcHex(client, "eth_call", [{
|
|
1637
3532
|
to: token,
|
|
1638
3533
|
data
|
|
1639
3534
|
}, "latest"]);
|
|
@@ -1659,8 +3554,8 @@ var AssetsController = class extends Controller {
|
|
|
1659
3554
|
return;
|
|
1660
3555
|
}
|
|
1661
3556
|
const client = this.options.networks.client(network);
|
|
1662
|
-
await this.
|
|
1663
|
-
await this.
|
|
3557
|
+
await this.callToken(client, address, "symbol");
|
|
3558
|
+
await this.callToken(client, address, "decimals");
|
|
1664
3559
|
await this.persistToken(network, address);
|
|
1665
3560
|
}
|
|
1666
3561
|
async persistToken(network, address) {
|
|
@@ -1677,17 +3572,6 @@ var AssetsController = class extends Controller {
|
|
|
1677
3572
|
await this.engineStore.collection(COLLECTION$1).put("customTokens", new TextEncoder().encode(JSON.stringify(merged)));
|
|
1678
3573
|
}
|
|
1679
3574
|
}
|
|
1680
|
-
async rpc(client, method, params) {
|
|
1681
|
-
return client.transport.request({
|
|
1682
|
-
method,
|
|
1683
|
-
params
|
|
1684
|
-
});
|
|
1685
|
-
}
|
|
1686
|
-
async rpcHex(client, method, params) {
|
|
1687
|
-
const result = await this.rpc(client, method, params);
|
|
1688
|
-
if (typeof result !== "string") throw new TypeError(`${method} returned a malformed result`);
|
|
1689
|
-
return result;
|
|
1690
|
-
}
|
|
1691
3575
|
/** BTC balance = confirmed spendable + own unconfirmed change; frozen 546/330 shown separately. */
|
|
1692
3576
|
async refreshBitcoin(xpub, network) {
|
|
1693
3577
|
const es = this.options.networks.esplora(network);
|
|
@@ -1722,8 +3606,7 @@ var AssetsController = class extends Controller {
|
|
|
1722
3606
|
}));
|
|
1723
3607
|
}
|
|
1724
3608
|
};
|
|
1725
|
-
|
|
1726
|
-
//#region src/backup/errors.ts
|
|
3609
|
+
// src/backup/errors.ts
|
|
1727
3610
|
var BackupControllerError = class extends Error {
|
|
1728
3611
|
category;
|
|
1729
3612
|
constructor(category, message) {
|
|
@@ -1732,8 +3615,7 @@ var BackupControllerError = class extends Error {
|
|
|
1732
3615
|
this.category = category;
|
|
1733
3616
|
}
|
|
1734
3617
|
};
|
|
1735
|
-
|
|
1736
|
-
//#region src/backup/controller.ts
|
|
3618
|
+
// src/backup/controller.ts
|
|
1737
3619
|
/**
|
|
1738
3620
|
* The backup reconcile loop (design/backup.mdx "同步"): keeps the cloud file
|
|
1739
3621
|
* `qntx-wallet-backup-<id>.json` in step with the vault. Single-flight — a trigger during a run
|
|
@@ -1772,17 +3654,15 @@ function parseRecord(bytes) {
|
|
|
1772
3654
|
const { digest } = o;
|
|
1773
3655
|
const { uploadedAt } = o;
|
|
1774
3656
|
const { walletIds } = o;
|
|
1775
|
-
const { nostrKeyIds } = o;
|
|
1776
3657
|
if (typeof id !== "string" || !/^[0-9a-f]{32}$/.test(id)) return;
|
|
1777
3658
|
if (digest !== void 0 && (typeof digest !== "string" || !/^[0-9a-f]{64}$/.test(digest))) return;
|
|
1778
3659
|
if (uploadedAt !== void 0 && (typeof uploadedAt !== "number" || !Number.isSafeInteger(uploadedAt))) return;
|
|
1779
|
-
if (!isStringArray(walletIds)
|
|
3660
|
+
if (!isStringArray(walletIds)) return;
|
|
1780
3661
|
return {
|
|
1781
3662
|
id,
|
|
1782
3663
|
digest,
|
|
1783
3664
|
uploadedAt,
|
|
1784
|
-
walletIds
|
|
1785
|
-
nostrKeyIds
|
|
3665
|
+
walletIds
|
|
1786
3666
|
};
|
|
1787
3667
|
} catch {
|
|
1788
3668
|
return;
|
|
@@ -1899,8 +3779,7 @@ var BackupController = class extends Controller {
|
|
|
1899
3779
|
id,
|
|
1900
3780
|
digest: digestOf(text),
|
|
1901
3781
|
uploadedAt: previous?.uploadedAt,
|
|
1902
|
-
walletIds: []
|
|
1903
|
-
nostrKeyIds: []
|
|
3782
|
+
walletIds: []
|
|
1904
3783
|
});
|
|
1905
3784
|
}
|
|
1906
3785
|
try {
|
|
@@ -1930,20 +3809,10 @@ var BackupController = class extends Controller {
|
|
|
1930
3809
|
}
|
|
1931
3810
|
unprotected(snapshot, record) {
|
|
1932
3811
|
const covered = snapshot.backup !== null && record !== void 0 && record.id === snapshot.backup.id && record.digest !== void 0;
|
|
1933
|
-
|
|
1934
|
-
const keys = snapshot.nostr.keys.filter((k) => k.ref.kind === "standalone" && k.backedUp === false);
|
|
1935
|
-
const walletIds = wallets.map((w) => w.walletId).filter((id) => !covered || !record.walletIds.includes(id));
|
|
1936
|
-
const nostrKeyIds = keys.map((k) => k.publicKey).filter((id) => !covered || !record.nostrKeyIds.includes(id));
|
|
1937
|
-
return {
|
|
1938
|
-
walletIds: sorted(walletIds),
|
|
1939
|
-
nostrKeyIds: sorted(nostrKeyIds)
|
|
1940
|
-
};
|
|
3812
|
+
return { walletIds: sorted(snapshot.wallets.filter((w) => w.backup !== "mnemonic").map((w) => w.walletId).filter((id) => !covered || !record.walletIds.includes(id))) };
|
|
1941
3813
|
}
|
|
1942
3814
|
coverage(snapshot) {
|
|
1943
|
-
return {
|
|
1944
|
-
walletIds: sorted(snapshot.wallets.map((w) => w.walletId)),
|
|
1945
|
-
nostrKeyIds: sorted(snapshot.nostr.keys.filter((k) => k.ref.kind === "standalone").map((k) => k.publicKey))
|
|
1946
|
-
};
|
|
3815
|
+
return { walletIds: sorted(snapshot.wallets.map((w) => w.walletId)) };
|
|
1947
3816
|
}
|
|
1948
3817
|
setStatus(generation, status, error, record) {
|
|
1949
3818
|
if (generation !== this.generation) return;
|
|
@@ -1995,8 +3864,7 @@ var BackupController = class extends Controller {
|
|
|
1995
3864
|
id: adoption.id,
|
|
1996
3865
|
digest: adoption.digest,
|
|
1997
3866
|
uploadedAt: void 0,
|
|
1998
|
-
walletIds: []
|
|
1999
|
-
nostrKeyIds: []
|
|
3867
|
+
walletIds: []
|
|
2000
3868
|
};
|
|
2001
3869
|
if (generation !== this.generation) return;
|
|
2002
3870
|
await this.writeRecord(record);
|
|
@@ -2019,7 +3887,7 @@ var BackupController = class extends Controller {
|
|
|
2019
3887
|
}
|
|
2020
3888
|
const baseline = record !== void 0 && record.id === backup.id ? record.digest : void 0;
|
|
2021
3889
|
const cov = this.coverage(snapshot);
|
|
2022
|
-
if (baseline !== void 0 && record !== void 0 && sameSet(cov.walletIds, record.walletIds)
|
|
3890
|
+
if (baseline !== void 0 && record !== void 0 && sameSet(cov.walletIds, record.walletIds)) {
|
|
2023
3891
|
this.setStatus(generation, "synced", void 0, record);
|
|
2024
3892
|
return;
|
|
2025
3893
|
}
|
|
@@ -2044,8 +3912,7 @@ var BackupController = class extends Controller {
|
|
|
2044
3912
|
id: backup.id,
|
|
2045
3913
|
digest,
|
|
2046
3914
|
uploadedAt: this.now(),
|
|
2047
|
-
walletIds: cov.walletIds
|
|
2048
|
-
nostrKeyIds: cov.nostrKeyIds
|
|
3915
|
+
walletIds: cov.walletIds
|
|
2049
3916
|
};
|
|
2050
3917
|
if (generation !== this.generation) return;
|
|
2051
3918
|
await this.writeRecord(next);
|
|
@@ -2068,10 +3935,7 @@ function defaultState() {
|
|
|
2068
3935
|
status: "locked",
|
|
2069
3936
|
backup: null,
|
|
2070
3937
|
uploadedAt: void 0,
|
|
2071
|
-
unprotected: {
|
|
2072
|
-
walletIds: [],
|
|
2073
|
-
nostrKeyIds: []
|
|
2074
|
-
},
|
|
3938
|
+
unprotected: { walletIds: [] },
|
|
2075
3939
|
error: void 0
|
|
2076
3940
|
};
|
|
2077
3941
|
}
|
|
@@ -2085,7 +3949,8 @@ const PUBKEY_RE = /^[0-9a-f]{64}$/;
|
|
|
2085
3949
|
const KNOWN_DOC_KEYS = /* @__PURE__ */ new Set([
|
|
2086
3950
|
"v",
|
|
2087
3951
|
"wallet",
|
|
2088
|
-
"groups"
|
|
3952
|
+
"groups",
|
|
3953
|
+
"contacts"
|
|
2089
3954
|
]);
|
|
2090
3955
|
const KNOWN_WALLET_KEYS = /* @__PURE__ */ new Set(["name", "updatedAt"]);
|
|
2091
3956
|
const KNOWN_GROUP_KEYS = /* @__PURE__ */ new Set([
|
|
@@ -2099,11 +3964,14 @@ const isObject$7 = (v) => typeof v === "object" && v !== null && !Array.isArray(
|
|
|
2099
3964
|
const isNonNegativeInt = (v) => typeof v === "number" && Number.isSafeInteger(v) && v >= 0;
|
|
2100
3965
|
const validName = (v) => v === void 0 || typeof v === "string" && v.trim().length > 0 && v.trim().length <= NAME_MAX;
|
|
2101
3966
|
const trimmedName = (v) => typeof v === "string" ? v.trim() : void 0;
|
|
2102
|
-
/**
|
|
3967
|
+
/**
|
|
3968
|
+
* Strict decode — any malformed shape, unknown key, duplicate index or id, or invalid contact
|
|
3969
|
+
* returns `undefined`; only `v: 2` is read.
|
|
3970
|
+
*/
|
|
2103
3971
|
function decodeMetaDoc(value) {
|
|
2104
3972
|
if (!isObject$7(value)) return;
|
|
2105
3973
|
for (const k of Object.keys(value)) if (!KNOWN_DOC_KEYS.has(k)) return;
|
|
2106
|
-
if (value["v"] !==
|
|
3974
|
+
if (value["v"] !== 2) return;
|
|
2107
3975
|
const { wallet } = value;
|
|
2108
3976
|
if (!isObject$7(wallet)) return;
|
|
2109
3977
|
for (const k of Object.keys(wallet)) if (!KNOWN_WALLET_KEYS.has(k)) return;
|
|
@@ -2132,9 +4000,18 @@ function decodeMetaDoc(value) {
|
|
|
2132
4000
|
});
|
|
2133
4001
|
}
|
|
2134
4002
|
out.sort((a, b) => a.index - b.index);
|
|
4003
|
+
const { contacts } = value;
|
|
4004
|
+
if (!Array.isArray(contacts) || contacts.length > 128) return;
|
|
4005
|
+
const records = /* @__PURE__ */ new Map();
|
|
4006
|
+
for (const item of contacts) {
|
|
4007
|
+
const record = decodeRecord(item);
|
|
4008
|
+
if (record === void 0 || records.has(record.id)) return;
|
|
4009
|
+
records.set(record.id, record);
|
|
4010
|
+
}
|
|
2135
4011
|
const name = trimmedName(wallet["name"]);
|
|
2136
4012
|
return {
|
|
2137
|
-
v:
|
|
4013
|
+
v: 2,
|
|
4014
|
+
contacts: [...records.values()].sort((a, b) => a.id < b.id ? -1 : 1),
|
|
2138
4015
|
wallet: {
|
|
2139
4016
|
updatedAt: wallet["updatedAt"],
|
|
2140
4017
|
...name === void 0 ? {} : { name }
|
|
@@ -2155,8 +4032,7 @@ function decodeMetaDocBytes(bytes) {
|
|
|
2155
4032
|
return;
|
|
2156
4033
|
}
|
|
2157
4034
|
}
|
|
2158
|
-
|
|
2159
|
-
//#region src/meta/errors.ts
|
|
4035
|
+
// src/meta/errors.ts
|
|
2160
4036
|
var MetaSyncError = class extends Error {
|
|
2161
4037
|
category;
|
|
2162
4038
|
constructor(category, message, cause) {
|
|
@@ -2165,15 +4041,15 @@ var MetaSyncError = class extends Error {
|
|
|
2165
4041
|
this.category = category;
|
|
2166
4042
|
}
|
|
2167
4043
|
};
|
|
2168
|
-
|
|
2169
|
-
//#region src/meta/controller.ts
|
|
4044
|
+
// src/meta/controller.ts
|
|
2170
4045
|
const META_SYNC_KIND = 30078;
|
|
2171
4046
|
const PUBLISH_DEBOUNCE_MS = 2e3;
|
|
2172
4047
|
/**
|
|
2173
4048
|
* Wallet-metadata sync over kind-30078 addressable events: one NIP-44-encrypted document per
|
|
2174
|
-
* wallet, authored by its
|
|
2175
|
-
* replaceable events would otherwise let a stale local tree overwrite a newer remote
|
|
2176
|
-
* Debounced publishes are per wallet; a generation counter cancels in-flight write-backs
|
|
4049
|
+
* wallet, authored by its dedicated sync key (WD-070). Pull always precedes publish in an unlock
|
|
4050
|
+
* session — replaceable events would otherwise let a stale local tree overwrite a newer remote
|
|
4051
|
+
* document. Debounced publishes are per wallet; a generation counter cancels in-flight write-backs
|
|
4052
|
+
* on lock.
|
|
2177
4053
|
*/
|
|
2178
4054
|
var MetaSyncController = class extends Controller {
|
|
2179
4055
|
options;
|
|
@@ -2184,6 +4060,7 @@ var MetaSyncController = class extends Controller {
|
|
|
2184
4060
|
timers = /* @__PURE__ */ new Map();
|
|
2185
4061
|
inflight = /* @__PURE__ */ new Set();
|
|
2186
4062
|
accountsUnsubscribe;
|
|
4063
|
+
addressBookUnsubscribe;
|
|
2187
4064
|
constructor(options) {
|
|
2188
4065
|
super({
|
|
2189
4066
|
name: "metaSync",
|
|
@@ -2197,6 +4074,9 @@ var MetaSyncController = class extends Controller {
|
|
|
2197
4074
|
this.accountsUnsubscribe = this.options.accounts.store.subscribe(() => {
|
|
2198
4075
|
this.onTreeChange();
|
|
2199
4076
|
});
|
|
4077
|
+
this.addressBookUnsubscribe = this.options.addressBook.store.subscribe(() => {
|
|
4078
|
+
this.onTreeChange();
|
|
4079
|
+
});
|
|
2200
4080
|
await this.pullAll(gen);
|
|
2201
4081
|
}
|
|
2202
4082
|
async destroy() {
|
|
@@ -2207,6 +4087,8 @@ var MetaSyncController = class extends Controller {
|
|
|
2207
4087
|
this.generation += 1;
|
|
2208
4088
|
this.accountsUnsubscribe?.();
|
|
2209
4089
|
this.accountsUnsubscribe = void 0;
|
|
4090
|
+
this.addressBookUnsubscribe?.();
|
|
4091
|
+
this.addressBookUnsubscribe = void 0;
|
|
2210
4092
|
for (const timer of this.timers.values()) clearTimeout(timer);
|
|
2211
4093
|
this.timers.clear();
|
|
2212
4094
|
this.pulled.clear();
|
|
@@ -2239,19 +4121,6 @@ var MetaSyncController = class extends Controller {
|
|
|
2239
4121
|
const snap = this.options.wallet.getSnapshot();
|
|
2240
4122
|
await Promise.all(snap.wallets.map(async (w) => this.pullOne(w.walletId, gen)));
|
|
2241
4123
|
}
|
|
2242
|
-
async pubkeyFor(signer) {
|
|
2243
|
-
return signer.getPublicKey();
|
|
2244
|
-
}
|
|
2245
|
-
signerFor(walletId) {
|
|
2246
|
-
const snap = this.options.wallet.getSnapshot();
|
|
2247
|
-
const ref = {
|
|
2248
|
-
kind: "derived",
|
|
2249
|
-
walletId,
|
|
2250
|
-
groupIndex: 0
|
|
2251
|
-
};
|
|
2252
|
-
if (!snap.nostr.keys.some((k) => k.ref.kind === "derived" && k.ref.walletId === walletId && k.ref.groupIndex === 0)) return;
|
|
2253
|
-
return this.options.nostr.signer(ref);
|
|
2254
|
-
}
|
|
2255
4124
|
setStatus(walletId, status) {
|
|
2256
4125
|
this.update((s) => ({
|
|
2257
4126
|
...s,
|
|
@@ -2264,15 +4133,11 @@ var MetaSyncController = class extends Controller {
|
|
|
2264
4133
|
async pullOne(walletId, gen) {
|
|
2265
4134
|
const config = this.options.metaSync;
|
|
2266
4135
|
if (config === void 0 || this.inflight.has(walletId)) return;
|
|
2267
|
-
const signer = this.
|
|
2268
|
-
if (signer === void 0) {
|
|
2269
|
-
if (gen === this.generation) this.setStatus(walletId, { status: "unavailable" });
|
|
2270
|
-
return;
|
|
2271
|
-
}
|
|
4136
|
+
const signer = this.options.nostr.syncSigner(walletId);
|
|
2272
4137
|
this.inflight.add(walletId);
|
|
2273
4138
|
this.setStatus(walletId, { status: "syncing" });
|
|
2274
4139
|
try {
|
|
2275
|
-
const pubkey = await
|
|
4140
|
+
const pubkey = await signer.getPublicKey();
|
|
2276
4141
|
const events = await config.relays.fetch(this.filterFor(pubkey, config.tag));
|
|
2277
4142
|
let remote;
|
|
2278
4143
|
let remoteSkipped = 0;
|
|
@@ -2284,16 +4149,15 @@ var MetaSyncController = class extends Controller {
|
|
|
2284
4149
|
if (remote === void 0 || e.created_at > remote.created_at) remote = e;
|
|
2285
4150
|
}
|
|
2286
4151
|
if (remote === void 0) {
|
|
2287
|
-
const local = this.
|
|
2288
|
-
const hasEdits = local.wallet.updatedAt > 0 || local.groups.some((g) => g.updatedAt > 0);
|
|
4152
|
+
const local = this.exportDoc(walletId);
|
|
4153
|
+
const hasEdits = local.wallet.updatedAt > 0 || local.groups.some((g) => g.updatedAt > 0) || local.contacts.length > 0;
|
|
2289
4154
|
if (gen !== this.generation) return;
|
|
2290
4155
|
this.pulled.add(walletId);
|
|
2291
4156
|
this.rememberExport(walletId);
|
|
2292
4157
|
this.setStatus(walletId, { status: "synced" });
|
|
2293
4158
|
if (hasEdits) await this.publishNow(walletId, gen);
|
|
2294
4159
|
} else {
|
|
2295
|
-
const
|
|
2296
|
-
const plaintext = decrypt === void 0 ? void 0 : await decrypt.call(signer, pubkey, remote.content).catch(() => void 0);
|
|
4160
|
+
const plaintext = await signer.decrypt(remote.content).catch(() => void 0);
|
|
2297
4161
|
const doc = plaintext === void 0 ? void 0 : decodeMetaDocBytes(new TextEncoder().encode(plaintext));
|
|
2298
4162
|
if (doc === void 0) {
|
|
2299
4163
|
if (gen !== this.generation) return;
|
|
@@ -2306,12 +4170,13 @@ var MetaSyncController = class extends Controller {
|
|
|
2306
4170
|
return;
|
|
2307
4171
|
}
|
|
2308
4172
|
const merge = this.options.accounts.mergeMeta(walletId, doc);
|
|
4173
|
+
const contacts = await this.options.addressBook.merge(doc.contacts);
|
|
2309
4174
|
if (gen !== this.generation) return;
|
|
2310
4175
|
this.lastRemoteCreatedAt.set(walletId, remote.created_at);
|
|
2311
4176
|
this.pulled.add(walletId);
|
|
2312
4177
|
this.rememberExport(walletId);
|
|
2313
4178
|
this.setStatus(walletId, { status: "synced" });
|
|
2314
|
-
if (merge.localNewer) await this.publishNow(walletId, gen);
|
|
4179
|
+
if (merge.localNewer || contacts.localNewer) await this.publishNow(walletId, gen);
|
|
2315
4180
|
}
|
|
2316
4181
|
if (remoteSkipped > 0) this.options.logger.warn(`meta sync: ignored ${remoteSkipped} invalid remote events`);
|
|
2317
4182
|
} catch (error) {
|
|
@@ -2326,6 +4191,14 @@ var MetaSyncController = class extends Controller {
|
|
|
2326
4191
|
this.inflight.delete(walletId);
|
|
2327
4192
|
}
|
|
2328
4193
|
}
|
|
4194
|
+
/** The wallet's tree plus the shared address book — every wallet's document carries both. */
|
|
4195
|
+
exportDoc(walletId) {
|
|
4196
|
+
return {
|
|
4197
|
+
v: 2,
|
|
4198
|
+
...this.options.accounts.exportMeta(walletId),
|
|
4199
|
+
contacts: this.options.addressBook.syncEntries()
|
|
4200
|
+
};
|
|
4201
|
+
}
|
|
2329
4202
|
filterFor(pubkey, tag) {
|
|
2330
4203
|
return {
|
|
2331
4204
|
kinds: [META_SYNC_KIND],
|
|
@@ -2341,8 +4214,7 @@ var MetaSyncController = class extends Controller {
|
|
|
2341
4214
|
/** Snapshot the wallet's exported doc — the debounce publishes only on a real change. */
|
|
2342
4215
|
rememberExport(walletId) {
|
|
2343
4216
|
try {
|
|
2344
|
-
|
|
2345
|
-
this.lastExported.set(walletId, JSON.stringify(doc));
|
|
4217
|
+
this.lastExported.set(walletId, JSON.stringify(this.exportDoc(walletId)));
|
|
2346
4218
|
} catch {}
|
|
2347
4219
|
}
|
|
2348
4220
|
onTreeChange() {
|
|
@@ -2350,7 +4222,7 @@ var MetaSyncController = class extends Controller {
|
|
|
2350
4222
|
for (const walletId of this.pulled) {
|
|
2351
4223
|
let current;
|
|
2352
4224
|
try {
|
|
2353
|
-
current = JSON.stringify(this.
|
|
4225
|
+
current = JSON.stringify(this.exportDoc(walletId));
|
|
2354
4226
|
} catch {
|
|
2355
4227
|
continue;
|
|
2356
4228
|
}
|
|
@@ -2368,18 +4240,12 @@ var MetaSyncController = class extends Controller {
|
|
|
2368
4240
|
async publishNow(walletId, gen) {
|
|
2369
4241
|
const config = this.options.metaSync;
|
|
2370
4242
|
if (config === void 0 || !this.pulled.has(walletId)) return;
|
|
2371
|
-
const signer = this.
|
|
2372
|
-
if (signer === void 0) {
|
|
2373
|
-
this.setStatus(walletId, { status: "unavailable" });
|
|
2374
|
-
return;
|
|
2375
|
-
}
|
|
4243
|
+
const signer = this.options.nostr.syncSigner(walletId);
|
|
2376
4244
|
try {
|
|
2377
|
-
const bytes = encodeMetaDoc(this.
|
|
4245
|
+
const bytes = encodeMetaDoc(this.exportDoc(walletId));
|
|
2378
4246
|
if (bytes === void 0) throw new MetaSyncError("internal", "meta doc exceeds 64 KB");
|
|
2379
|
-
const
|
|
2380
|
-
|
|
2381
|
-
const pubkey = await this.pubkeyFor(signer);
|
|
2382
|
-
const content = await encrypt.call(signer, pubkey, new TextDecoder().decode(bytes));
|
|
4247
|
+
const pubkey = await signer.getPublicKey();
|
|
4248
|
+
const content = await signer.encrypt(new TextDecoder().decode(bytes));
|
|
2383
4249
|
const lastRemote = this.lastRemoteCreatedAt.get(walletId) ?? 0;
|
|
2384
4250
|
const created_at = Math.max(Math.floor(this.options.now() / 1e3), lastRemote + 1);
|
|
2385
4251
|
const event = await signer.signEvent({
|
|
@@ -2405,17 +4271,16 @@ var MetaSyncController = class extends Controller {
|
|
|
2405
4271
|
function categorizeSync(error) {
|
|
2406
4272
|
return error instanceof MetaSyncError ? error.category : "network";
|
|
2407
4273
|
}
|
|
2408
|
-
/** A decrypted doc that parses as JSON with a numeric `v` above
|
|
4274
|
+
/** A decrypted doc that parses as JSON with a numeric `v` above 2 is a newer schema, not garbage. */
|
|
2409
4275
|
function isUnsupportedDoc(plaintext) {
|
|
2410
4276
|
try {
|
|
2411
4277
|
const doc = JSON.parse(plaintext);
|
|
2412
|
-
return typeof doc === "object" && doc !== null && "v" in doc && typeof doc.v === "number" && doc.v >
|
|
4278
|
+
return typeof doc === "object" && doc !== null && "v" in doc && typeof doc.v === "number" && doc.v > 2;
|
|
2413
4279
|
} catch {
|
|
2414
4280
|
return false;
|
|
2415
4281
|
}
|
|
2416
4282
|
}
|
|
2417
|
-
|
|
2418
|
-
//#region src/chains/bitcoin.ts
|
|
4283
|
+
// src/chains/bitcoin.ts
|
|
2419
4284
|
const DUST = {
|
|
2420
4285
|
p2wpkh: 294,
|
|
2421
4286
|
p2tr: 330,
|
|
@@ -2550,8 +4415,8 @@ var BitcoinChainAdapter = class {
|
|
|
2550
4415
|
entry.nonWitnessUtxo = raw;
|
|
2551
4416
|
}
|
|
2552
4417
|
if (template === "btc.bip86") {
|
|
2553
|
-
entry.tapInternalKey = pub
|
|
2554
|
-
entry.tapBip32Derivation = [[pub
|
|
4418
|
+
entry.tapInternalKey = pub;
|
|
4419
|
+
entry.tapBip32Derivation = [[pub, {
|
|
2555
4420
|
hashes: [],
|
|
2556
4421
|
der: {
|
|
2557
4422
|
fingerprint,
|
|
@@ -2607,104 +4472,7 @@ var BitcoinChainAdapter = class {
|
|
|
2607
4472
|
};
|
|
2608
4473
|
}
|
|
2609
4474
|
};
|
|
2610
|
-
|
|
2611
|
-
//#region src/networks/registry.ts
|
|
2612
|
-
const entry = (chainId, name, symbol, testnet, _source) => ({
|
|
2613
|
-
id: `eip155:${chainId}`,
|
|
2614
|
-
chainId,
|
|
2615
|
-
name,
|
|
2616
|
-
symbol,
|
|
2617
|
-
decimals: 18,
|
|
2618
|
-
testnet,
|
|
2619
|
-
family: "evm",
|
|
2620
|
-
multicall3: "0xcA11bde05977b3631167028862bE2a173976CA11"
|
|
2621
|
-
});
|
|
2622
|
-
const NETWORKS = [
|
|
2623
|
-
entry(1, "Ethereum", "ETH", false, "https://ethereum.org/en/developers/docs/networks/"),
|
|
2624
|
-
entry(8453, "Base", "ETH", false, "https://docs.base.org/docs/network-information"),
|
|
2625
|
-
entry(42161, "Arbitrum One", "ETH", false, "https://docs.arbitrum.io/for-devs/dev-tools-and-resources/chain-info"),
|
|
2626
|
-
entry(10, "OP Mainnet", "ETH", false, "https://docs.optimism.io/chain/networks"),
|
|
2627
|
-
entry(137, "Polygon", "POL", false, "https://docs.polygon.technology/pos/reference/rpc-endpoints/"),
|
|
2628
|
-
entry(56, "BNB Smart Chain", "BNB", false, "https://docs.bnbchain.org/bnb-smart-chain/developers/json_rpc/"),
|
|
2629
|
-
entry(999, "HyperEVM", "HYPE", false, "https://hyperliquid.gitbook.io/hyperliquid-docs/for-developers/hyperevm"),
|
|
2630
|
-
entry(43114, "Avalanche C-Chain", "AVAX", false, "https://build.avax.network/docs/tooling/rpc-providers"),
|
|
2631
|
-
entry(11155111, "Sepolia", "ETH", true, "https://ethereum.org/en/developers/docs/networks/"),
|
|
2632
|
-
entry(84532, "Base Sepolia", "ETH", true, "https://docs.base.org/docs/network-information"),
|
|
2633
|
-
entry(421614, "Arbitrum Sepolia", "ETH", true, "https://docs.arbitrum.io/for-devs/dev-tools-and-resources/chain-info"),
|
|
2634
|
-
entry(11155420, "OP Sepolia", "ETH", true, "https://docs.optimism.io/chain/networks"),
|
|
2635
|
-
entry(80002, "Polygon Amoy", "POL", true, "https://docs.polygon.technology/pos/reference/rpc-endpoints/"),
|
|
2636
|
-
entry(97, "BNB Smart Chain Testnet", "tBNB", true, "https://docs.bnbchain.org/bnb-smart-chain/developers/json_rpc/"),
|
|
2637
|
-
entry(998, "HyperEVM Testnet", "HYPE", true, "https://hyperliquid.gitbook.io/hyperliquid-docs/for-developers/hyperevm"),
|
|
2638
|
-
entry(43113, "Avalanche Fuji", "AVAX", true, "https://build.avax.network/docs/tooling/rpc-providers")
|
|
2639
|
-
];
|
|
2640
|
-
const networkById = (id) => [
|
|
2641
|
-
...NETWORKS,
|
|
2642
|
-
...SOLANA_NETWORKS,
|
|
2643
|
-
...BITCOIN_NETWORKS
|
|
2644
|
-
].find((n) => n.id === id);
|
|
2645
|
-
const BITCOIN_NETWORKS = [
|
|
2646
|
-
{
|
|
2647
|
-
id: "bip122:000000000019d6689c085ae165831e93",
|
|
2648
|
-
chainId: 0,
|
|
2649
|
-
name: "Bitcoin",
|
|
2650
|
-
symbol: "BTC",
|
|
2651
|
-
decimals: 8,
|
|
2652
|
-
testnet: false,
|
|
2653
|
-
family: "bitcoin",
|
|
2654
|
-
genesisHash: "000000000019d6689c085ae165831e934ff763ae46a2a6c172b3f1b60a8ce26f"
|
|
2655
|
-
},
|
|
2656
|
-
{
|
|
2657
|
-
id: "bip122:00000000da84f2bafbbc53dee25a72ae",
|
|
2658
|
-
chainId: 0,
|
|
2659
|
-
name: "Bitcoin Testnet4",
|
|
2660
|
-
symbol: "tBTC",
|
|
2661
|
-
decimals: 8,
|
|
2662
|
-
testnet: true,
|
|
2663
|
-
family: "bitcoin",
|
|
2664
|
-
genesisHash: "00000000da84f2bafbbc53dee25a72ae507ff4914b867c565be350b0da8bf043"
|
|
2665
|
-
},
|
|
2666
|
-
{
|
|
2667
|
-
id: "bip122:00000008819873e925422c1ff0f99f7c",
|
|
2668
|
-
chainId: 0,
|
|
2669
|
-
name: "Bitcoin Signet",
|
|
2670
|
-
symbol: "sBTC",
|
|
2671
|
-
decimals: 8,
|
|
2672
|
-
testnet: true,
|
|
2673
|
-
family: "bitcoin",
|
|
2674
|
-
genesisHash: "00000008819873e925422c1ff0f99f7cc9bbb232af63a077a480a3633bee1ef6"
|
|
2675
|
-
},
|
|
2676
|
-
{
|
|
2677
|
-
id: "bip122:0f9188f13cb7b2c71f2a335e3a4fc328",
|
|
2678
|
-
chainId: 0,
|
|
2679
|
-
name: "Bitcoin Regtest",
|
|
2680
|
-
symbol: "rBTC",
|
|
2681
|
-
decimals: 8,
|
|
2682
|
-
testnet: true,
|
|
2683
|
-
family: "bitcoin",
|
|
2684
|
-
genesisHash: "0f9188f13cb7b2c71f2a335e3a4fc328bf5beb436012afca590b1a11466e2206"
|
|
2685
|
-
}
|
|
2686
|
-
];
|
|
2687
|
-
const SOLANA_NETWORKS = [{
|
|
2688
|
-
id: "solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp",
|
|
2689
|
-
chainId: 0,
|
|
2690
|
-
name: "Solana",
|
|
2691
|
-
symbol: "SOL",
|
|
2692
|
-
decimals: 9,
|
|
2693
|
-
testnet: false,
|
|
2694
|
-
family: "solana",
|
|
2695
|
-
genesisHash: "5eykt4UsFv8P8NJdTREpY1vzqKqZKvdpWvDVGok8cUQPCbq9AzdTQvpNbNvyq4qDvMNTrDDezsFapSihepu2eQEdje"
|
|
2696
|
-
}, {
|
|
2697
|
-
id: "solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1",
|
|
2698
|
-
chainId: 0,
|
|
2699
|
-
name: "Solana Devnet",
|
|
2700
|
-
symbol: "SOL",
|
|
2701
|
-
decimals: 9,
|
|
2702
|
-
testnet: true,
|
|
2703
|
-
family: "solana",
|
|
2704
|
-
genesisHash: "EtWTRABZaYq6iMfeYKouRu166VU2xqa1wcaWoxPkrZBGGSWgX4GwCJRmgdcvRaRmFbXfw3nDZBTqsr"
|
|
2705
|
-
}];
|
|
2706
|
-
//#endregion
|
|
2707
|
-
//#region src/networks/controller.ts
|
|
4475
|
+
// src/networks/controller.ts
|
|
2708
4476
|
/**
|
|
2709
4477
|
* EVM network controller: the built-in registry plus host-provided and user-added RPC endpoints.
|
|
2710
4478
|
* Each network gets a lazily-created viem PublicClient on a `fallback` transport — endpoints are
|
|
@@ -2715,21 +4483,86 @@ const SOLANA_NETWORKS = [{
|
|
|
2715
4483
|
const esploraSleep = async (ms) => new Promise((resolve) => {
|
|
2716
4484
|
setTimeout(resolve, ms);
|
|
2717
4485
|
});
|
|
4486
|
+
/** Bound on the unlock-time `/v1/networks` read so a dead gateway cannot stall unlocking. */
|
|
4487
|
+
const GATEWAY_PROBE_TIMEOUT_MS = 5e3;
|
|
2718
4488
|
const isObject$5 = (v) => typeof v === "object" && v !== null;
|
|
4489
|
+
const makeClient = (info, urls, fetchFn) => createPublicClient({
|
|
4490
|
+
chain: {
|
|
4491
|
+
id: info.chainId,
|
|
4492
|
+
name: info.name,
|
|
4493
|
+
nativeCurrency: {
|
|
4494
|
+
name: info.symbol,
|
|
4495
|
+
symbol: info.symbol,
|
|
4496
|
+
decimals: info.decimals
|
|
4497
|
+
},
|
|
4498
|
+
rpcUrls: { default: { http: [...urls] } }
|
|
4499
|
+
},
|
|
4500
|
+
transport: fallback(urls.map((url) => http(url, { fetchFn }))),
|
|
4501
|
+
batch: { multicall: true }
|
|
4502
|
+
});
|
|
4503
|
+
const esploraClient = (urls, fetchFn) => esplora(urls, async (url, init) => {
|
|
4504
|
+
const r = await fetchFn(url, init);
|
|
4505
|
+
return {
|
|
4506
|
+
ok: r.ok,
|
|
4507
|
+
status: r.status,
|
|
4508
|
+
headers: { get: (h) => r.headers.get(h) ?? void 0 },
|
|
4509
|
+
json: async () => r.json(),
|
|
4510
|
+
text: async () => r.text()
|
|
4511
|
+
};
|
|
4512
|
+
}, esploraSleep);
|
|
4513
|
+
const solanaCaller = (urls, fetchFn) => ({ call: async (method, params) => {
|
|
4514
|
+
const attempt = async (rest, last) => {
|
|
4515
|
+
const [url, ...next] = rest;
|
|
4516
|
+
if (url === void 0) throw last instanceof Error ? last : new Error(typeof last === "string" ? last : method);
|
|
4517
|
+
try {
|
|
4518
|
+
const body = await (await fetchFn(url, {
|
|
4519
|
+
method: "POST",
|
|
4520
|
+
headers: { "content-type": "application/json" },
|
|
4521
|
+
body: JSON.stringify({
|
|
4522
|
+
jsonrpc: "2.0",
|
|
4523
|
+
id: 1,
|
|
4524
|
+
method,
|
|
4525
|
+
params
|
|
4526
|
+
})
|
|
4527
|
+
})).json();
|
|
4528
|
+
if (!isObject$5(body)) return await attempt(next, /* @__PURE__ */ new Error(`malformed response from ${url}`));
|
|
4529
|
+
if (body["error"] !== void 0) {
|
|
4530
|
+
const msg = isObject$5(body["error"]) ? body["error"]["message"] : void 0;
|
|
4531
|
+
return await attempt(next, new Error(typeof msg === "string" ? msg : method));
|
|
4532
|
+
}
|
|
4533
|
+
return body["result"];
|
|
4534
|
+
} catch (error) {
|
|
4535
|
+
return attempt(next, error);
|
|
4536
|
+
}
|
|
4537
|
+
};
|
|
4538
|
+
return attempt(urls, void 0);
|
|
4539
|
+
} });
|
|
2719
4540
|
var NetworkController = class extends Controller {
|
|
2720
4541
|
options;
|
|
2721
4542
|
engineStore;
|
|
2722
4543
|
clients = /* @__PURE__ */ new Map();
|
|
4544
|
+
gatewayBase;
|
|
4545
|
+
gatewayOrigin;
|
|
4546
|
+
/** Networks the gateway reported at unlock; empty while locked or when the read failed. */
|
|
4547
|
+
gatewayServed = /* @__PURE__ */ new Set();
|
|
2723
4548
|
constructor(options) {
|
|
2724
4549
|
super({
|
|
2725
4550
|
name: "networks",
|
|
2726
4551
|
defaultState: () => ({ customRpc: {} })
|
|
2727
4552
|
});
|
|
2728
4553
|
this.options = options;
|
|
4554
|
+
if (options.gateway !== void 0) {
|
|
4555
|
+
this.gatewayBase = options.gateway.url.replace(/\/+$/, "");
|
|
4556
|
+
this.gatewayOrigin = new URL(options.gateway.url).origin;
|
|
4557
|
+
}
|
|
2729
4558
|
}
|
|
2730
4559
|
async init() {
|
|
2731
4560
|
this.engineStore = await this.options.openStore();
|
|
2732
|
-
|
|
4561
|
+
await this.loadCustomRpc(this.engineStore);
|
|
4562
|
+
await this.loadGatewayNetworks();
|
|
4563
|
+
}
|
|
4564
|
+
async loadCustomRpc(store) {
|
|
4565
|
+
const raw = await store.collection("networks").get("customRpc");
|
|
2733
4566
|
if (raw === void 0) return;
|
|
2734
4567
|
const parsed = JSON.parse(new TextDecoder().decode(raw));
|
|
2735
4568
|
if (typeof parsed === "object" && parsed !== null) {
|
|
@@ -2741,16 +4574,56 @@ var NetworkController = class extends Controller {
|
|
|
2741
4574
|
}));
|
|
2742
4575
|
}
|
|
2743
4576
|
}
|
|
4577
|
+
/** Reads the gateway's served networks; any failure leaves the gateway unused until next unlock. */
|
|
4578
|
+
async loadGatewayNetworks() {
|
|
4579
|
+
if (this.gatewayBase === void 0) return;
|
|
4580
|
+
const url = `${this.gatewayBase}${networksPath}`;
|
|
4581
|
+
try {
|
|
4582
|
+
const res = await this.gatewayFetch(url, { signal: AbortSignal.timeout(GATEWAY_PROBE_TIMEOUT_MS) });
|
|
4583
|
+
if (!res.ok) throw new Error(`status ${res.status}`);
|
|
4584
|
+
const body = await res.json();
|
|
4585
|
+
const served = parseWith(servedNetworksResponse, body);
|
|
4586
|
+
if (served === void 0) throw new Error("malformed response");
|
|
4587
|
+
this.gatewayServed = new Set(served.networks);
|
|
4588
|
+
} catch {
|
|
4589
|
+
this.gatewayServed = /* @__PURE__ */ new Set();
|
|
4590
|
+
this.options.logger.warn(`gateway unavailable, not used for this session: ${url}`);
|
|
4591
|
+
}
|
|
4592
|
+
}
|
|
4593
|
+
/** `fetch` that adds the install id to requests bound for the gateway's origin only. */
|
|
4594
|
+
gatewayFetch = async (input, init) => {
|
|
4595
|
+
const { gateway } = this.options;
|
|
4596
|
+
const target = input instanceof Request ? input.url : String(input);
|
|
4597
|
+
if (gateway === void 0 || new URL(target).origin !== this.gatewayOrigin) return this.options.fetch(input, init);
|
|
4598
|
+
const headers = new Headers(init?.headers);
|
|
4599
|
+
headers.set(INSTALL_ID_HEADER, gateway.installId);
|
|
4600
|
+
return this.options.fetch(input, {
|
|
4601
|
+
...init,
|
|
4602
|
+
headers
|
|
4603
|
+
});
|
|
4604
|
+
};
|
|
2744
4605
|
async destroy() {
|
|
2745
4606
|
this.lock();
|
|
2746
4607
|
await Promise.resolve();
|
|
2747
4608
|
}
|
|
2748
4609
|
lock() {
|
|
4610
|
+
this.gatewayServed = /* @__PURE__ */ new Set();
|
|
2749
4611
|
this.clients.clear();
|
|
2750
4612
|
this.engineStore = void 0;
|
|
2751
4613
|
}
|
|
4614
|
+
/**
|
|
4615
|
+
* Ordered endpoints: a network with custom endpoints uses custom → host and never the gateway;
|
|
4616
|
+
* otherwise a gateway-served network uses gateway → host; else host only.
|
|
4617
|
+
*/
|
|
2752
4618
|
endpoints(network) {
|
|
2753
|
-
|
|
4619
|
+
const custom = this.getState().customRpc[network] ?? [];
|
|
4620
|
+
const host = this.options.rpc[network] ?? [];
|
|
4621
|
+
if (custom.length > 0) return [...custom, ...host];
|
|
4622
|
+
if (this.gatewayBase !== void 0 && this.gatewayServed.has(network)) {
|
|
4623
|
+
const path = networkById(network)?.family === "bitcoin" ? esploraBasePath(network) : rpcPath(network);
|
|
4624
|
+
return [`${this.gatewayBase}${path}`, ...host];
|
|
4625
|
+
}
|
|
4626
|
+
return host;
|
|
2754
4627
|
}
|
|
2755
4628
|
info(network) {
|
|
2756
4629
|
const info = networkById(network);
|
|
@@ -2763,78 +4636,56 @@ var NetworkController = class extends Controller {
|
|
|
2763
4636
|
if (cached !== void 0) return cached;
|
|
2764
4637
|
const urls = this.endpoints(network);
|
|
2765
4638
|
if (urls.length === 0) throw new Error(`no rpc endpoint for ${network}`);
|
|
2766
|
-
const client =
|
|
2767
|
-
chain: {
|
|
2768
|
-
id: info.chainId,
|
|
2769
|
-
name: info.name,
|
|
2770
|
-
nativeCurrency: {
|
|
2771
|
-
name: info.symbol,
|
|
2772
|
-
symbol: info.symbol,
|
|
2773
|
-
decimals: info.decimals
|
|
2774
|
-
},
|
|
2775
|
-
rpcUrls: { default: { http: urls } }
|
|
2776
|
-
},
|
|
2777
|
-
transport: fallback(urls.map((url) => http(url, { fetchFn: this.options.fetch }))),
|
|
2778
|
-
batch: { multicall: true }
|
|
2779
|
-
});
|
|
4639
|
+
const client = makeClient(info, urls, this.gatewayFetch);
|
|
2780
4640
|
this.clients.set(network, client);
|
|
2781
4641
|
return client;
|
|
2782
4642
|
}
|
|
2783
|
-
/**
|
|
2784
|
-
|
|
2785
|
-
|
|
4643
|
+
/** True when the gateway heads this network's endpoint list (no custom endpoints, gateway-served). */
|
|
4644
|
+
gatewayFirst(network) {
|
|
4645
|
+
return (this.getState().customRpc[network] ?? []).length === 0 && this.gatewayBase !== void 0 && this.gatewayServed.has(network);
|
|
4646
|
+
}
|
|
4647
|
+
independent(network, make) {
|
|
4648
|
+
if (!this.gatewayFirst(network)) return { kind: "not-needed" };
|
|
4649
|
+
const hosts = this.options.rpc[network] ?? [];
|
|
4650
|
+
return hosts.length === 0 ? { kind: "unavailable" } : {
|
|
4651
|
+
kind: "available",
|
|
4652
|
+
client: make(hosts)
|
|
4653
|
+
};
|
|
4654
|
+
}
|
|
4655
|
+
/** Host-endpoint EVM client for cross-checking reads; never sends the install id. */
|
|
4656
|
+
independentClient(network) {
|
|
4657
|
+
return this.independent(network, (hosts) => makeClient(this.info(network), hosts, this.options.fetch));
|
|
4658
|
+
}
|
|
4659
|
+
independentSolanaRpc(network) {
|
|
4660
|
+
return this.independent(network, (hosts) => solanaCaller(hosts, this.options.fetch));
|
|
4661
|
+
}
|
|
4662
|
+
independentEsplora(network) {
|
|
4663
|
+
return this.independent(network, (hosts) => esploraClient(hosts, this.options.fetch));
|
|
4664
|
+
}
|
|
4665
|
+
/** Networks with at least one configured endpoint or served by the gateway, by family. */
|
|
2786
4666
|
enabled(family) {
|
|
2787
|
-
return [.../* @__PURE__ */ new Set([
|
|
4667
|
+
return [.../* @__PURE__ */ new Set([
|
|
4668
|
+
...Object.keys(this.options.rpc),
|
|
4669
|
+
...Object.keys(this.getState().customRpc),
|
|
4670
|
+
...this.gatewayServed
|
|
4671
|
+
])].filter((id) => {
|
|
2788
4672
|
const info = networkById(id);
|
|
2789
4673
|
return info !== void 0 && info.family === family;
|
|
2790
4674
|
});
|
|
2791
4675
|
}
|
|
4676
|
+
/** Solana JSON-RPC over the ordered endpoint list — kit's transport is single-endpoint. */
|
|
2792
4677
|
solanaRpc(network) {
|
|
2793
4678
|
const urls = this.endpoints(network);
|
|
2794
4679
|
if (urls.length === 0) throw new Error(`no rpc endpoint for ${network}`);
|
|
2795
|
-
return
|
|
2796
|
-
const attempt = async (rest, last) => {
|
|
2797
|
-
const [url, ...next] = rest;
|
|
2798
|
-
if (url === void 0) throw last instanceof Error ? last : new Error(typeof last === "string" ? last : method);
|
|
2799
|
-
try {
|
|
2800
|
-
const body = await (await this.options.fetch(url, {
|
|
2801
|
-
method: "POST",
|
|
2802
|
-
headers: { "content-type": "application/json" },
|
|
2803
|
-
body: JSON.stringify({
|
|
2804
|
-
jsonrpc: "2.0",
|
|
2805
|
-
id: 1,
|
|
2806
|
-
method,
|
|
2807
|
-
params
|
|
2808
|
-
})
|
|
2809
|
-
})).json();
|
|
2810
|
-
if (!isObject$5(body)) return await attempt(next, /* @__PURE__ */ new Error(`malformed response from ${url}`));
|
|
2811
|
-
if (body["error"] !== void 0) {
|
|
2812
|
-
const msg = isObject$5(body["error"]) ? body["error"]["message"] : void 0;
|
|
2813
|
-
return await attempt(next, new Error(typeof msg === "string" ? msg : method));
|
|
2814
|
-
}
|
|
2815
|
-
return body["result"];
|
|
2816
|
-
} catch (error) {
|
|
2817
|
-
return attempt(next, error);
|
|
2818
|
-
}
|
|
2819
|
-
};
|
|
2820
|
-
return attempt(urls, void 0);
|
|
2821
|
-
} };
|
|
4680
|
+
return solanaCaller(urls, this.gatewayFetch);
|
|
2822
4681
|
}
|
|
2823
4682
|
/** Bounded-concurrency + 429-aware ordered-failover Esplora client. */
|
|
2824
4683
|
esplora(network) {
|
|
2825
4684
|
const urls = this.endpoints(network);
|
|
2826
4685
|
if (urls.length === 0) throw new Error(`no esplora endpoint for ${network}`);
|
|
2827
|
-
return
|
|
2828
|
-
const r = await this.options.fetch(url, init);
|
|
2829
|
-
return {
|
|
2830
|
-
ok: r.ok,
|
|
2831
|
-
status: r.status,
|
|
2832
|
-
headers: { get: (h) => r.headers.get(h) ?? void 0 },
|
|
2833
|
-
json: async () => r.json(),
|
|
2834
|
-
text: async () => r.text()
|
|
2835
|
-
};
|
|
2836
|
-
}, esploraSleep);
|
|
4686
|
+
return esploraClient(urls, this.gatewayFetch);
|
|
2837
4687
|
}
|
|
4688
|
+
/** Validate a custom endpoint's reported chain id, then persist it first in the list. */
|
|
2838
4689
|
async addRpc(network, url) {
|
|
2839
4690
|
const info = this.info(network);
|
|
2840
4691
|
if (info.family === "bitcoin") {
|
|
@@ -2894,8 +4745,7 @@ var NetworkController = class extends Controller {
|
|
|
2894
4745
|
if (store !== void 0) await store.collection("networks").put("customRpc", new TextEncoder().encode(JSON.stringify(customRpc)));
|
|
2895
4746
|
}
|
|
2896
4747
|
};
|
|
2897
|
-
|
|
2898
|
-
//#region src/payments/parse.ts
|
|
4748
|
+
// src/payments/parse.ts
|
|
2899
4749
|
/** Invoices with less than this much life left are treated as already expired. */
|
|
2900
4750
|
const BOLT11_EXPIRY_GRACE_MS = 6e4;
|
|
2901
4751
|
const invalid = (reason) => ({
|
|
@@ -3165,158 +5015,7 @@ function parsePayment(input, options) {
|
|
|
3165
5015
|
return invalid("malformed");
|
|
3166
5016
|
}
|
|
3167
5017
|
}
|
|
3168
|
-
|
|
3169
|
-
//#region src/spark/errors.ts
|
|
3170
|
-
var SparkControllerError = class extends Error {
|
|
3171
|
-
category;
|
|
3172
|
-
constructor(category, message, cause) {
|
|
3173
|
-
super(message, { cause });
|
|
3174
|
-
this.name = "SparkControllerError";
|
|
3175
|
-
this.category = category;
|
|
3176
|
-
}
|
|
3177
|
-
};
|
|
3178
|
-
/** Maps a thrown client/kernel error into a category without leaking payloads. */
|
|
3179
|
-
function categorize(error) {
|
|
3180
|
-
const name = error instanceof Error ? error.name : "";
|
|
3181
|
-
const message = error instanceof Error ? error.message : "";
|
|
3182
|
-
if (/approval|denied|rejected/i.test(message)) return /required/i.test(message) ? "approval-required" : "rejected";
|
|
3183
|
-
if (/insufficient|not enough|balance/i.test(message)) return "insufficient-funds";
|
|
3184
|
-
if (name === "SparkRequestError" || /fetch|network|timeout|unavailable|closed/i.test(message)) return "network";
|
|
3185
|
-
return "internal";
|
|
3186
|
-
}
|
|
3187
|
-
//#endregion
|
|
3188
|
-
//#region src/payments/errors.ts
|
|
3189
|
-
var PaymentError = class extends Error {
|
|
3190
|
-
category;
|
|
3191
|
-
constructor(category, message, cause) {
|
|
3192
|
-
super(message, { cause });
|
|
3193
|
-
this.name = "PaymentError";
|
|
3194
|
-
this.category = category;
|
|
3195
|
-
}
|
|
3196
|
-
};
|
|
3197
|
-
//#endregion
|
|
3198
|
-
//#region src/payments/pay.ts
|
|
3199
|
-
/**
|
|
3200
|
-
* Executes one parsed payment option. Every input validation happens before the approval round — a
|
|
3201
|
-
* `PaymentError` here never consumed a verification. `SparkControllerError` and `WalletError` pass
|
|
3202
|
-
* through untouched.
|
|
3203
|
-
*/
|
|
3204
|
-
async function pay(context, params) {
|
|
3205
|
-
const { spark } = context;
|
|
3206
|
-
const ref = params.account;
|
|
3207
|
-
let result;
|
|
3208
|
-
switch (params.option.kind) {
|
|
3209
|
-
case "spark":
|
|
3210
|
-
if (params.amountSats === void 0) throw new PaymentError("invalid-input", "spark payment: amountSats is required");
|
|
3211
|
-
result = {
|
|
3212
|
-
kind: "spark",
|
|
3213
|
-
transfer: await spark.send(ref, {
|
|
3214
|
-
receiver: params.option.address,
|
|
3215
|
-
amountSats: params.amountSats,
|
|
3216
|
-
verify: params.verify
|
|
3217
|
-
})
|
|
3218
|
-
};
|
|
3219
|
-
break;
|
|
3220
|
-
case "spark-invoice": {
|
|
3221
|
-
if (params.option.expiresAt !== void 0 && params.option.expiresAt <= context.now()) throw new PaymentError("rejected", "spark invoice expired");
|
|
3222
|
-
if (params.option.amountSats !== void 0 && params.amountSats !== void 0 && params.amountSats !== params.option.amountSats) throw new PaymentError("invalid-input", "spark invoice: amountSats conflicts with the invoice amount");
|
|
3223
|
-
const invoiceAmount = params.option.amountSats === void 0 ? params.amountSats : void 0;
|
|
3224
|
-
result = {
|
|
3225
|
-
kind: "spark",
|
|
3226
|
-
transfer: await spark.paySparkInvoice(ref, {
|
|
3227
|
-
invoice: params.option.invoice,
|
|
3228
|
-
...invoiceAmount === void 0 ? {} : { amountSats: invoiceAmount },
|
|
3229
|
-
verify: params.verify
|
|
3230
|
-
})
|
|
3231
|
-
};
|
|
3232
|
-
break;
|
|
3233
|
-
}
|
|
3234
|
-
case "lightning":
|
|
3235
|
-
if (params.option.amountSats === void 0 && params.amountSats === void 0) throw new PaymentError("invalid-input", "amountless invoice: amountSats is required");
|
|
3236
|
-
if (params.maxFeeSats === void 0) throw new PaymentError("invalid-input", "lightning payment: maxFeeSats is required");
|
|
3237
|
-
if (params.option.expiresAt <= context.now() + 6e4) throw new PaymentError("rejected", "lightning invoice expired");
|
|
3238
|
-
if (params.option.amountSats !== void 0 && params.amountSats !== void 0 && params.amountSats !== params.option.amountSats) throw new PaymentError("invalid-input", "amountSats conflicts with the invoice amount");
|
|
3239
|
-
result = {
|
|
3240
|
-
kind: "lightning",
|
|
3241
|
-
payment: await spark.payInvoice(ref, {
|
|
3242
|
-
invoice: params.option.invoice,
|
|
3243
|
-
maxFeeSats: params.maxFeeSats,
|
|
3244
|
-
...params.option.amountSats === void 0 && params.amountSats !== void 0 ? { amountSats: params.amountSats } : {},
|
|
3245
|
-
verify: params.verify
|
|
3246
|
-
})
|
|
3247
|
-
};
|
|
3248
|
-
break;
|
|
3249
|
-
case "lnurl-pay": {
|
|
3250
|
-
const { request } = params.option;
|
|
3251
|
-
if (params.amountSats === void 0) throw new PaymentError("invalid-input", "lnurl-pay: amountSats is required");
|
|
3252
|
-
if (params.maxFeeSats === void 0) throw new PaymentError("invalid-input", "lnurl-pay: maxFeeSats is required");
|
|
3253
|
-
const amountMsat = params.amountSats * 1e3;
|
|
3254
|
-
if (amountMsat < request.minSendableMsat || amountMsat > request.maxSendableMsat) throw new PaymentError("rejected", "lnurl-pay: amount outside the service's range");
|
|
3255
|
-
if (request.commentAllowed === 0 && params.comment !== void 0) throw new PaymentError("rejected", "lnurl-pay: comments not accepted by this service");
|
|
3256
|
-
if (params.comment !== void 0 && params.comment.length > request.commentAllowed) throw new PaymentError("rejected", "lnurl-pay: comment exceeds the service's limit");
|
|
3257
|
-
let requested;
|
|
3258
|
-
try {
|
|
3259
|
-
requested = await requestInvoice(request, {
|
|
3260
|
-
fetch: context.fetch,
|
|
3261
|
-
network: ref.network,
|
|
3262
|
-
amountMsat,
|
|
3263
|
-
...params.comment === void 0 ? {} : { comment: params.comment }
|
|
3264
|
-
});
|
|
3265
|
-
} catch (error) {
|
|
3266
|
-
if (error instanceof PaymentError || error instanceof SparkControllerError) throw error;
|
|
3267
|
-
throw new PaymentError("network", "lnurl-pay: invoice request failed", error);
|
|
3268
|
-
}
|
|
3269
|
-
result = {
|
|
3270
|
-
kind: "lightning",
|
|
3271
|
-
payment: await spark.payInvoice(ref, {
|
|
3272
|
-
invoice: requested.invoice,
|
|
3273
|
-
maxFeeSats: params.maxFeeSats,
|
|
3274
|
-
verify: params.verify
|
|
3275
|
-
})
|
|
3276
|
-
};
|
|
3277
|
-
break;
|
|
3278
|
-
}
|
|
3279
|
-
case "lnurl-withdraw": {
|
|
3280
|
-
const { request } = params.option;
|
|
3281
|
-
if (params.amountSats === void 0) throw new PaymentError("invalid-input", "lnurl-withdraw: amountSats is required");
|
|
3282
|
-
const amountMsat = params.amountSats * 1e3;
|
|
3283
|
-
if (amountMsat < request.minWithdrawableMsat || amountMsat > request.maxWithdrawableMsat) throw new PaymentError("rejected", "lnurl-withdraw: amount outside the service's range");
|
|
3284
|
-
const invoice = await spark.createInvoice(ref, {
|
|
3285
|
-
amountSats: params.amountSats,
|
|
3286
|
-
memo: request.defaultDescription
|
|
3287
|
-
});
|
|
3288
|
-
try {
|
|
3289
|
-
await submitWithdraw(request, {
|
|
3290
|
-
fetch: context.fetch,
|
|
3291
|
-
invoice: invoice.invoice
|
|
3292
|
-
});
|
|
3293
|
-
} catch (error) {
|
|
3294
|
-
if (error instanceof PaymentError || error instanceof SparkControllerError) throw error;
|
|
3295
|
-
throw new PaymentError("network", "lnurl-withdraw: submission failed", error);
|
|
3296
|
-
}
|
|
3297
|
-
result = {
|
|
3298
|
-
kind: "lnurl-withdraw",
|
|
3299
|
-
invoice
|
|
3300
|
-
};
|
|
3301
|
-
break;
|
|
3302
|
-
}
|
|
3303
|
-
case "bitcoin":
|
|
3304
|
-
if (params.amountSats === void 0) throw new PaymentError("invalid-input", "bitcoin payment: amountSats is required");
|
|
3305
|
-
result = {
|
|
3306
|
-
kind: "withdraw",
|
|
3307
|
-
exit: await spark.withdraw(ref, {
|
|
3308
|
-
address: params.option.address,
|
|
3309
|
-
speed: params.speed ?? "MEDIUM",
|
|
3310
|
-
verify: params.verify,
|
|
3311
|
-
amountSats: params.amountSats,
|
|
3312
|
-
...params.maxFeeSats === void 0 ? {} : { maxFeeSats: params.maxFeeSats }
|
|
3313
|
-
})
|
|
3314
|
-
};
|
|
3315
|
-
}
|
|
3316
|
-
return result;
|
|
3317
|
-
}
|
|
3318
|
-
//#endregion
|
|
3319
|
-
//#region src/payments/resolve.ts
|
|
5018
|
+
// src/payments/resolve.ts
|
|
3320
5019
|
/**
|
|
3321
5020
|
* `parsePayment` plus LNURL dereferencing: an `lnurl` result is fetched and turned into a concrete
|
|
3322
5021
|
* `lnurl-pay` / `lnurl-withdraw` request (`tag=login` endpoints report as unsupported). Network
|
|
@@ -3351,93 +5050,7 @@ async function resolvePayment(input, options) {
|
|
|
3351
5050
|
format: "lnurl-auth"
|
|
3352
5051
|
};
|
|
3353
5052
|
}
|
|
3354
|
-
|
|
3355
|
-
//#region src/payments/zap.ts
|
|
3356
|
-
/** Everything up to payment: resolve LNURL, sign the 9734, request the invoice. */
|
|
3357
|
-
async function prepareZap(params, context) {
|
|
3358
|
-
const amountMsat = params.amountSats * 1e3;
|
|
3359
|
-
if (!Number.isSafeInteger(params.amountSats) || params.amountSats <= 0 || !Number.isSafeInteger(amountMsat)) throw new PaymentError("invalid-input", "zap amount is not a positive safe integer");
|
|
3360
|
-
try {
|
|
3361
|
-
const resolved = await resolveLnurl(params.recipient.lightning, { fetch: context.fetch });
|
|
3362
|
-
if (resolved.tag !== "payRequest") throw new PaymentError("rejected", "recipient is not an LNURL-pay endpoint");
|
|
3363
|
-
if (!resolved.allowsNostr || resolved.nostrPubkey === void 0) throw new PaymentError("rejected", "LNURL service does not accept nostr zaps");
|
|
3364
|
-
if (amountMsat < resolved.minSendableMsat || amountMsat > resolved.maxSendableMsat) throw new PaymentError("rejected", "zap amount outside the service's bounds");
|
|
3365
|
-
if (params.comment !== void 0) {
|
|
3366
|
-
if (resolved.commentAllowed === 0) throw new PaymentError("rejected", "the service does not accept comments");
|
|
3367
|
-
if (params.comment.length > resolved.commentAllowed) throw new PaymentError("rejected", "comment exceeds the service's limit");
|
|
3368
|
-
}
|
|
3369
|
-
const lnurl = params.recipient.lightning.toLowerCase().startsWith("lnurl1") ? params.recipient.lightning.toLowerCase() : encodeLnurl(lightningAddressUrl(params.recipient.lightning));
|
|
3370
|
-
const base = {
|
|
3371
|
-
amount: amountMsat,
|
|
3372
|
-
relays: [...params.relays],
|
|
3373
|
-
comment: params.comment,
|
|
3374
|
-
lnurl
|
|
3375
|
-
};
|
|
3376
|
-
const unsigned = {
|
|
3377
|
-
..."event" in params.recipient ? makeZapRequest({
|
|
3378
|
-
event: params.recipient.event,
|
|
3379
|
-
...base
|
|
3380
|
-
}) : makeZapRequest({
|
|
3381
|
-
pubkey: params.recipient.pubkey,
|
|
3382
|
-
...base
|
|
3383
|
-
}),
|
|
3384
|
-
pubkey: await params.signer.getPublicKey()
|
|
3385
|
-
};
|
|
3386
|
-
const zapRequest = await params.signer.signEvent(unsigned);
|
|
3387
|
-
if (!verifyEvent(zapRequest) || !signedMatchesUnsigned(zapRequest, unsigned)) throw new PaymentError("rejected", "signer returned a zap request that does not match");
|
|
3388
|
-
const { invoice } = await requestInvoice(resolved, {
|
|
3389
|
-
fetch: context.fetch,
|
|
3390
|
-
network: params.network,
|
|
3391
|
-
amountMsat,
|
|
3392
|
-
...params.comment === void 0 ? {} : { comment: params.comment },
|
|
3393
|
-
zapRequest: JSON.stringify(zapRequest)
|
|
3394
|
-
});
|
|
3395
|
-
const decoded = decodeBolt11(invoice);
|
|
3396
|
-
const expiresAt = (decoded.timestamp + decoded.expirySeconds) * 1e3;
|
|
3397
|
-
if (expiresAt <= context.now() + 6e4) throw new PaymentError("rejected", "zap invoice expires too soon");
|
|
3398
|
-
const eventId = "event" in params.recipient ? params.recipient.event.id : void 0;
|
|
3399
|
-
return {
|
|
3400
|
-
zapRequest,
|
|
3401
|
-
invoice,
|
|
3402
|
-
paymentHash: bytesToHex(decoded.paymentHash),
|
|
3403
|
-
expiresAt,
|
|
3404
|
-
amountSats: params.amountSats,
|
|
3405
|
-
receiptPubkey: bytesToHex(resolved.nostrPubkey),
|
|
3406
|
-
recipientPubkey: "event" in params.recipient ? params.recipient.event.pubkey : params.recipient.pubkey,
|
|
3407
|
-
...eventId === void 0 ? {} : { eventId },
|
|
3408
|
-
...params.comment === void 0 ? {} : { comment: params.comment },
|
|
3409
|
-
lightning: params.recipient.lightning
|
|
3410
|
-
};
|
|
3411
|
-
} catch (error) {
|
|
3412
|
-
if (error instanceof PaymentError) throw error;
|
|
3413
|
-
if (error instanceof SparkInputError || error instanceof Error && error.cause instanceof SparkInputError) throw new PaymentError("rejected", "zap failed invoice validation");
|
|
3414
|
-
throw new PaymentError(categorizePayment(error), "zap failed");
|
|
3415
|
-
}
|
|
3416
|
-
}
|
|
3417
|
-
/**
|
|
3418
|
-
* Pays a prepared zap through the controller's approve-first path. A nearly-expired invoice is
|
|
3419
|
-
* rejected before approval — the user may have sat on the confirm screen since `prepareZap`.
|
|
3420
|
-
*/
|
|
3421
|
-
async function payZap(spark, account, prepared, options) {
|
|
3422
|
-
if (prepared.expiresAt <= options.now() + 6e4) throw new PaymentError("rejected", "zap invoice expired");
|
|
3423
|
-
return spark.payInvoice(account, {
|
|
3424
|
-
invoice: prepared.invoice,
|
|
3425
|
-
maxFeeSats: options.maxFeeSats,
|
|
3426
|
-
verify: options.verify,
|
|
3427
|
-
note: {
|
|
3428
|
-
kind: "zap",
|
|
3429
|
-
recipientPubkey: prepared.recipientPubkey,
|
|
3430
|
-
...prepared.eventId === void 0 ? {} : { eventId: prepared.eventId },
|
|
3431
|
-
...prepared.comment === void 0 ? {} : { comment: prepared.comment },
|
|
3432
|
-
lightning: prepared.lightning
|
|
3433
|
-
}
|
|
3434
|
-
});
|
|
3435
|
-
}
|
|
3436
|
-
function categorizePayment(error) {
|
|
3437
|
-
return categorize(error) === "network" ? "network" : "rejected";
|
|
3438
|
-
}
|
|
3439
|
-
//#endregion
|
|
3440
|
-
//#region src/spark/notes.ts
|
|
5053
|
+
// src/spark/notes.ts
|
|
3441
5054
|
const HEX64 = /^[0-9a-f]{64}$/;
|
|
3442
5055
|
const isObject$4 = (v) => typeof v === "object" && v !== null && !Array.isArray(v);
|
|
3443
5056
|
const NOTE_FIELDS = /* @__PURE__ */ new Set([
|
|
@@ -3468,8 +5081,7 @@ function parsePaymentNote(value) {
|
|
|
3468
5081
|
function encodePaymentNote(note) {
|
|
3469
5082
|
return new TextEncoder().encode(JSON.stringify(note));
|
|
3470
5083
|
}
|
|
3471
|
-
|
|
3472
|
-
//#region src/spark/exit-chain-codec.ts
|
|
5084
|
+
// src/spark/exit-chain-codec.ts
|
|
3473
5085
|
/**
|
|
3474
5086
|
* SparkExitChain persistence codec: JSON with Uint8Arrays tagged `{ $b64: "…" }` — small and
|
|
3475
5087
|
* explicit rather than schema-bound, so upstream field additions survive a round-trip.
|
|
@@ -3496,8 +5108,7 @@ function encodeExitChain(chain) {
|
|
|
3496
5108
|
function decodeExitChain(bytes) {
|
|
3497
5109
|
return decodeValue(JSON.parse(new TextDecoder().decode(bytes)));
|
|
3498
5110
|
}
|
|
3499
|
-
|
|
3500
|
-
//#region src/spark/store.ts
|
|
5111
|
+
// src/spark/store.ts
|
|
3501
5112
|
const ALL = "__all__";
|
|
3502
5113
|
/**
|
|
3503
5114
|
* SparkStore over one EngineCollection: chains keyed by leafId, plus an index record listing them
|
|
@@ -3541,8 +5152,7 @@ function sparkStore(col) {
|
|
|
3541
5152
|
}
|
|
3542
5153
|
};
|
|
3543
5154
|
}
|
|
3544
|
-
|
|
3545
|
-
//#region src/spark/controller.ts
|
|
5155
|
+
// src/spark/controller.ts
|
|
3546
5156
|
const key = (ref) => `${ref.walletId}:${ref.groupIndex}:${ref.network}`;
|
|
3547
5157
|
/** TransferId: the client and SO expect a UUIDv7 — mint it from the injected clock+random. */
|
|
3548
5158
|
const transferId = (random, now) => uuidv7(random, now);
|
|
@@ -3959,6 +5569,14 @@ var SparkController = class extends Controller {
|
|
|
3959
5569
|
...params.memo === void 0 ? {} : { memo: params.memo }
|
|
3960
5570
|
}));
|
|
3961
5571
|
}
|
|
5572
|
+
/** A fresh single-use L1 deposit address (with the SO's proof of possession). */
|
|
5573
|
+
async newDepositAddress(ref) {
|
|
5574
|
+
return this.guarded(async () => this.clientFor(ref).deposits.newAddress());
|
|
5575
|
+
}
|
|
5576
|
+
/** Claims a confirmed L1 deposit paying one of the account's unused deposit addresses. */
|
|
5577
|
+
async claimDeposit(ref, transaction) {
|
|
5578
|
+
await this.guarded(async () => this.clientFor(ref).deposits.claim({ transaction }));
|
|
5579
|
+
}
|
|
3962
5580
|
async receiveAddress(ref) {
|
|
3963
5581
|
const state = this.getState().accounts[key(ref)];
|
|
3964
5582
|
await Promise.resolve();
|
|
@@ -4006,10 +5624,77 @@ var SparkController = class extends Controller {
|
|
|
4006
5624
|
});
|
|
4007
5625
|
}
|
|
4008
5626
|
};
|
|
4009
|
-
|
|
4010
|
-
|
|
5627
|
+
// src/spark/deposit-claim.ts
|
|
5628
|
+
/**
|
|
5629
|
+
* Claims Spark deposits: once a transaction tagged `sparkDeposit` is confirmed, its raw bytes are
|
|
5630
|
+
* read from Esplora and handed to the Spark account's `deposits.claim`. Claiming too early (the SO
|
|
5631
|
+
* wants enough confirmations) just fails and is retried with backoff; locking stops everything.
|
|
5632
|
+
* Works only from the transaction state, so a restart resumes whatever is confirmed and unclaimed.
|
|
5633
|
+
*/
|
|
5634
|
+
const pending = (tx) => tx.sparkDeposit !== void 0 && tx.state === "confirmed" && tx.sparkDeposit.claimed !== true;
|
|
5635
|
+
var DepositClaimer = class {
|
|
5636
|
+
options;
|
|
5637
|
+
active = /* @__PURE__ */ new Map();
|
|
5638
|
+
unsubscribe;
|
|
5639
|
+
constructor(options) {
|
|
5640
|
+
this.options = options;
|
|
5641
|
+
}
|
|
5642
|
+
/** Starts watching; every confirmed, unclaimed deposit already in the state is picked up. */
|
|
5643
|
+
start() {
|
|
5644
|
+
this.stop();
|
|
5645
|
+
this.unsubscribe = this.options.transactions.store.subscribe(() => {
|
|
5646
|
+
this.scan();
|
|
5647
|
+
});
|
|
5648
|
+
this.scan();
|
|
5649
|
+
}
|
|
5650
|
+
stop() {
|
|
5651
|
+
this.unsubscribe?.();
|
|
5652
|
+
this.unsubscribe = void 0;
|
|
5653
|
+
for (const abort of this.active.values()) abort.abort();
|
|
5654
|
+
this.active.clear();
|
|
5655
|
+
}
|
|
5656
|
+
scan() {
|
|
5657
|
+
for (const tx of Object.values(this.options.transactions.getState().txs)) if (pending(tx) && !this.active.has(tx.id)) {
|
|
5658
|
+
const abort = new AbortController();
|
|
5659
|
+
this.active.set(tx.id, abort);
|
|
5660
|
+
this.claim(tx.id, abort.signal);
|
|
5661
|
+
}
|
|
5662
|
+
}
|
|
5663
|
+
async claim(id, signal) {
|
|
5664
|
+
for (let failures = 0; !signal.aborted; failures += 1) {
|
|
5665
|
+
const tx = this.options.transactions.getState().txs[id];
|
|
5666
|
+
if (tx === void 0 || !pending(tx) || tx.sparkDeposit === void 0) break;
|
|
5667
|
+
const { walletId, groupIndex, network: sparkNetwork } = tx.sparkDeposit;
|
|
5668
|
+
try {
|
|
5669
|
+
if (tx.hash === void 0) throw new Error("the deposit has no transaction id");
|
|
5670
|
+
const raw = await this.options.networks.esplora(tx.network).get(`/tx/${tx.hash}/hex`);
|
|
5671
|
+
if (typeof raw !== "string") throw new TypeError("esplora returned a malformed transaction");
|
|
5672
|
+
await this.options.spark.claimDeposit({
|
|
5673
|
+
walletId,
|
|
5674
|
+
groupIndex,
|
|
5675
|
+
network: sparkNetwork
|
|
5676
|
+
}, hex.decode(raw.trim()));
|
|
5677
|
+
if (signal.aborted) break;
|
|
5678
|
+
await this.options.transactions.markDepositClaimed(id);
|
|
5679
|
+
break;
|
|
5680
|
+
} catch (error) {
|
|
5681
|
+
this.options.logger.warn(`spark deposit ${id} not claimed yet`, error);
|
|
5682
|
+
await eventsBackoff(failures, signal);
|
|
5683
|
+
}
|
|
5684
|
+
}
|
|
5685
|
+
if (this.active.get(id)?.signal === signal) this.active.delete(id);
|
|
5686
|
+
}
|
|
5687
|
+
};
|
|
5688
|
+
// src/transactions/bitcoin.ts
|
|
4011
5689
|
/** Bitcoin route: scan → coin-select → PSBT → sign → broadcast, plus esplora status poll. */
|
|
4012
5690
|
const isObject$3 = (v) => typeof v === "object" && v !== null;
|
|
5691
|
+
const broadcastPsbt = async (es, result) => {
|
|
5692
|
+
if (result.kind !== "bitcoin.psbt" || !(result.signedPsbt instanceof Uint8Array)) throw new Error("unexpected bitcoin sign result");
|
|
5693
|
+
const signed = Transaction.fromPSBT(result.signedPsbt);
|
|
5694
|
+
signed.finalize();
|
|
5695
|
+
const raw = signed.extract();
|
|
5696
|
+
return String(await es.post("/tx", hex.encode(raw)));
|
|
5697
|
+
};
|
|
4013
5698
|
const fetchRawTxes = async (es, utxos) => {
|
|
4014
5699
|
const out = /* @__PURE__ */ new Map();
|
|
4015
5700
|
await Promise.all(utxos.map(async (u) => {
|
|
@@ -4033,15 +5718,49 @@ const poll = async (ctx, tx) => {
|
|
|
4033
5718
|
if (!(isObject$3(status) && status["confirmed"] === true)) return;
|
|
4034
5719
|
return "confirmed";
|
|
4035
5720
|
};
|
|
5721
|
+
const nextReceiveAddress = async (ctx, walletId, groupIndex, network) => {
|
|
5722
|
+
const account = await (ctx.options.chains ?? chains(ctx.options.wallet)).account({
|
|
5723
|
+
walletId,
|
|
5724
|
+
groupIndex,
|
|
5725
|
+
template: "btc.bip84",
|
|
5726
|
+
network
|
|
5727
|
+
});
|
|
5728
|
+
if (!("xpub" in account) || account.xpub === void 0) throw new Error("bitcoin account carries no xpub");
|
|
5729
|
+
const receive = await scanChain(ctx.options.networks.esplora(network), account.xpub, "btc.bip84", network, bitcoinNetwork(network).params, 0);
|
|
5730
|
+
const next = new BitcoinScanStore().nextUnused({
|
|
5731
|
+
addresses: receive,
|
|
5732
|
+
utxos: [],
|
|
5733
|
+
scannedAt: ctx.options.now()
|
|
5734
|
+
}, 0);
|
|
5735
|
+
if (next === void 0) throw new Error("no unused receive address");
|
|
5736
|
+
return next.address;
|
|
5737
|
+
};
|
|
4036
5738
|
/** Per-controller route — the UTXO store and the build stash are engine state. */
|
|
4037
5739
|
const createBitcoinRoute = () => {
|
|
4038
5740
|
const bitcoin = new BitcoinChainAdapter();
|
|
4039
5741
|
const bitcoinUtxos = new BtcUtxoStore();
|
|
4040
5742
|
const pending = /* @__PURE__ */ new Map();
|
|
4041
|
-
|
|
4042
|
-
|
|
5743
|
+
/**
|
|
5744
|
+
* Outputs held by a request that has not been broadcast yet ("txid:vout" → request id). Memory
|
|
5745
|
+
* only: the request queue is not persisted, so a crash must not leave anything locked. The
|
|
5746
|
+
* persistent lock (keyed by tx id) is written only once a broadcast succeeds.
|
|
5747
|
+
*/
|
|
5748
|
+
const reserved = /* @__PURE__ */ new Map();
|
|
5749
|
+
const isHeld = (key) => {
|
|
5750
|
+
const [txid = "", vout = "0"] = key.split(":");
|
|
5751
|
+
return reserved.has(key) || bitcoinUtxos.isLocked({
|
|
5752
|
+
txid,
|
|
5753
|
+
vout: Number(vout)
|
|
5754
|
+
});
|
|
5755
|
+
};
|
|
5756
|
+
const releaseReservation = (requestId) => {
|
|
5757
|
+
for (const [key, holder] of reserved) if (holder === requestId) reserved.delete(key);
|
|
5758
|
+
};
|
|
5759
|
+
const prepare = async (ctx, params, requestId, sparkDeposit) => {
|
|
5760
|
+
const { account, network, to, amount, fee } = params;
|
|
4043
5761
|
if (account.kind !== "hd") throw new Error("imported accounts are not yet supported");
|
|
4044
5762
|
const es = ctx.options.networks.esplora(network);
|
|
5763
|
+
const independent = ctx.options.networks.independentEsplora(network);
|
|
4045
5764
|
const apis = ctx.options.chains ?? chains(ctx.options.wallet);
|
|
4046
5765
|
const template = [
|
|
4047
5766
|
"btc.bip44",
|
|
@@ -4103,9 +5822,25 @@ const createBitcoinRoute = () => {
|
|
|
4103
5822
|
const quotes = await feeTiers(es);
|
|
4104
5823
|
const quote = quotes.find((q) => q.tier === (fee === "fast" ? "fast" : fee === "slow" ? "slow" : "normal")) ?? quotes[1];
|
|
4105
5824
|
if (quote === void 0) throw new Error("no fee quote");
|
|
4106
|
-
const
|
|
5825
|
+
const flags = [independent.kind === "unavailable" ? "reads-unverified" : void 0];
|
|
5826
|
+
let rate = quote.satPerByte;
|
|
5827
|
+
let independentRate;
|
|
5828
|
+
if (independent.kind === "available") try {
|
|
5829
|
+
independentRate = (await feeTiers(independent.client)).find((q) => q.tier === quote.tier)?.satPerByte;
|
|
5830
|
+
if (independentRate === void 0) flags.push("reads-unverified");
|
|
5831
|
+
else {
|
|
5832
|
+
const merged = mergeSatPerByte(quote.satPerByte, independentRate);
|
|
5833
|
+
({rate} = merged);
|
|
5834
|
+
flags.push(merged.mismatch ? "fee-mismatch" : void 0);
|
|
5835
|
+
}
|
|
5836
|
+
} catch {
|
|
5837
|
+
flags.push("reads-unverified");
|
|
5838
|
+
}
|
|
5839
|
+
const free = inputs.filter((i) => !isHeld(`${String(i.txid ?? "")}:${String(i.index ?? 0)}`));
|
|
5840
|
+
const { psbt, inputs: selected } = bitcoin.buildPsbt(free, to, amount, BigInt(rate), changeAddr, net.params);
|
|
4107
5841
|
const selectedKeys = new Set(selected.map((i) => `${String(i.txid ?? "")}:${String(i.index ?? 0)}`));
|
|
4108
5842
|
const selectedUtxos = utxos.filter((u) => selectedKeys.has(`${u.txid}:${u.vout}`));
|
|
5843
|
+
for (const key of selectedKeys) reserved.set(key, requestId);
|
|
4109
5844
|
const txP = Transaction.fromPSBT(psbt);
|
|
4110
5845
|
const ownScripts = new Set(change.filter((a) => !a.used).map((a) => hex.encode(scriptPubKey(a.address, net.params))));
|
|
4111
5846
|
const request = {
|
|
@@ -4130,7 +5865,7 @@ const createBitcoinRoute = () => {
|
|
|
4130
5865
|
const inTotal = selectedUtxos.reduce((a, u) => a + u.value, 0);
|
|
4131
5866
|
const outTotal = outValues.reduce((a, o) => a + o.value, 0);
|
|
4132
5867
|
const id0 = newId(ctx.options.random);
|
|
4133
|
-
|
|
5868
|
+
const stash = {
|
|
4134
5869
|
inputs,
|
|
4135
5870
|
allUtxos: utxos,
|
|
4136
5871
|
pathOf: (u) => {
|
|
@@ -4152,62 +5887,73 @@ const createBitcoinRoute = () => {
|
|
|
4152
5887
|
tier: fee === "fast" ? "fast" : fee === "slow" ? "slow" : "normal",
|
|
4153
5888
|
template,
|
|
4154
5889
|
network
|
|
4155
|
-
}
|
|
4156
|
-
|
|
4157
|
-
|
|
4158
|
-
|
|
5890
|
+
};
|
|
5891
|
+
const record = {
|
|
5892
|
+
inputs: selectedUtxos.map((u) => ({
|
|
5893
|
+
txid: u.txid,
|
|
5894
|
+
vout: u.vout,
|
|
5895
|
+
value: u.value,
|
|
5896
|
+
address: u.address
|
|
5897
|
+
})),
|
|
5898
|
+
outputs: outValues,
|
|
5899
|
+
vsize,
|
|
5900
|
+
fee: inTotal - outTotal,
|
|
5901
|
+
feeRate: Math.ceil((inTotal - outTotal) / vsize)
|
|
5902
|
+
};
|
|
5903
|
+
if (independentRate !== void 0) flags.push(exceedsNetworkCap(BigInt(record.feeRate), BigInt(Math.ceil(independentRate))) ? "fee-above-network" : void 0);
|
|
5904
|
+
flags.push(feeReachesAmount(BigInt(record.fee), amount) ? "fee-exceeds-amount" : void 0);
|
|
5905
|
+
const submit = async (verify) => {
|
|
5906
|
+
pending.set(id0, stash);
|
|
5907
|
+
let tx = {
|
|
5908
|
+
id: id0,
|
|
5909
|
+
family: "bitcoin",
|
|
5910
|
+
network,
|
|
5911
|
+
account: account.walletId,
|
|
5912
|
+
hash: void 0,
|
|
5913
|
+
state: "draft",
|
|
5914
|
+
intent,
|
|
5915
|
+
nonce: 0,
|
|
5916
|
+
btc: record,
|
|
5917
|
+
...sparkDeposit === void 0 ? {} : { sparkDeposit },
|
|
5918
|
+
submittedAt: ctx.options.now(),
|
|
5919
|
+
error: void 0
|
|
5920
|
+
};
|
|
5921
|
+
tx = await ctx.transition(tx, {});
|
|
5922
|
+
try {
|
|
5923
|
+
const result = await apis.sign({
|
|
5924
|
+
request,
|
|
5925
|
+
verify
|
|
5926
|
+
});
|
|
5927
|
+
tx = await ctx.transition(tx, { state: "signed" });
|
|
5928
|
+
const hash = await broadcastPsbt(es, result);
|
|
5929
|
+
bitcoinUtxos.lock(selectedUtxos, tx.id);
|
|
5930
|
+
releaseReservation(requestId);
|
|
5931
|
+
return await submitTx(ctx, tx, hash);
|
|
5932
|
+
} catch (error) {
|
|
5933
|
+
await failTx(ctx, tx, error);
|
|
5934
|
+
throw error;
|
|
5935
|
+
}
|
|
5936
|
+
};
|
|
5937
|
+
return {
|
|
5938
|
+
txId: id0,
|
|
5939
|
+
account,
|
|
4159
5940
|
network,
|
|
4160
|
-
account: account.walletId,
|
|
4161
|
-
hash: void 0,
|
|
4162
|
-
state: "draft",
|
|
4163
5941
|
intent,
|
|
4164
|
-
|
|
4165
|
-
|
|
4166
|
-
|
|
4167
|
-
|
|
4168
|
-
|
|
4169
|
-
|
|
4170
|
-
address: u.address
|
|
4171
|
-
})),
|
|
4172
|
-
outputs: outValues,
|
|
4173
|
-
vsize,
|
|
4174
|
-
fee: inTotal - outTotal,
|
|
4175
|
-
feeRate: Math.ceil((inTotal - outTotal) / vsize)
|
|
4176
|
-
},
|
|
4177
|
-
submittedAt: ctx.options.now(),
|
|
4178
|
-
error: void 0
|
|
5942
|
+
checks: collectChecks(flags),
|
|
5943
|
+
staleReason: () => void 0,
|
|
5944
|
+
submit,
|
|
5945
|
+
release: () => {
|
|
5946
|
+
releaseReservation(requestId);
|
|
5947
|
+
}
|
|
4179
5948
|
};
|
|
4180
|
-
tx = await ctx.transition(tx, {});
|
|
4181
|
-
try {
|
|
4182
|
-
const result = await apis.sign({
|
|
4183
|
-
request,
|
|
4184
|
-
verify
|
|
4185
|
-
});
|
|
4186
|
-
tx = await ctx.transition(tx, { state: "signed" });
|
|
4187
|
-
if (result.kind !== "bitcoin.psbt" || !(result.signedPsbt instanceof Uint8Array)) throw new Error("unexpected bitcoin sign result");
|
|
4188
|
-
bitcoinUtxos.lock(selectedUtxos, tx.id);
|
|
4189
|
-
const signed = Transaction.fromPSBT(result.signedPsbt);
|
|
4190
|
-
signed.finalize();
|
|
4191
|
-
const raw = signed.extract();
|
|
4192
|
-
const hash = String(await es.post("/tx", hex.encode(raw)));
|
|
4193
|
-
tx = await ctx.transition(tx, {
|
|
4194
|
-
state: "submitted",
|
|
4195
|
-
hash,
|
|
4196
|
-
submittedAt: ctx.options.now()
|
|
4197
|
-
});
|
|
4198
|
-
ctx.track(tx.id, hash);
|
|
4199
|
-
return tx;
|
|
4200
|
-
} catch (error) {
|
|
4201
|
-
const failed = tx.state === "signed" ? "failed" : "rejected";
|
|
4202
|
-
await ctx.transition(tx, {
|
|
4203
|
-
state: failed,
|
|
4204
|
-
error: errorMessage(error)
|
|
4205
|
-
});
|
|
4206
|
-
throw error;
|
|
4207
|
-
}
|
|
4208
5949
|
};
|
|
4209
5950
|
/** Esplora `/tx/:txid/status` → terminal state or `undefined` to keep polling. */
|
|
4210
|
-
|
|
5951
|
+
/**
|
|
5952
|
+
* Builds the approval-ready request for a replacement or CPFP child. `held` are the extra outputs
|
|
5953
|
+
* it spends beyond the original's own inputs; they are reserved in memory here and only locked
|
|
5954
|
+
* persistently, under the new transaction id, once the broadcast succeeds.
|
|
5955
|
+
*/
|
|
5956
|
+
const prepareFinish = (ctx, stash, es, account, old, psbt, record, link, requestId, held, flags) => {
|
|
4211
5957
|
const apis = ctx.options.chains ?? chains(ctx.options.wallet);
|
|
4212
5958
|
const txP = Transaction.fromPSBT(psbt);
|
|
4213
5959
|
const request = {
|
|
@@ -4217,61 +5963,66 @@ const createBitcoinRoute = () => {
|
|
|
4217
5963
|
psbt,
|
|
4218
5964
|
sign: Array.from({ length: txP.inputsLength }, (_, i) => i)
|
|
4219
5965
|
};
|
|
4220
|
-
const ownScripts = new Set(stash.changeAddresses.map((a) => hex.encode(scriptPubKey(a, bitcoinNetwork(stash.network).params))));
|
|
4221
|
-
const intent = decodeBitcoinPsbt(request, ownScripts);
|
|
4222
|
-
|
|
4223
|
-
|
|
4224
|
-
|
|
5966
|
+
const ownScripts = new Set(stash.changeAddresses.map((a) => hex.encode(scriptPubKey(a, bitcoinNetwork(stash.network).params))));
|
|
5967
|
+
const intent = decodeBitcoinPsbt(request, ownScripts);
|
|
5968
|
+
const txId = newId(ctx.options.random);
|
|
5969
|
+
for (const key of held) reserved.set(key, requestId);
|
|
5970
|
+
const submit = async (verify) => {
|
|
5971
|
+
let tx = {
|
|
5972
|
+
id: txId,
|
|
5973
|
+
family: "bitcoin",
|
|
5974
|
+
network: old.network,
|
|
5975
|
+
account: old.account,
|
|
5976
|
+
hash: void 0,
|
|
5977
|
+
state: "draft",
|
|
5978
|
+
intent,
|
|
5979
|
+
nonce: 0,
|
|
5980
|
+
btc: record,
|
|
5981
|
+
submittedAt: ctx.options.now(),
|
|
5982
|
+
error: void 0,
|
|
5983
|
+
...link
|
|
5984
|
+
};
|
|
5985
|
+
tx = await ctx.transition(tx, {});
|
|
5986
|
+
try {
|
|
5987
|
+
const result = await apis.sign({
|
|
5988
|
+
request,
|
|
5989
|
+
verify
|
|
5990
|
+
});
|
|
5991
|
+
tx = await ctx.transition(tx, { state: "signed" });
|
|
5992
|
+
const hash = await broadcastPsbt(es, result);
|
|
5993
|
+
if (link.replaces !== void 0) bitcoinUtxos.endTx(old.id, "dropped");
|
|
5994
|
+
bitcoinUtxos.lock(record.inputs.map((i) => ({
|
|
5995
|
+
...i,
|
|
5996
|
+
status: "confirmed",
|
|
5997
|
+
height: 0
|
|
5998
|
+
})), tx.id);
|
|
5999
|
+
releaseReservation(requestId);
|
|
6000
|
+
return await submitTx(ctx, tx, hash);
|
|
6001
|
+
} catch (error) {
|
|
6002
|
+
await failTx(ctx, tx, error);
|
|
6003
|
+
throw error;
|
|
6004
|
+
}
|
|
6005
|
+
};
|
|
6006
|
+
return {
|
|
6007
|
+
txId,
|
|
6008
|
+
account,
|
|
4225
6009
|
network: old.network,
|
|
4226
|
-
|
|
4227
|
-
hash: void 0,
|
|
4228
|
-
state: "draft",
|
|
6010
|
+
target: old.id,
|
|
4229
6011
|
intent,
|
|
4230
|
-
|
|
4231
|
-
|
|
4232
|
-
|
|
4233
|
-
|
|
4234
|
-
|
|
6012
|
+
checks: collectChecks(flags),
|
|
6013
|
+
staleReason: () => link.replaces === void 0 ? void 0 : replacementStaleReason(ctx, old.id),
|
|
6014
|
+
submit,
|
|
6015
|
+
release: () => {
|
|
6016
|
+
releaseReservation(requestId);
|
|
6017
|
+
}
|
|
4235
6018
|
};
|
|
4236
|
-
tx = await ctx.transition(tx, {});
|
|
4237
|
-
try {
|
|
4238
|
-
const result = await apis.sign({
|
|
4239
|
-
request,
|
|
4240
|
-
verify
|
|
4241
|
-
});
|
|
4242
|
-
tx = await ctx.transition(tx, { state: "signed" });
|
|
4243
|
-
if (result.kind !== "bitcoin.psbt" || !(result.signedPsbt instanceof Uint8Array)) throw new Error("unexpected bitcoin sign result");
|
|
4244
|
-
bitcoinUtxos.endTx(old.id, "dropped");
|
|
4245
|
-
const signed = Transaction.fromPSBT(result.signedPsbt);
|
|
4246
|
-
signed.finalize();
|
|
4247
|
-
const raw = signed.extract();
|
|
4248
|
-
const hash = String(await es.post("/tx", hex.encode(raw)));
|
|
4249
|
-
bitcoinUtxos.lock(record.inputs.map((i) => ({
|
|
4250
|
-
...i,
|
|
4251
|
-
status: "confirmed",
|
|
4252
|
-
height: 0
|
|
4253
|
-
})), tx.id);
|
|
4254
|
-
tx = await ctx.transition(tx, {
|
|
4255
|
-
state: "submitted",
|
|
4256
|
-
hash,
|
|
4257
|
-
submittedAt: ctx.options.now()
|
|
4258
|
-
});
|
|
4259
|
-
ctx.track(tx.id, hash);
|
|
4260
|
-
return tx;
|
|
4261
|
-
} catch (error) {
|
|
4262
|
-
const failed = tx.state === "signed" ? "failed" : "rejected";
|
|
4263
|
-
await ctx.transition(tx, {
|
|
4264
|
-
state: failed,
|
|
4265
|
-
error: errorMessage(error)
|
|
4266
|
-
});
|
|
4267
|
-
throw error;
|
|
4268
|
-
}
|
|
4269
6019
|
};
|
|
4270
|
-
const
|
|
6020
|
+
const prepareReplace = async (ctx, old, mode, requestId) => {
|
|
4271
6021
|
const stash = pending.get(old.id);
|
|
4272
6022
|
const { btc } = old;
|
|
4273
6023
|
if (stash === void 0 || btc === void 0) throw new Error("the original build context is gone — cannot replace after restart");
|
|
4274
6024
|
const es = ctx.options.networks.esplora(old.network);
|
|
6025
|
+
const independent = ctx.options.networks.independentEsplora(old.network);
|
|
4275
6026
|
const apis = ctx.options.chains ?? chains(ctx.options.wallet);
|
|
4276
6027
|
const { template } = stash;
|
|
4277
6028
|
if (!("xpub" in await apis.account({
|
|
@@ -4288,7 +6039,20 @@ const createBitcoinRoute = () => {
|
|
|
4288
6039
|
};
|
|
4289
6040
|
const tiers = await feeTiers(es);
|
|
4290
6041
|
const tierQuote = tiers.find((t) => t.tier === stash.tier) ?? tiers[1];
|
|
4291
|
-
|
|
6042
|
+
let rate = tierQuote === void 0 ? 1 : tierQuote.satPerByte;
|
|
6043
|
+
const flags = [independent.kind === "unavailable" ? "reads-unverified" : void 0];
|
|
6044
|
+
let independentRate;
|
|
6045
|
+
if (independent.kind === "available") try {
|
|
6046
|
+
independentRate = (await feeTiers(independent.client)).find((q) => q.tier === tierQuote?.tier)?.satPerByte;
|
|
6047
|
+
if (independentRate === void 0) flags.push("reads-unverified");
|
|
6048
|
+
else {
|
|
6049
|
+
const merged = mergeSatPerByte(rate, independentRate);
|
|
6050
|
+
({rate} = merged);
|
|
6051
|
+
flags.push(merged.mismatch ? "fee-mismatch" : void 0);
|
|
6052
|
+
}
|
|
6053
|
+
} catch {
|
|
6054
|
+
flags.push("reads-unverified");
|
|
6055
|
+
}
|
|
4292
6056
|
const newFee = Math.max(btc.fee + btc.vsize, Math.ceil(btc.vsize * rate));
|
|
4293
6057
|
const [changeAddr] = stash.changeAddresses;
|
|
4294
6058
|
if (changeAddr === void 0) throw new Error("no unused change address");
|
|
@@ -4300,7 +6064,7 @@ const createBitcoinRoute = () => {
|
|
|
4300
6064
|
amount: BigInt(o.value)
|
|
4301
6065
|
}));
|
|
4302
6066
|
const usedKeys = new Set(btc.inputs.map((i) => `${i.txid}:${i.vout}`));
|
|
4303
|
-
const spare = stash.allUtxos.filter((u) => !usedKeys.has(`${u.txid}:${u.vout}`));
|
|
6067
|
+
const spare = stash.allUtxos.filter((u) => !usedKeys.has(`${u.txid}:${u.vout}`) && !isHeld(`${u.txid}:${u.vout}`));
|
|
4304
6068
|
const extraInputs = [];
|
|
4305
6069
|
const extraUtxos = [];
|
|
4306
6070
|
const feeNeeded = (o) => BigInt(newFee) - (BigInt(inTotal) + extraUtxos.reduce((a, u) => a + BigInt(u.value), 0n) - o.reduce((a, x) => a + x.amount, 0n));
|
|
@@ -4371,13 +6135,15 @@ const createBitcoinRoute = () => {
|
|
|
4371
6135
|
fee: newFee,
|
|
4372
6136
|
feeRate: Math.ceil(newFee / vsize)
|
|
4373
6137
|
};
|
|
4374
|
-
|
|
6138
|
+
if (independentRate !== void 0) flags.push(exceedsNetworkCap(BigInt(record.feeRate), BigInt(Math.ceil(independentRate))) ? "fee-above-network" : void 0);
|
|
6139
|
+
return prepareFinish(ctx, stash, es, account, old, psbt, record, { replaces: old.id }, requestId, extraUtxos.map((u) => `${u.txid}:${u.vout}`), flags);
|
|
4375
6140
|
};
|
|
4376
|
-
const
|
|
6141
|
+
const prepareCpfp = async (ctx, old, targetRate, requestId) => {
|
|
4377
6142
|
const stash = pending.get(old.id);
|
|
4378
6143
|
const { btc } = old;
|
|
4379
6144
|
if (stash === void 0 || btc === void 0) throw new Error("the parent build context is gone — cannot bump after restart");
|
|
4380
6145
|
const es = ctx.options.networks.esplora(old.network);
|
|
6146
|
+
const independent = ctx.options.networks.independentEsplora(old.network);
|
|
4381
6147
|
const { template } = stash;
|
|
4382
6148
|
const account = {
|
|
4383
6149
|
kind: "hd",
|
|
@@ -4390,6 +6156,15 @@ const createBitcoinRoute = () => {
|
|
|
4390
6156
|
vout
|
|
4391
6157
|
}] : []);
|
|
4392
6158
|
if (own.length === 0) throw new Error("parent has no own outputs to bump");
|
|
6159
|
+
const parentKeys = own.map((o) => `${old.hash ?? ""}:${o.vout}`);
|
|
6160
|
+
if (parentKeys.some((key) => isHeld(key))) throw new Error("the parent's outputs are already reserved by another request");
|
|
6161
|
+
const flags = [independent.kind === "unavailable" ? "reads-unverified" : void 0];
|
|
6162
|
+
if (independent.kind === "available") try {
|
|
6163
|
+
const fastest = (await feeTiers(independent.client)).find((q) => q.tier === "fast");
|
|
6164
|
+
flags.push(fastest === void 0 ? "reads-unverified" : exceedsNetworkCap(BigInt(Math.ceil(targetRate)), BigInt(Math.ceil(fastest.satPerByte))) ? "fee-above-network" : void 0);
|
|
6165
|
+
} catch {
|
|
6166
|
+
flags.push("reads-unverified");
|
|
6167
|
+
}
|
|
4393
6168
|
const childInputs = bitcoin.toInputs(own.map((o) => ({
|
|
4394
6169
|
txid: old.hash ?? "",
|
|
4395
6170
|
vout: o.vout,
|
|
@@ -4412,9 +6187,9 @@ const createBitcoinRoute = () => {
|
|
|
4412
6187
|
amount: BigInt(inTotal - childFee)
|
|
4413
6188
|
}], net.params);
|
|
4414
6189
|
const record = {
|
|
4415
|
-
inputs: own.map((o
|
|
6190
|
+
inputs: own.map((o) => ({
|
|
4416
6191
|
txid: old.hash ?? "",
|
|
4417
|
-
vout:
|
|
6192
|
+
vout: o.vout,
|
|
4418
6193
|
value: o.value,
|
|
4419
6194
|
address: o.address
|
|
4420
6195
|
})),
|
|
@@ -4427,30 +6202,75 @@ const createBitcoinRoute = () => {
|
|
|
4427
6202
|
fee: childFee,
|
|
4428
6203
|
feeRate: Math.ceil(childFee / vsize)
|
|
4429
6204
|
};
|
|
4430
|
-
return
|
|
6205
|
+
return prepareFinish(ctx, stash, es, account, old, psbt, record, { parentId: old.id }, requestId, parentKeys, flags);
|
|
6206
|
+
};
|
|
6207
|
+
const pollAndRelease = async (ctx, tx) => {
|
|
6208
|
+
const state = await poll(ctx, tx);
|
|
6209
|
+
if (state === "confirmed") bitcoinUtxos.endTx(tx.id, "confirmed");
|
|
6210
|
+
return state;
|
|
4431
6211
|
};
|
|
4432
6212
|
return {
|
|
4433
|
-
|
|
4434
|
-
|
|
4435
|
-
|
|
4436
|
-
|
|
6213
|
+
prepare,
|
|
6214
|
+
nextReceiveAddress,
|
|
6215
|
+
poll: pollAndRelease,
|
|
6216
|
+
prepareReplace,
|
|
6217
|
+
prepareCpfp,
|
|
6218
|
+
unlock: async (store) => bitcoinUtxos.unlock(store),
|
|
6219
|
+
lock: () => {
|
|
6220
|
+
reserved.clear();
|
|
6221
|
+
bitcoinUtxos.lockStore();
|
|
6222
|
+
}
|
|
4437
6223
|
};
|
|
4438
6224
|
};
|
|
4439
|
-
|
|
4440
|
-
//#region src/transactions/evm.ts
|
|
6225
|
+
// src/transactions/evm.ts
|
|
4441
6226
|
/** EVM route: native/ERC-20 send build → fee → decode → sign → broadcast, plus status poll. */
|
|
4442
6227
|
const bump = (v) => (v * 11n + 9n) / 10n;
|
|
6228
|
+
const signSubmit = async (ctx, tx, apis, request, verify, broadcast) => {
|
|
6229
|
+
let signed;
|
|
6230
|
+
let tracked = tx;
|
|
6231
|
+
try {
|
|
6232
|
+
const result = await apis.sign({
|
|
6233
|
+
request,
|
|
6234
|
+
verify
|
|
6235
|
+
});
|
|
6236
|
+
tracked = await ctx.transition(tracked, { state: "signed" });
|
|
6237
|
+
signed = result;
|
|
6238
|
+
} catch (error) {
|
|
6239
|
+
await ctx.transition(tracked, {
|
|
6240
|
+
state: "rejected",
|
|
6241
|
+
error: errorMessage$1(error)
|
|
6242
|
+
});
|
|
6243
|
+
throw error;
|
|
6244
|
+
}
|
|
6245
|
+
try {
|
|
6246
|
+
const hash = await broadcast(signed);
|
|
6247
|
+
tracked = await ctx.transition(tracked, {
|
|
6248
|
+
state: "submitted",
|
|
6249
|
+
hash,
|
|
6250
|
+
submittedAt: ctx.options.now()
|
|
6251
|
+
});
|
|
6252
|
+
ctx.track(tracked.id, hash);
|
|
6253
|
+
return tracked;
|
|
6254
|
+
} catch (error) {
|
|
6255
|
+
await ctx.transition(tracked, {
|
|
6256
|
+
state: "failed",
|
|
6257
|
+
error: errorMessage$1(error)
|
|
6258
|
+
});
|
|
6259
|
+
throw error;
|
|
6260
|
+
}
|
|
6261
|
+
};
|
|
4443
6262
|
/** Per-controller route — the adapter's nonce queue and the replace stash are engine state. */
|
|
4444
6263
|
const createEvmRoute = () => {
|
|
4445
6264
|
const evm = new EvmChainAdapter();
|
|
4446
6265
|
/** In-memory build context keyed by tx id — the inputs a replace rebuilds from. */
|
|
4447
6266
|
const pending = /* @__PURE__ */ new Map();
|
|
4448
|
-
const
|
|
4449
|
-
const { account, network,
|
|
6267
|
+
const prepareSpec = async (ctx, spec) => {
|
|
6268
|
+
const { account, network, fee } = spec;
|
|
4450
6269
|
const { networks } = ctx.options;
|
|
4451
6270
|
if (account.kind !== "hd") throw new Error("imported accounts are not yet supported");
|
|
4452
6271
|
const info = networks.info(network);
|
|
4453
6272
|
const client = networks.client(network);
|
|
6273
|
+
const independent = networks.independentClient(network);
|
|
4454
6274
|
const apis = ctx.options.chains ?? chains(ctx.options.wallet);
|
|
4455
6275
|
const evmAccount = await apis.account({
|
|
4456
6276
|
walletId: account.walletId,
|
|
@@ -4459,90 +6279,144 @@ const createEvmRoute = () => {
|
|
|
4459
6279
|
network
|
|
4460
6280
|
});
|
|
4461
6281
|
const from = "address" in evmAccount ? evmAccount.address : "";
|
|
4462
|
-
const
|
|
4463
|
-
|
|
4464
|
-
|
|
4465
|
-
|
|
4466
|
-
|
|
4467
|
-
|
|
4468
|
-
|
|
4469
|
-
|
|
4470
|
-
|
|
4471
|
-
|
|
6282
|
+
const flags = [independent.kind === "unavailable" ? "reads-unverified" : void 0];
|
|
6283
|
+
let nonce = await evm.nextNonce(client, network, from);
|
|
6284
|
+
if (independent.kind === "available") try {
|
|
6285
|
+
const [primary, other] = await Promise.all([evm.pendingCount(client, from), evm.pendingCount(independent.client, from)]);
|
|
6286
|
+
nonce = Math.max(nonce, other);
|
|
6287
|
+
flags.push(primary === other ? void 0 : "nonce-mismatch");
|
|
6288
|
+
} catch {
|
|
6289
|
+
flags.push("reads-unverified");
|
|
6290
|
+
}
|
|
6291
|
+
const unsigned = await evm.build(client, network, from, spec.intent, nonce);
|
|
4472
6292
|
const quotes = await evm.estimateFee(client);
|
|
4473
|
-
|
|
6293
|
+
let quote = quotes.find((q) => q.tier === fee) ?? quotes[1];
|
|
4474
6294
|
if (quote === void 0) throw new Error("no fee quote");
|
|
6295
|
+
if (independent.kind === "available") try {
|
|
6296
|
+
const other = (await evm.estimateFee(independent.client)).find((q) => q.tier === quote?.tier);
|
|
6297
|
+
if (other === void 0) flags.push("reads-unverified");
|
|
6298
|
+
else {
|
|
6299
|
+
const merged = mergeEvmQuote(quote, other);
|
|
6300
|
+
({quote} = merged);
|
|
6301
|
+
flags.push(merged.mismatch ? "fee-mismatch" : void 0, exceedsNetworkCap(quote.maxFeePerGas, other.maxFeePerGas) ? "fee-above-network" : void 0);
|
|
6302
|
+
}
|
|
6303
|
+
} catch {
|
|
6304
|
+
flags.push("reads-unverified");
|
|
6305
|
+
}
|
|
4475
6306
|
const feed = evm.applyFee(unsigned, quote);
|
|
6307
|
+
if (spec.nativeValue !== void 0 && feeReachesAmount(feed.gas * (feed.maxFeePerGas ?? feed.gasPrice ?? 0n), spec.nativeValue)) flags.push("fee-exceeds-amount");
|
|
4476
6308
|
const request = evm.toSignRequest(feed, account, info.chainId);
|
|
4477
6309
|
const intent = decodeEvm(request);
|
|
4478
|
-
const
|
|
4479
|
-
|
|
4480
|
-
|
|
4481
|
-
|
|
4482
|
-
|
|
4483
|
-
chainId: info.chainId
|
|
4484
|
-
});
|
|
4485
|
-
let tx = {
|
|
4486
|
-
id,
|
|
4487
|
-
family: "evm",
|
|
4488
|
-
network,
|
|
4489
|
-
account: account.walletId,
|
|
4490
|
-
hash: void 0,
|
|
4491
|
-
state: "draft",
|
|
4492
|
-
intent,
|
|
4493
|
-
nonce: unsigned.nonce,
|
|
4494
|
-
submittedAt: ctx.options.now(),
|
|
4495
|
-
error: void 0
|
|
4496
|
-
};
|
|
4497
|
-
tx = await ctx.transition(tx, {});
|
|
4498
|
-
let signed;
|
|
4499
|
-
try {
|
|
4500
|
-
const result = await apis.sign({
|
|
6310
|
+
const txId = newId(ctx.options.random);
|
|
6311
|
+
const chosen = quote;
|
|
6312
|
+
const submit = async (verify) => {
|
|
6313
|
+
pending.set(txId, {
|
|
6314
|
+
unsigned: feed,
|
|
4501
6315
|
request,
|
|
4502
|
-
|
|
4503
|
-
|
|
4504
|
-
tx = await ctx.transition(tx, { state: "signed" });
|
|
4505
|
-
signed = result;
|
|
4506
|
-
} catch (error) {
|
|
4507
|
-
await ctx.transition(tx, {
|
|
4508
|
-
state: "rejected",
|
|
4509
|
-
error: errorMessage(error)
|
|
4510
|
-
});
|
|
4511
|
-
throw error;
|
|
4512
|
-
}
|
|
4513
|
-
try {
|
|
4514
|
-
const raw = evm.finalize(feed, signed);
|
|
4515
|
-
const hash = await evm.broadcast(client, raw);
|
|
4516
|
-
tx = await ctx.transition(tx, {
|
|
4517
|
-
state: "submitted",
|
|
4518
|
-
hash,
|
|
4519
|
-
submittedAt: ctx.options.now()
|
|
6316
|
+
quote: chosen,
|
|
6317
|
+
chainId: info.chainId
|
|
4520
6318
|
});
|
|
4521
|
-
|
|
4522
|
-
|
|
4523
|
-
|
|
4524
|
-
|
|
4525
|
-
|
|
4526
|
-
|
|
6319
|
+
let tx = {
|
|
6320
|
+
id: txId,
|
|
6321
|
+
family: "evm",
|
|
6322
|
+
network,
|
|
6323
|
+
account: account.walletId,
|
|
6324
|
+
hash: void 0,
|
|
6325
|
+
state: "draft",
|
|
6326
|
+
intent,
|
|
6327
|
+
nonce: unsigned.nonce,
|
|
6328
|
+
submittedAt: ctx.options.now(),
|
|
6329
|
+
error: void 0
|
|
6330
|
+
};
|
|
6331
|
+
tx = await ctx.transition(tx, {});
|
|
6332
|
+
return signSubmit(ctx, tx, apis, request, verify, async (signed) => {
|
|
6333
|
+
const raw = evm.finalize(feed, signed);
|
|
6334
|
+
const hash = await evm.broadcast(client, raw);
|
|
6335
|
+
evm.commitNonce(network, from, unsigned.nonce);
|
|
6336
|
+
return hash;
|
|
4527
6337
|
});
|
|
4528
|
-
|
|
4529
|
-
|
|
6338
|
+
};
|
|
6339
|
+
return {
|
|
6340
|
+
txId,
|
|
6341
|
+
account,
|
|
6342
|
+
network,
|
|
6343
|
+
intent,
|
|
6344
|
+
checks: collectChecks(flags),
|
|
6345
|
+
staleReason: () => evm.nonceConsumed(network, from, unsigned.nonce) ? "nonce already used by a newer transaction — start over" : void 0,
|
|
6346
|
+
submit,
|
|
6347
|
+
release: () => void 0
|
|
6348
|
+
};
|
|
6349
|
+
};
|
|
6350
|
+
const prepare = async (ctx, params) => prepareSpec(ctx, {
|
|
6351
|
+
account: params.account,
|
|
6352
|
+
network: params.network,
|
|
6353
|
+
fee: params.fee,
|
|
6354
|
+
intent: params.token === void 0 ? {
|
|
6355
|
+
kind: "native",
|
|
6356
|
+
to: params.to,
|
|
6357
|
+
value: params.amount
|
|
6358
|
+
} : {
|
|
6359
|
+
kind: "erc20",
|
|
6360
|
+
token: params.token,
|
|
6361
|
+
to: params.to,
|
|
6362
|
+
value: params.amount
|
|
6363
|
+
},
|
|
6364
|
+
nativeValue: params.token === void 0 ? params.amount : void 0
|
|
6365
|
+
});
|
|
6366
|
+
/**
|
|
6367
|
+
* A dApp transaction, already normalized: only `to`, `value` and `data` are kept; nonce and fees
|
|
6368
|
+
* are read again and the dApp's `gas` only raises the floor under our own estimate.
|
|
6369
|
+
*/
|
|
6370
|
+
const prepareCall = async (ctx, request) => {
|
|
6371
|
+
const { tx } = request;
|
|
6372
|
+
if (tx.to === void 0) throw new Error("contract creation is not supported");
|
|
6373
|
+
const value = BigInt(tx.value);
|
|
6374
|
+
return prepareSpec(ctx, {
|
|
6375
|
+
account: request.account,
|
|
6376
|
+
network: request.network,
|
|
6377
|
+
fee: "normal",
|
|
6378
|
+
intent: {
|
|
6379
|
+
kind: "call",
|
|
6380
|
+
to: tx.to,
|
|
6381
|
+
value,
|
|
6382
|
+
data: tx.data,
|
|
6383
|
+
minGas: BigInt(tx.gas)
|
|
6384
|
+
},
|
|
6385
|
+
nativeValue: value > 0n ? value : void 0
|
|
6386
|
+
});
|
|
4530
6387
|
};
|
|
4531
6388
|
/**
|
|
4532
6389
|
* EVM speed-up/cancel — replaces a pending tx at the same nonce with a bumped fee (cancel = a
|
|
4533
|
-
* 0-value transfer to self). The
|
|
4534
|
-
*
|
|
6390
|
+
* 0-value transfer to self). The fee is cross-checked and merged first, then the replacement
|
|
6391
|
+
* floor (+10% on both fee fields, never below the merged estimate) is applied on top, so a low
|
|
6392
|
+
* quote can never push the replacement under the node's replacement rule. A rejected signature
|
|
6393
|
+
* never reaches the network and leaves the original.
|
|
4535
6394
|
*/
|
|
4536
|
-
const
|
|
6395
|
+
const prepareReplace = async (ctx, old, mode) => {
|
|
4537
6396
|
const stash = pending.get(old.id);
|
|
4538
6397
|
if (stash === void 0) throw new Error("the original build context is gone — cannot replace after restart");
|
|
4539
6398
|
const { networks } = ctx.options;
|
|
4540
6399
|
const client = networks.client(old.network);
|
|
6400
|
+
const independent = networks.independentClient(old.network);
|
|
4541
6401
|
const apis = ctx.options.chains ?? chains(ctx.options.wallet);
|
|
4542
6402
|
const { tier } = stash.quote;
|
|
4543
6403
|
const estimates = await evm.estimateFee(client);
|
|
4544
|
-
|
|
6404
|
+
let estimate = estimates.find((q) => q.tier === tier) ?? estimates[1];
|
|
4545
6405
|
if (estimate === void 0) throw new Error("no fee quote");
|
|
6406
|
+
const flags = [independent.kind === "unavailable" ? "reads-unverified" : void 0];
|
|
6407
|
+
let independentCap;
|
|
6408
|
+
if (independent.kind === "available") try {
|
|
6409
|
+
const other = (await evm.estimateFee(independent.client)).find((q) => q.tier === estimate?.tier);
|
|
6410
|
+
if (other === void 0) flags.push("reads-unverified");
|
|
6411
|
+
else {
|
|
6412
|
+
const merged = mergeEvmQuote(estimate, other);
|
|
6413
|
+
({quote: estimate} = merged);
|
|
6414
|
+
independentCap = other.maxFeePerGas;
|
|
6415
|
+
flags.push(merged.mismatch ? "fee-mismatch" : void 0);
|
|
6416
|
+
}
|
|
6417
|
+
} catch {
|
|
6418
|
+
flags.push("reads-unverified");
|
|
6419
|
+
}
|
|
4546
6420
|
const oldFee = stash.unsigned;
|
|
4547
6421
|
const bumpedFee = bump(oldFee.maxFeePerGas ?? oldFee.gasPrice ?? 0n);
|
|
4548
6422
|
const maxFeePerGas = bumpedFee > estimate.maxFeePerGas ? bumpedFee : estimate.maxFeePerGas;
|
|
@@ -4551,6 +6425,7 @@ const createEvmRoute = () => {
|
|
|
4551
6425
|
maxFeePerGas,
|
|
4552
6426
|
maxPriorityFeePerGas: priorityBump
|
|
4553
6427
|
};
|
|
6428
|
+
if (independentCap !== void 0) flags.push(exceedsNetworkCap(maxFeePerGas, independentCap) ? "fee-above-network" : void 0);
|
|
4554
6429
|
const unsigned = mode === "cancel" ? {
|
|
4555
6430
|
...stash.unsigned,
|
|
4556
6431
|
to: stash.unsigned.from,
|
|
@@ -4564,59 +6439,41 @@ const createEvmRoute = () => {
|
|
|
4564
6439
|
};
|
|
4565
6440
|
const request = evm.toSignRequest(unsigned, stash.request.account, stash.chainId);
|
|
4566
6441
|
const intent = decodeEvm(request);
|
|
4567
|
-
const
|
|
4568
|
-
|
|
4569
|
-
|
|
4570
|
-
|
|
4571
|
-
|
|
4572
|
-
|
|
4573
|
-
|
|
4574
|
-
|
|
4575
|
-
|
|
4576
|
-
|
|
6442
|
+
const txId = newId(ctx.options.random);
|
|
6443
|
+
const submit = async (verify) => {
|
|
6444
|
+
pending.set(txId, {
|
|
6445
|
+
unsigned,
|
|
6446
|
+
request,
|
|
6447
|
+
quote: stash.quote,
|
|
6448
|
+
chainId: stash.chainId
|
|
6449
|
+
});
|
|
6450
|
+
let tx = {
|
|
6451
|
+
id: txId,
|
|
6452
|
+
family: "evm",
|
|
6453
|
+
network: old.network,
|
|
6454
|
+
account: old.account,
|
|
6455
|
+
hash: void 0,
|
|
6456
|
+
state: "draft",
|
|
6457
|
+
intent,
|
|
6458
|
+
nonce: old.nonce,
|
|
6459
|
+
replaces: old.id,
|
|
6460
|
+
submittedAt: ctx.options.now(),
|
|
6461
|
+
error: void 0
|
|
6462
|
+
};
|
|
6463
|
+
tx = await ctx.transition(tx, {});
|
|
6464
|
+
return signSubmit(ctx, tx, apis, request, verify, async (signed) => evm.broadcast(client, evm.finalize(unsigned, signed)));
|
|
6465
|
+
};
|
|
6466
|
+
return {
|
|
6467
|
+
txId,
|
|
6468
|
+
account: stash.request.account,
|
|
4577
6469
|
network: old.network,
|
|
4578
|
-
|
|
4579
|
-
hash: void 0,
|
|
4580
|
-
state: "draft",
|
|
6470
|
+
target: old.id,
|
|
4581
6471
|
intent,
|
|
4582
|
-
|
|
4583
|
-
|
|
4584
|
-
|
|
4585
|
-
|
|
6472
|
+
checks: collectChecks(flags),
|
|
6473
|
+
staleReason: () => replacementStaleReason(ctx, old.id),
|
|
6474
|
+
submit,
|
|
6475
|
+
release: () => void 0
|
|
4586
6476
|
};
|
|
4587
|
-
tx = await ctx.transition(tx, {});
|
|
4588
|
-
let signed;
|
|
4589
|
-
try {
|
|
4590
|
-
const result = await apis.sign({
|
|
4591
|
-
request,
|
|
4592
|
-
verify
|
|
4593
|
-
});
|
|
4594
|
-
tx = await ctx.transition(tx, { state: "signed" });
|
|
4595
|
-
signed = result;
|
|
4596
|
-
} catch (error) {
|
|
4597
|
-
await ctx.transition(tx, {
|
|
4598
|
-
state: "rejected",
|
|
4599
|
-
error: errorMessage(error)
|
|
4600
|
-
});
|
|
4601
|
-
throw error;
|
|
4602
|
-
}
|
|
4603
|
-
try {
|
|
4604
|
-
const raw = evm.finalize(unsigned, signed);
|
|
4605
|
-
const hash = await evm.broadcast(client, raw);
|
|
4606
|
-
tx = await ctx.transition(tx, {
|
|
4607
|
-
state: "submitted",
|
|
4608
|
-
hash,
|
|
4609
|
-
submittedAt: ctx.options.now()
|
|
4610
|
-
});
|
|
4611
|
-
ctx.track(tx.id, hash);
|
|
4612
|
-
return tx;
|
|
4613
|
-
} catch (error) {
|
|
4614
|
-
await ctx.transition(tx, {
|
|
4615
|
-
state: "failed",
|
|
4616
|
-
error: errorMessage(error)
|
|
4617
|
-
});
|
|
4618
|
-
throw error;
|
|
4619
|
-
}
|
|
4620
6477
|
};
|
|
4621
6478
|
/** Returns the terminal state once the receipt lands, or `undefined` to keep polling. */
|
|
4622
6479
|
const poll = async (ctx, tx) => {
|
|
@@ -4627,9 +6484,10 @@ const createEvmRoute = () => {
|
|
|
4627
6484
|
return state === "finalized" ? "confirmed" : state;
|
|
4628
6485
|
};
|
|
4629
6486
|
return {
|
|
4630
|
-
|
|
6487
|
+
prepare,
|
|
6488
|
+
prepareCall,
|
|
4631
6489
|
poll,
|
|
4632
|
-
|
|
6490
|
+
prepareReplace
|
|
4633
6491
|
};
|
|
4634
6492
|
};
|
|
4635
6493
|
PROGRAM_IDS.token2022;
|
|
@@ -4816,19 +6674,21 @@ var SolanaChainAdapter = class {
|
|
|
4816
6674
|
return BigInt(str(result, "0"));
|
|
4817
6675
|
}
|
|
4818
6676
|
};
|
|
4819
|
-
|
|
4820
|
-
//#region src/transactions/solana.ts
|
|
6677
|
+
// src/transactions/solana.ts
|
|
4821
6678
|
/** Solana route: SOL/SPL send → sign → broadcast, plus status poll with rebroadcast/drop. */
|
|
6679
|
+
/** The network's base signature fee, lamports per signature. */
|
|
6680
|
+
const BASE_FEE_LAMPORTS = 5000n;
|
|
4822
6681
|
const isObject$1 = (v) => typeof v === "object" && v !== null;
|
|
4823
6682
|
const obj = (v) => isObject$1(v) ? v : void 0;
|
|
4824
6683
|
const arr = (v) => Array.isArray(v) ? v : void 0;
|
|
4825
6684
|
const solana = new SolanaChainAdapter();
|
|
4826
6685
|
/** Signed wire transactions kept for re-broadcast until the tx confirms or drops. */
|
|
4827
6686
|
const wires = /* @__PURE__ */ new Map();
|
|
4828
|
-
const
|
|
4829
|
-
const { account, network, to, amount, fee
|
|
6687
|
+
const prepareSolana = async (ctx, params) => {
|
|
6688
|
+
const { account, network, to, amount, fee } = params;
|
|
4830
6689
|
if (account.kind !== "hd") throw new Error("imported accounts are not yet supported");
|
|
4831
6690
|
const rpc = ctx.options.networks.solanaRpc(network);
|
|
6691
|
+
const independent = ctx.options.networks.independentSolanaRpc(network);
|
|
4832
6692
|
const apis = ctx.options.chains ?? chains(ctx.options.wallet);
|
|
4833
6693
|
const solAccount = await apis.account({
|
|
4834
6694
|
walletId: account.walletId,
|
|
@@ -4870,8 +6730,25 @@ const sendSolana = async (ctx, params) => {
|
|
|
4870
6730
|
...tokenProgram === void 0 ? {} : { tokenProgram }
|
|
4871
6731
|
});
|
|
4872
6732
|
const quotes = await solana.estimateFee(rpc, unsigned.writable);
|
|
4873
|
-
|
|
6733
|
+
let quote = quotes.find((q) => q.tier === fee) ?? quotes[1];
|
|
4874
6734
|
if (quote === void 0) throw new Error("no fee quote");
|
|
6735
|
+
const flags = [independent.kind === "unavailable" ? "reads-unverified" : void 0];
|
|
6736
|
+
if (independent.kind === "available") try {
|
|
6737
|
+
const other = (await solana.estimateFee(independent.client, unsigned.writable)).find((q) => q.tier === quote?.tier);
|
|
6738
|
+
if (other === void 0) flags.push("reads-unverified");
|
|
6739
|
+
else {
|
|
6740
|
+
const merged = mergeSolanaQuote(quote, other);
|
|
6741
|
+
({quote} = merged);
|
|
6742
|
+
flags.push(merged.mismatch ? "fee-mismatch" : void 0, exceedsNetworkCap(quote.microLamportsPerCu, other.microLamportsPerCu) ? "fee-above-network" : void 0);
|
|
6743
|
+
}
|
|
6744
|
+
} catch {
|
|
6745
|
+
flags.push("reads-unverified");
|
|
6746
|
+
}
|
|
6747
|
+
if (params.token === void 0) {
|
|
6748
|
+
const units = BigInt(Math.ceil((unsigned.unitsConsumed === 0 ? 14e5 : unsigned.unitsConsumed) * 1.1));
|
|
6749
|
+
const maxFee = BASE_FEE_LAMPORTS + (units * quote.microLamportsPerCu + 999999n) / 1000000n;
|
|
6750
|
+
flags.push(feeReachesAmount(maxFee, amount) ? "fee-exceeds-amount" : void 0);
|
|
6751
|
+
}
|
|
4875
6752
|
const messageBytes = solana.applyFee(unsigned, quote);
|
|
4876
6753
|
const request = {
|
|
4877
6754
|
kind: "solana.transaction",
|
|
@@ -4881,45 +6758,47 @@ const sendSolana = async (ctx, params) => {
|
|
|
4881
6758
|
};
|
|
4882
6759
|
const intent = ext === void 0 || params.token === void 0 ? decodeSolana(request, from) : token2022Intent(decodeSolana(request, from), ext, params.token, amount);
|
|
4883
6760
|
const id = newId(ctx.options.random);
|
|
4884
|
-
|
|
4885
|
-
|
|
4886
|
-
|
|
6761
|
+
const submit = async (verify) => {
|
|
6762
|
+
let tx = {
|
|
6763
|
+
id,
|
|
6764
|
+
family: "solana",
|
|
6765
|
+
network,
|
|
6766
|
+
account: account.walletId,
|
|
6767
|
+
hash: void 0,
|
|
6768
|
+
state: "draft",
|
|
6769
|
+
intent,
|
|
6770
|
+
nonce: 0,
|
|
6771
|
+
lastValidBlockHeight: Number(unsigned.lastValidBlockHeight),
|
|
6772
|
+
submittedAt: ctx.options.now(),
|
|
6773
|
+
error: void 0
|
|
6774
|
+
};
|
|
6775
|
+
tx = await ctx.transition(tx, {});
|
|
6776
|
+
try {
|
|
6777
|
+
const result = await apis.sign({
|
|
6778
|
+
request,
|
|
6779
|
+
verify
|
|
6780
|
+
});
|
|
6781
|
+
tx = await ctx.transition(tx, { state: "signed" });
|
|
6782
|
+
if (result.kind !== "solana.transaction" || !(result.signature instanceof Uint8Array)) throw new Error("unexpected solana sign result");
|
|
6783
|
+
const wire = solana.finalize(messageBytes, [result.signature]);
|
|
6784
|
+
const hash = await solana.broadcast(rpc, wire);
|
|
6785
|
+
wires.set(id, wire);
|
|
6786
|
+
return await submitTx(ctx, tx, hash);
|
|
6787
|
+
} catch (error) {
|
|
6788
|
+
await failTx(ctx, tx, error);
|
|
6789
|
+
throw error;
|
|
6790
|
+
}
|
|
6791
|
+
};
|
|
6792
|
+
return {
|
|
6793
|
+
txId: id,
|
|
6794
|
+
account,
|
|
4887
6795
|
network,
|
|
4888
|
-
account: account.walletId,
|
|
4889
|
-
hash: void 0,
|
|
4890
|
-
state: "draft",
|
|
4891
6796
|
intent,
|
|
4892
|
-
|
|
4893
|
-
|
|
4894
|
-
|
|
4895
|
-
|
|
6797
|
+
checks: collectChecks(flags),
|
|
6798
|
+
staleReason: async () => await solana.blockHeight(rpc) > unsigned.lastValidBlockHeight ? "blockhash expired — start over" : void 0,
|
|
6799
|
+
submit,
|
|
6800
|
+
release: () => void 0
|
|
4896
6801
|
};
|
|
4897
|
-
tx = await ctx.transition(tx, {});
|
|
4898
|
-
try {
|
|
4899
|
-
const result = await apis.sign({
|
|
4900
|
-
request,
|
|
4901
|
-
verify
|
|
4902
|
-
});
|
|
4903
|
-
tx = await ctx.transition(tx, { state: "signed" });
|
|
4904
|
-
if (result.kind !== "solana.transaction" || !(result.signature instanceof Uint8Array)) throw new Error("unexpected solana sign result");
|
|
4905
|
-
const wire = solana.finalize(messageBytes, [result.signature]);
|
|
4906
|
-
const hash = await solana.broadcast(rpc, wire);
|
|
4907
|
-
wires.set(id, wire);
|
|
4908
|
-
tx = await ctx.transition(tx, {
|
|
4909
|
-
state: "submitted",
|
|
4910
|
-
hash,
|
|
4911
|
-
submittedAt: ctx.options.now()
|
|
4912
|
-
});
|
|
4913
|
-
ctx.track(tx.id, hash);
|
|
4914
|
-
return tx;
|
|
4915
|
-
} catch (error) {
|
|
4916
|
-
const failed = tx.state === "signed" ? "failed" : "rejected";
|
|
4917
|
-
await ctx.transition(tx, {
|
|
4918
|
-
state: failed,
|
|
4919
|
-
error: errorMessage(error)
|
|
4920
|
-
});
|
|
4921
|
-
throw error;
|
|
4922
|
-
}
|
|
4923
6802
|
};
|
|
4924
6803
|
/** Returns the terminal state, `dropped` on blockhash expiry, or `undefined` to keep polling. */
|
|
4925
6804
|
const pollSolana = async (ctx, tx) => {
|
|
@@ -4979,13 +6858,7 @@ const token2022Intent = (intent, ext, mint, amount) => {
|
|
|
4979
6858
|
warnings
|
|
4980
6859
|
};
|
|
4981
6860
|
};
|
|
4982
|
-
|
|
4983
|
-
//#region src/transactions/controller.ts
|
|
4984
|
-
/**
|
|
4985
|
-
* Transaction controller: orchestrates lifecycle, persistence and polling; each chain family routes
|
|
4986
|
-
* through its own module (`evm.ts`, `solana.ts`, `bitcoin.ts`). A rejected signature (wrong
|
|
4987
|
-
* password, cancelled presence) never broadcasts.
|
|
4988
|
-
*/
|
|
6861
|
+
// src/transactions/controller.ts
|
|
4989
6862
|
const encode = (tx) => new TextEncoder().encode(JSON.stringify(tx));
|
|
4990
6863
|
const isObject = (v) => typeof v === "object" && v !== null;
|
|
4991
6864
|
const TX_STATES = /* @__PURE__ */ new Set([
|
|
@@ -5005,11 +6878,12 @@ const isBtcRecord = (v) => {
|
|
|
5005
6878
|
const { inputs, outputs, vsize, fee, feeRate } = v;
|
|
5006
6879
|
return Array.isArray(inputs) && Array.isArray(outputs) && typeof vsize === "number" && typeof fee === "number" && typeof feeRate === "number" && inputs.every((i) => isObject(i) && typeof i["txid"] === "string" && typeof i["vout"] === "number" && typeof i["value"] === "number" && typeof i["address"] === "string") && outputs.every((o) => isObject(o) && typeof o["address"] === "string" && typeof o["value"] === "number" && typeof o["own"] === "boolean");
|
|
5007
6880
|
};
|
|
6881
|
+
const isSparkDeposit = (v) => isObject(v) && typeof v["walletId"] === "string" && typeof v["groupIndex"] === "number" && (v["network"] === "mainnet" || v["network"] === "regtest") && (v["claimed"] === void 0 || typeof v["claimed"] === "boolean");
|
|
5008
6882
|
const isIntent = (v) => isObject(v) && typeof v["summary"] === "string" && Array.isArray(v["outflows"]) && typeof v["verifiability"] === "string";
|
|
5009
6883
|
const decode = (raw) => {
|
|
5010
6884
|
const parsed = JSON.parse(new TextDecoder().decode(raw));
|
|
5011
6885
|
if (!isObject(parsed)) return;
|
|
5012
|
-
const { id, family, network, account, hash, state, intent, nonce, replaces, parentId, btc, submittedAt, error } = parsed;
|
|
6886
|
+
const { id, family, network, account, hash, state, intent, nonce, replaces, parentId, btc, sparkDeposit, submittedAt, error } = parsed;
|
|
5013
6887
|
if (typeof id !== "string" || family !== "evm" && family !== "solana" && family !== "bitcoin" || typeof network !== "string" || typeof account !== "string" || !isTxState(state) || typeof nonce !== "number" || typeof submittedAt !== "number") return;
|
|
5014
6888
|
return {
|
|
5015
6889
|
id,
|
|
@@ -5023,6 +6897,7 @@ const decode = (raw) => {
|
|
|
5023
6897
|
...typeof replaces === "string" ? { replaces } : {},
|
|
5024
6898
|
...typeof parentId === "string" ? { parentId } : {},
|
|
5025
6899
|
...isBtcRecord(btc) ? { btc } : {},
|
|
6900
|
+
...isSparkDeposit(sparkDeposit) ? { sparkDeposit } : {},
|
|
5026
6901
|
submittedAt,
|
|
5027
6902
|
error: typeof error === "string" ? error : void 0
|
|
5028
6903
|
};
|
|
@@ -5044,6 +6919,7 @@ var TransactionController = class extends Controller {
|
|
|
5044
6919
|
async init() {
|
|
5045
6920
|
this.locked = false;
|
|
5046
6921
|
this.engineStore = await this.options.openStore();
|
|
6922
|
+
await this.bitcoinRoute.unlock(this.engineStore);
|
|
5047
6923
|
const collection = this.engineStore.collection("transactions");
|
|
5048
6924
|
const keys = await collection.list();
|
|
5049
6925
|
const rows = await Promise.all(keys.map(async (key) => collection.get(key)));
|
|
@@ -5070,10 +6946,16 @@ var TransactionController = class extends Controller {
|
|
|
5070
6946
|
this.timers.clear();
|
|
5071
6947
|
this.engineStore = void 0;
|
|
5072
6948
|
this.evmRoute = createEvmRoute();
|
|
6949
|
+
this.bitcoinRoute.lock();
|
|
6950
|
+
this.bitcoinRoute = createBitcoinRoute();
|
|
5073
6951
|
}
|
|
5074
6952
|
async persist(tx) {
|
|
5075
6953
|
const store = this.engineStore;
|
|
5076
|
-
if (store
|
|
6954
|
+
if (store === void 0) {
|
|
6955
|
+
this.options.logger.warn(`transaction ${tx.id} (${tx.state}) not persisted: vault locked`);
|
|
6956
|
+
return;
|
|
6957
|
+
}
|
|
6958
|
+
await store.collection("transactions").put(tx.id, encode(tx));
|
|
5077
6959
|
}
|
|
5078
6960
|
async transition(tx, next) {
|
|
5079
6961
|
const updated = {
|
|
@@ -5095,45 +6977,109 @@ var TransactionController = class extends Controller {
|
|
|
5095
6977
|
options: this.options,
|
|
5096
6978
|
transition: async (tx, next) => this.transition(tx, next),
|
|
5097
6979
|
track: (id, hash) => this.track(id, hash),
|
|
6980
|
+
txs: () => this.getState().txs,
|
|
5098
6981
|
newId: () => newId(this.options.random)
|
|
5099
6982
|
};
|
|
5100
6983
|
}
|
|
5101
|
-
|
|
6984
|
+
/**
|
|
6985
|
+
* Internal entry for the request pipeline: builds, estimates and cross-checks a send without
|
|
6986
|
+
* signing it. Bitcoin outputs it selects stay locked under `requestId` until the returned
|
|
6987
|
+
* `release` runs or the broadcast moves them to the transaction.
|
|
6988
|
+
*/
|
|
6989
|
+
async prepareSend(params, requestId, sparkDeposit) {
|
|
5102
6990
|
if (this.locked) throw new Error("transactions require an unlocked vault");
|
|
5103
6991
|
const { account, network } = params;
|
|
5104
6992
|
const info = this.options.networks.info(network);
|
|
5105
6993
|
if (account.kind !== "hd") throw new Error("imported accounts are not yet supported");
|
|
5106
6994
|
const ctx = this.routeCtx();
|
|
5107
|
-
if (info.family === "solana") return
|
|
5108
|
-
if (info.family === "bitcoin") return this.bitcoinRoute.
|
|
5109
|
-
return this.evmRoute.
|
|
6995
|
+
if (info.family === "solana") return prepareSolana(ctx, params);
|
|
6996
|
+
if (info.family === "bitcoin") return this.bitcoinRoute.prepare(ctx, params, requestId, sparkDeposit);
|
|
6997
|
+
return this.evmRoute.prepare(ctx, params);
|
|
6998
|
+
}
|
|
6999
|
+
/** The group's next unused `btc.bip84` receive address on a bitcoin `network`. */
|
|
7000
|
+
async nextReceiveAddress(walletId, groupIndex, network) {
|
|
7001
|
+
if (this.locked) throw new Error("transactions require an unlocked vault");
|
|
7002
|
+
return this.bitcoinRoute.nextReceiveAddress(this.routeCtx(), walletId, groupIndex, network);
|
|
7003
|
+
}
|
|
7004
|
+
/** Records that a Spark deposit was credited; persisted with the transaction. */
|
|
7005
|
+
async markDepositClaimed(id) {
|
|
7006
|
+
const tx = this.getState().txs[id];
|
|
7007
|
+
if (tx?.sparkDeposit === void 0) throw new Error(`transaction ${id} is not a Spark deposit`);
|
|
7008
|
+
await this.transition(tx, { sparkDeposit: {
|
|
7009
|
+
...tx.sparkDeposit,
|
|
7010
|
+
claimed: true
|
|
7011
|
+
} });
|
|
5110
7012
|
}
|
|
5111
7013
|
/**
|
|
5112
|
-
*
|
|
5113
|
-
*
|
|
7014
|
+
* Internal entry for a dApp's EVM signature request: the request is normalized first, a
|
|
7015
|
+
* transaction is rebuilt with fresh nonce and fees (the dApp's own are discarded), and a message
|
|
7016
|
+
* is decoded and signed as is.
|
|
5114
7017
|
*/
|
|
5115
|
-
async
|
|
7018
|
+
async prepareDapp(account, request) {
|
|
5116
7019
|
if (this.locked) throw new Error("transactions require an unlocked vault");
|
|
5117
|
-
const
|
|
5118
|
-
if (
|
|
5119
|
-
if (
|
|
5120
|
-
|
|
5121
|
-
|
|
5122
|
-
|
|
7020
|
+
const normalized = normalizeEvm(request);
|
|
7021
|
+
if (account.kind !== "hd") throw new Error("imported accounts are not yet supported");
|
|
7022
|
+
if (normalized.kind === "evm.transaction") return this.evmRoute.prepareCall(this.routeCtx(), {
|
|
7023
|
+
account,
|
|
7024
|
+
network: normalized.network,
|
|
7025
|
+
tx: normalized.tx
|
|
7026
|
+
});
|
|
7027
|
+
if (normalized.kind !== "evm.personal-message" && normalized.kind !== "evm.typed-data") throw new Error(`unsupported dApp request ${normalized.kind}`);
|
|
7028
|
+
const { kind, network } = normalized;
|
|
7029
|
+
const apis = this.options.chains ?? chains(this.options.wallet);
|
|
7030
|
+
return {
|
|
7031
|
+
account,
|
|
7032
|
+
network,
|
|
7033
|
+
intent: decodeEvm(normalized),
|
|
7034
|
+
checks: [],
|
|
7035
|
+
staleReason: () => void 0,
|
|
7036
|
+
release: () => void 0,
|
|
7037
|
+
submit: async (verify) => {
|
|
7038
|
+
const result = await apis.sign({
|
|
7039
|
+
request: normalized.kind === "evm.personal-message" ? {
|
|
7040
|
+
kind: normalized.kind,
|
|
7041
|
+
network,
|
|
7042
|
+
account,
|
|
7043
|
+
message: normalized.message
|
|
7044
|
+
} : {
|
|
7045
|
+
kind: normalized.kind,
|
|
7046
|
+
network,
|
|
7047
|
+
account,
|
|
7048
|
+
typedData: normalized.typedData
|
|
7049
|
+
},
|
|
7050
|
+
verify
|
|
7051
|
+
});
|
|
7052
|
+
if (result.kind !== kind || !("signature" in result)) throw new Error("unexpected sign result");
|
|
7053
|
+
return {
|
|
7054
|
+
kind,
|
|
7055
|
+
signature: result.signature
|
|
7056
|
+
};
|
|
7057
|
+
}
|
|
7058
|
+
};
|
|
5123
7059
|
}
|
|
5124
7060
|
/**
|
|
5125
|
-
*
|
|
5126
|
-
*
|
|
7061
|
+
* Internal entry for replacements and CPFP: builds, cross-checks and reserves what the
|
|
7062
|
+
* replacement needs without signing it. The original must be `submitted` or `pending`.
|
|
5127
7063
|
*/
|
|
5128
|
-
async
|
|
7064
|
+
async prepareReplacement(input, requestId) {
|
|
5129
7065
|
if (this.locked) throw new Error("transactions require an unlocked vault");
|
|
5130
|
-
const old = this.getState().txs[
|
|
5131
|
-
if (old === void 0) throw new Error(`unknown transaction ${
|
|
5132
|
-
if (old.
|
|
5133
|
-
|
|
5134
|
-
|
|
7066
|
+
const old = this.getState().txs[input.txId];
|
|
7067
|
+
if (old === void 0) throw new Error(`unknown transaction ${input.txId}`);
|
|
7068
|
+
if (old.state !== "pending" && old.state !== "submitted") throw new Error(`cannot ${input.method} a ${old.state} transaction`);
|
|
7069
|
+
const ctx = this.routeCtx();
|
|
7070
|
+
if (input.method === "cpfp") {
|
|
7071
|
+
if (old.family !== "bitcoin") throw new Error("cpfp is Bitcoin-only");
|
|
7072
|
+
return this.bitcoinRoute.prepareCpfp(ctx, old, input.feeRate, requestId);
|
|
7073
|
+
}
|
|
7074
|
+
if (old.family === "bitcoin") return this.bitcoinRoute.prepareReplace(ctx, old, input.method, requestId);
|
|
7075
|
+
if (old.family === "evm") return this.evmRoute.prepareReplace(ctx, old, input.method);
|
|
7076
|
+
throw new Error(`${input.method} is EVM and Bitcoin only`);
|
|
5135
7077
|
}
|
|
5136
7078
|
track(id, hash) {
|
|
7079
|
+
if (this.locked) {
|
|
7080
|
+
this.options.logger.warn(`transaction ${id} was broadcast after the vault locked; it is neither persisted nor tracked`);
|
|
7081
|
+
return;
|
|
7082
|
+
}
|
|
5137
7083
|
if (this.timers.has(id) || hash === "") return;
|
|
5138
7084
|
const poll = this.options.pollMs ?? 4e3;
|
|
5139
7085
|
const stop = this.options.setTimer(poll, async () => {
|
|
@@ -5161,8 +7107,7 @@ var TransactionController = class extends Controller {
|
|
|
5161
7107
|
this.timers.set(id, stop);
|
|
5162
7108
|
}
|
|
5163
7109
|
};
|
|
5164
|
-
|
|
5165
|
-
//#region src/engine.ts
|
|
7110
|
+
// src/engine.ts
|
|
5166
7111
|
/**
|
|
5167
7112
|
* Composition root: wires the Wallet's lock state to the SparkController's lifecycle. Unlocked →
|
|
5168
7113
|
* open the EngineStore and start every declared account; locked → abort event loops, close every
|
|
@@ -5193,7 +7138,8 @@ function createWalletEngine(options) {
|
|
|
5193
7138
|
fetch: options.fetch,
|
|
5194
7139
|
rpc: options.rpc ?? {},
|
|
5195
7140
|
openStore: options.openStore,
|
|
5196
|
-
logger: options.logger
|
|
7141
|
+
logger: options.logger,
|
|
7142
|
+
gateway: options.gateway
|
|
5197
7143
|
});
|
|
5198
7144
|
const assets = new AssetsController({
|
|
5199
7145
|
networks,
|
|
@@ -5220,6 +7166,28 @@ function createWalletEngine(options) {
|
|
|
5220
7166
|
now: options.now,
|
|
5221
7167
|
logger: options.logger
|
|
5222
7168
|
});
|
|
7169
|
+
const addressBook = new AddressBookController({
|
|
7170
|
+
openStore: options.openStore,
|
|
7171
|
+
random: options.random,
|
|
7172
|
+
now: options.now,
|
|
7173
|
+
logger: options.logger
|
|
7174
|
+
});
|
|
7175
|
+
const requests = new RequestController({
|
|
7176
|
+
transactions,
|
|
7177
|
+
addressBook,
|
|
7178
|
+
openStore: options.openStore,
|
|
7179
|
+
spark: controller,
|
|
7180
|
+
fetch: options.fetch,
|
|
7181
|
+
random: options.random,
|
|
7182
|
+
now: options.now,
|
|
7183
|
+
setTimer: options.setTimer ?? ((ms, fn) => {
|
|
7184
|
+
const id = setTimeout(() => {
|
|
7185
|
+
fn();
|
|
7186
|
+
}, ms);
|
|
7187
|
+
return () => clearTimeout(id);
|
|
7188
|
+
}),
|
|
7189
|
+
logger: options.logger
|
|
7190
|
+
});
|
|
5223
7191
|
const accounts = new AccountTreeController({
|
|
5224
7192
|
wallet: options.wallet,
|
|
5225
7193
|
spark: controller,
|
|
@@ -5231,11 +7199,45 @@ function createWalletEngine(options) {
|
|
|
5231
7199
|
const metaSync = new MetaSyncController({
|
|
5232
7200
|
wallet: options.wallet,
|
|
5233
7201
|
accounts,
|
|
5234
|
-
|
|
7202
|
+
addressBook,
|
|
7203
|
+
nostr: { syncSigner: (walletId) => nostr(options.wallet).syncSigner(walletId) },
|
|
5235
7204
|
metaSync: options.metaSync,
|
|
5236
7205
|
logger: options.logger,
|
|
5237
7206
|
now: options.now
|
|
5238
7207
|
});
|
|
7208
|
+
const depositClaimer = new DepositClaimer({
|
|
7209
|
+
transactions,
|
|
7210
|
+
spark: controller,
|
|
7211
|
+
networks,
|
|
7212
|
+
logger: options.logger
|
|
7213
|
+
});
|
|
7214
|
+
const connections = new ConnectionController({
|
|
7215
|
+
openStore: options.openStore,
|
|
7216
|
+
random: options.random,
|
|
7217
|
+
now: options.now,
|
|
7218
|
+
logger: options.logger,
|
|
7219
|
+
allowLocalhost: options.allowLocalhostOrigins ?? false,
|
|
7220
|
+
selectedGroup: () => accounts.getState().selected ?? void 0,
|
|
7221
|
+
evmAddress: async (group, network) => {
|
|
7222
|
+
const account = await chains(options.wallet).account({
|
|
7223
|
+
...group,
|
|
7224
|
+
template: "evm.bip44",
|
|
7225
|
+
network
|
|
7226
|
+
});
|
|
7227
|
+
if (!("address" in account)) throw new Error("no EVM address for the group");
|
|
7228
|
+
return account.address;
|
|
7229
|
+
},
|
|
7230
|
+
enabledNetworks: () => networks.enabled("evm"),
|
|
7231
|
+
invalidate: (origin) => {
|
|
7232
|
+
requests.invalidateOrigin(origin);
|
|
7233
|
+
}
|
|
7234
|
+
});
|
|
7235
|
+
const eip1193 = new Eip1193Handler({
|
|
7236
|
+
connections,
|
|
7237
|
+
requests,
|
|
7238
|
+
networks,
|
|
7239
|
+
defaultNetwork: () => networks.enabled("evm")[0] ?? "eip155:1"
|
|
7240
|
+
});
|
|
5239
7241
|
let chain = Promise.resolve();
|
|
5240
7242
|
let store;
|
|
5241
7243
|
let started = false;
|
|
@@ -5257,12 +7259,20 @@ function createWalletEngine(options) {
|
|
|
5257
7259
|
backup.trigger();
|
|
5258
7260
|
await accounts.init();
|
|
5259
7261
|
await controller.init();
|
|
7262
|
+
await addressBook.init();
|
|
5260
7263
|
await metaSync.init();
|
|
5261
7264
|
await networks.init();
|
|
5262
7265
|
await assets.init();
|
|
5263
7266
|
await transactions.init();
|
|
7267
|
+
await requests.init();
|
|
7268
|
+
await connections.init();
|
|
7269
|
+
depositClaimer.start();
|
|
5264
7270
|
} else if (!unlocked && started) {
|
|
5265
7271
|
started = false;
|
|
7272
|
+
depositClaimer.stop();
|
|
7273
|
+
connections.lock();
|
|
7274
|
+
requests.lock();
|
|
7275
|
+
addressBook.lock();
|
|
5266
7276
|
transactions.lock();
|
|
5267
7277
|
assets.lock();
|
|
5268
7278
|
networks.lock();
|
|
@@ -5302,20 +7312,18 @@ function createWalletEngine(options) {
|
|
|
5302
7312
|
networks,
|
|
5303
7313
|
assets,
|
|
5304
7314
|
transactions,
|
|
7315
|
+
requests,
|
|
7316
|
+
addressBook,
|
|
7317
|
+
connections,
|
|
7318
|
+
eip1193,
|
|
5305
7319
|
spark: controller,
|
|
5306
7320
|
accounts,
|
|
5307
7321
|
metaSync,
|
|
5308
7322
|
backup,
|
|
5309
|
-
zaps: {
|
|
5310
|
-
|
|
5311
|
-
|
|
5312
|
-
|
|
5313
|
-
}),
|
|
5314
|
-
pay: async (account, prepared, payOptions) => payZap(controller, account, prepared, {
|
|
5315
|
-
...payOptions,
|
|
5316
|
-
now: options.now
|
|
5317
|
-
})
|
|
5318
|
-
},
|
|
7323
|
+
zaps: { prepare: async (params) => prepareZap(params, {
|
|
7324
|
+
fetch: options.fetch,
|
|
7325
|
+
now: options.now
|
|
7326
|
+
}) },
|
|
5319
7327
|
payments: {
|
|
5320
7328
|
parse: (input, network) => parsePayment(input, {
|
|
5321
7329
|
network,
|
|
@@ -5326,7 +7334,7 @@ function createWalletEngine(options) {
|
|
|
5326
7334
|
now: options.now(),
|
|
5327
7335
|
fetch: options.fetch
|
|
5328
7336
|
}),
|
|
5329
|
-
|
|
7337
|
+
withdrawLnurl: async (params) => withdrawLnurl({
|
|
5330
7338
|
spark: controller,
|
|
5331
7339
|
fetch: options.fetch,
|
|
5332
7340
|
now: options.now
|
|
@@ -5335,8 +7343,7 @@ function createWalletEngine(options) {
|
|
|
5335
7343
|
unsubscribe
|
|
5336
7344
|
};
|
|
5337
7345
|
}
|
|
5338
|
-
|
|
5339
|
-
//#region src/backup/memory.ts
|
|
7346
|
+
// src/backup/memory.ts
|
|
5340
7347
|
const tick = Promise.resolve();
|
|
5341
7348
|
/** In-memory CloudStore for tests and the playground; swap the Map for cross-engine sharing. */
|
|
5342
7349
|
function createMemoryCloudStore(backing) {
|
|
@@ -5368,7 +7375,6 @@ function createMemoryCloudStore(backing) {
|
|
|
5368
7375
|
}
|
|
5369
7376
|
};
|
|
5370
7377
|
}
|
|
5371
|
-
|
|
5372
|
-
export { AccountTreeController, AccountTreeError, BackupController, BackupControllerError, Controller, EngineStoreError, MetaSyncController, MetaSyncError, PaymentError, SparkController, SparkControllerError, createEventBus, createMemoryCloudStore, createMemoryEngineStore, createWalletEngine, decodeExitChain, encodeExitChain, parsePayment, payZap, prepareZap, resolvePayment, sparkStore };
|
|
7378
|
+
export { AccountTreeController, AccountTreeError, AddressBookController, AddressBookError, BackupController, BackupControllerError, ConnectionController, Controller, Eip1193Handler, EngineStoreError, MetaSyncController, MetaSyncError, PaymentError, ProviderError, REQUEST_TTL_MS, RequestController, SparkController, SparkControllerError, createEventBus, createMemoryCloudStore, createMemoryEngineStore, createWalletEngine, decodeExitChain, encodeExitChain, parsePayment, prepareZap, resolvePayment, sparkStore };
|
|
5373
7379
|
|
|
5374
7380
|
//# sourceMappingURL=index.mjs.map
|