@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/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
- //#region src/controller.ts
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
- //#endregion
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
- //#endregion
73
- //#region 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) {
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 = "EngineStoreError";
80
- this.code = code;
1727
+ this.name = "AddressBookError";
1728
+ this.category = category;
81
1729
  }
82
1730
  };
83
- //#endregion
84
- //#region src/store/guard.ts
85
- /** NUL is the composite-key/AAD separator — a name or key containing it collides. */
86
- function checkName(kind, value) {
87
- if (value.includes("\0")) throw new EngineStoreError("invalid-input", `${kind} must not contain NUL`);
88
- }
89
- //#endregion
90
- //#region src/store/memory.ts
91
- /** In-memory EngineStore: the Node/test implementation — same contract, no durability. */
92
- function createMemoryEngineStore(backing) {
93
- const collections = backing?.collections ?? /* @__PURE__ */ new Map();
94
- let closed = false;
95
- const guard = () => {
96
- if (closed) throw new EngineStoreError("closed", "engine store is closed");
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
- collection(name) {
100
- checkName("collection", name);
101
- let table = collections.get(name);
102
- if (table === void 0) {
103
- table = /* @__PURE__ */ new Map();
104
- collections.set(name, table);
105
- }
106
- return {
107
- get: async (key) => {
108
- guard();
109
- checkName("key", key);
110
- const value = table.get(key);
111
- return value === void 0 ? void 0 : new Uint8Array(value);
112
- },
113
- put: async (key, bytes) => {
114
- guard();
115
- checkName("key", key);
116
- table.set(key, new Uint8Array(bytes));
117
- },
118
- delete: async (key) => {
119
- guard();
120
- checkName("key", key);
121
- table.delete(key);
122
- },
123
- list: async () => {
124
- guard();
125
- return [...table.keys()];
126
- }
127
- };
128
- },
129
- close: async () => {
130
- closed = true;
131
- collections.clear();
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
- //#endregion
136
- //#region src/chains/bitcoin-scan.ts
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
- //#endregion
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
- //#endregion
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
- //#endregion
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). Nonces are allocated on a per-(network, from) serial queue as max(pending count, last
1148
- * submitted + 1). Finalization uses the `finalized` block tag; receipts with status 0 fail.
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
- async rpc(client, method, params) {
1222
- return client.transport.request({
1223
- method,
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
- /** Allocate the next nonce for (network, from) — serial per key. */
1233
- async nonce(client, network, from) {
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 = Number(await this.rpcHex(client, "eth_getTransactionCount", [from, "pending"]));
3143
+ const pending = await this.pendingCount(client, from);
1237
3144
  const local = this.lastNonce.get(key);
1238
- const nonce = Math.max(pending, local === void 0 ? 0 : local + 1);
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.nonce(client, network, from);
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 this.rpcHex(client, "eth_estimateGas", [{
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: estimate * scale / 10n + (estimate * scale % 10n === 0n ? 0n : 1n)
3185
+ gas: buffered > minGas ? buffered : minGas
1270
3186
  };
1271
3187
  }
1272
3188
  async estimateFee(client) {
1273
3189
  try {
1274
- const history = await this.rpc(client, "eth_feeHistory", [
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 this.rpcHex(client, "eth_gasPrice", []));
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 this.rpcHex(client, "eth_sendRawTransaction", [raw]);
3277
+ return evmRpcHex(client, "eth_sendRawTransaction", [raw]);
1362
3278
  }
1363
3279
  async status(client, hash) {
1364
- const receipt = await this.rpc(client, "eth_getTransactionReceipt", [hash]);
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 this.rpc(client, "eth_getBlockByNumber", ["finalized", false]);
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
- //#endregion
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 this.rpcHex(client, "eth_getBalance", [address, "latest"])).toString();
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.callString(client, t.address, "symbol") ?? "?",
1560
- decimals: await this.callNumber(client, t.address, "decimals") ?? 18,
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 this.rpcHex(client, "eth_call", [{
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 this.rpcHex(client, "eth_call", [{
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 callString(client, token, fn) {
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 this.rpcHex(client, "eth_call", [{
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.callString(client, address, "symbol");
1663
- await this.callNumber(client, address, "decimals");
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
- //#endregion
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
- //#endregion
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) || !isStringArray(nostrKeyIds)) return;
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
- const wallets = snapshot.wallets.filter((w) => w.backup !== "mnemonic");
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) && sameSet(cov.nostrKeyIds, record.nostrKeyIds)) {
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
- /** Strict decode — any malformed shape, unknown key or duplicate index returns `undefined`. */
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"] !== 1) return;
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: 1,
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
- //#endregion
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
- //#endregion
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 group-0 derived key. Pull always precedes publish in an unlock session —
2175
- * replaceable events would otherwise let a stale local tree overwrite a newer remote document.
2176
- * Debounced publishes are per wallet; a generation counter cancels in-flight write-backs on lock.
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.signerFor(walletId);
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 this.pubkeyFor(signer);
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.options.accounts.exportMeta(walletId);
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 decrypt = signer.nip44Decrypt;
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
- const doc = this.options.accounts.exportMeta(walletId);
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.options.accounts.exportMeta(walletId));
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.signerFor(walletId);
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.options.accounts.exportMeta(walletId));
4245
+ const bytes = encodeMetaDoc(this.exportDoc(walletId));
2378
4246
  if (bytes === void 0) throw new MetaSyncError("internal", "meta doc exceeds 64 KB");
2379
- const encrypt = signer.nip44Encrypt;
2380
- if (encrypt === void 0) throw new MetaSyncError("internal", "signer lacks nip44 encryption");
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 1 is a newer schema, not garbage. */
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 > 1;
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
- //#endregion
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.subarray(1);
2554
- entry.tapBip32Derivation = [[pub.subarray(1), {
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
- //#endregion
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
- const raw = await this.engineStore.collection("networks").get("customRpc");
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
- return [...this.getState().customRpc[network] ?? [], ...this.options.rpc[network] ?? []];
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 = createPublicClient({
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
- /** Validate a custom endpoint's reported chain id, then persist it first in the list. */
2784
- /** Solana JSON-RPC over the ordered endpoint list — kit's transport is single-endpoint. */
2785
- /** Networks with at least one configured endpoint, filtered by family. */
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([...Object.keys(this.options.rpc), ...Object.keys(this.getState().customRpc)])].filter((id) => {
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 { call: async (method, params) => {
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 esplora(urls, async (url, init) => {
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
- //#endregion
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
- //#endregion
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
- //#endregion
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
- //#endregion
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
- //#endregion
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
- //#endregion
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
- //#endregion
4010
- //#region src/transactions/bitcoin.ts
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
- const send = async (ctx, params) => {
4042
- const { account, network, to, amount, fee, verify } = params;
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 { psbt, inputs: selected } = bitcoin.buildPsbt(inputs, to, amount, BigInt(quote.satPerByte), changeAddr, net.params);
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
- pending.set(id0, {
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
- let tx = {
4157
- id: id0,
4158
- family: "bitcoin",
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
- nonce: 0,
4165
- btc: {
4166
- inputs: selectedUtxos.map((u) => ({
4167
- txid: u.txid,
4168
- vout: u.vout,
4169
- value: u.value,
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
- const finish = async (ctx, stash, es, account, old, psbt, record, link, verify) => {
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
- let tx = {
4223
- id: newId(ctx.options.random),
4224
- family: "bitcoin",
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
- account: old.account,
4227
- hash: void 0,
4228
- state: "draft",
6010
+ target: old.id,
4229
6011
  intent,
4230
- nonce: 0,
4231
- btc: record,
4232
- submittedAt: ctx.options.now(),
4233
- error: void 0,
4234
- ...link
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 replace = async (ctx, old, mode, verify) => {
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
- const rate = tierQuote === void 0 ? 1 : tierQuote.satPerByte;
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
- return finish(ctx, stash, es, account, old, psbt, record, { replaces: old.id }, verify);
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 bumpChild = async (ctx, old, targetRate, verify) => {
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, i) => ({
6190
+ inputs: own.map((o) => ({
4416
6191
  txid: old.hash ?? "",
4417
- vout: i,
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 finish(ctx, stash, es, account, old, psbt, record, { parentId: old.id }, verify);
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
- send,
4434
- poll,
4435
- replace,
4436
- bumpChild
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
- //#endregion
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 send = async (ctx, params) => {
4449
- const { account, network, to, amount, fee, verify } = params;
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 unsigned = await evm.build(client, network, from, params.token === void 0 ? {
4463
- kind: "native",
4464
- to,
4465
- value: amount
4466
- } : {
4467
- kind: "erc20",
4468
- token: params.token,
4469
- to,
4470
- value: amount
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
- const quote = quotes.find((q) => q.tier === fee) ?? quotes[1];
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 id = newId(ctx.options.random);
4479
- pending.set(id, {
4480
- unsigned: feed,
4481
- request,
4482
- quote,
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
- verify
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
- ctx.track(tx.id, hash);
4522
- return tx;
4523
- } catch (error) {
4524
- await ctx.transition(tx, {
4525
- state: "failed",
4526
- error: errorMessage(error)
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
- throw error;
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 new tx carries `replaces` and needs a fresh signature; a
4534
- * rejected one never reaches the network and leaves the original.
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 replace = async (ctx, old, mode, verify) => {
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
- const estimate = estimates.find((q) => q.tier === tier) ?? estimates[1];
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 id = newId(ctx.options.random);
4568
- pending.set(id, {
4569
- unsigned,
4570
- request,
4571
- quote: stash.quote,
4572
- chainId: stash.chainId
4573
- });
4574
- let tx = {
4575
- id,
4576
- family: "evm",
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
- account: old.account,
4579
- hash: void 0,
4580
- state: "draft",
6470
+ target: old.id,
4581
6471
  intent,
4582
- nonce: old.nonce,
4583
- replaces: old.id,
4584
- submittedAt: ctx.options.now(),
4585
- error: void 0
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
- send,
6487
+ prepare,
6488
+ prepareCall,
4631
6489
  poll,
4632
- replace
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
- //#endregion
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 sendSolana = async (ctx, params) => {
4829
- const { account, network, to, amount, fee, verify } = params;
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
- const quote = quotes.find((q) => q.tier === fee) ?? quotes[1];
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
- let tx = {
4885
- id,
4886
- family: "solana",
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
- nonce: 0,
4893
- lastValidBlockHeight: Number(unsigned.lastValidBlockHeight),
4894
- submittedAt: ctx.options.now(),
4895
- error: void 0
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
- //#endregion
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 !== void 0) await store.collection("transactions").put(tx.id, encode(tx));
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
- async send(params) {
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 sendSolana(ctx, params);
5108
- if (info.family === "bitcoin") return this.bitcoinRoute.send(ctx, params);
5109
- return this.evmRoute.send(ctx, params);
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
- * Speed up or cancel a pending/submitted EVM transaction at the same nonce — a fresh signature is
5113
- * required and a rejection leaves the original untouched.
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 replace(id, mode, verify) {
7018
+ async prepareDapp(account, request) {
5116
7019
  if (this.locked) throw new Error("transactions require an unlocked vault");
5117
- const old = this.getState().txs[id];
5118
- if (old === void 0) throw new Error(`unknown transaction ${id}`);
5119
- if (old.family === "bitcoin") return this.bitcoinRoute.replace(this.routeCtx(), old, mode, verify);
5120
- if (old.family !== "evm") throw new Error("replace is EVM-only in this layer");
5121
- if (old.state !== "pending" && old.state !== "submitted") throw new Error(`cannot replace a ${old.state} transaction`);
5122
- return this.evmRoute.replace(this.routeCtx(), old, mode, verify);
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
- * CPFP — spend an unconfirmed tx's own outputs into a higher-fee child so the package mines.
5126
- * Bitcoin only; the child is linked via `parentId`.
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 bumpChild(id, targetRate, verify) {
7064
+ async prepareReplacement(input, requestId) {
5129
7065
  if (this.locked) throw new Error("transactions require an unlocked vault");
5130
- const old = this.getState().txs[id];
5131
- if (old === void 0) throw new Error(`unknown transaction ${id}`);
5132
- if (old.family !== "bitcoin") throw new Error("bumpChild is Bitcoin-only in this layer");
5133
- if (old.state !== "pending" && old.state !== "submitted") throw new Error(`cannot bump a ${old.state} transaction`);
5134
- return this.bitcoinRoute.bumpChild(this.routeCtx(), old, targetRate, verify);
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
- //#endregion
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
- nostr: { signer: (ref) => nostr(options.wallet).signer(ref) },
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
- prepare: async (params) => prepareZap(params, {
5311
- fetch: options.fetch,
5312
- now: options.now
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
- pay: async (params) => pay({
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
- //#endregion
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
- //#endregion
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