@qntx/wallet-engine 0.5.2 → 0.6.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,22 +1,23 @@
1
+ import { A as parseWith, M as INSTALL_ID_HEADER, c as networkById, i as servedNetworksResponse, t as networksPath, u as esploraBasePath, x as rpcPath } from "./contract-D5pguqiZ.mjs";
1
2
  import { createStore } from "zustand/vanilla";
3
+ import { Address, OutScript, Transaction } from "@scure/btc-signer";
2
4
  import { SparkInputError, bitcoinAddressScript, createSparkClient, decodeBolt11, decodeSparkAddress, encodeSparkAddress, parseSparkInvoice, uuidv7 } from "@qntx/spark";
5
+ import { BITCOIN_NETWORKS, bitcoinAddressFromXpub, bitcoinNetwork, bitcoinPubkeyFromXpub, decodeBitcoinPsbt } from "@qntx/wallet-chains/bitcoin";
6
+ import { checksumEvmAddress, decodeEvm, isEvmAddress } from "@qntx/wallet-chains/evm";
7
+ import { PROGRAM_IDS, decodeSolana, isSolanaAddress, netAfterTransferFee, parseMintExtensions } from "@qntx/wallet-chains/solana";
8
+ import { WalletError, parseBackupHeader } from "@qntx/wallet";
9
+ import { bytesToHex, utf8ToBytes } from "@noble/hashes/utils.js";
10
+ import { decodeLnurl, encodeLnurl, lightningAddressUrl, registerLightningAddress, requestInvoice, resolveLnurl, submitWithdraw, unregisterLightningAddress } from "@qntx/spark/lnurl";
11
+ import { signedMatchesUnsigned, verifyEvent } from "@qntx/nostr";
12
+ import { makeZapRequest } from "@qntx/nostr/nips/nip57";
3
13
  import { nostr } from "@qntx/wallet/nostr";
4
14
  import { spark } from "@qntx/wallet/spark";
5
15
  import { createPublicClient, decodeFunctionResult, encodeFunctionData, fallback, http, isAddress, isHex, numberToHex } from "viem";
6
- import { bitcoinAddressFromXpub, bitcoinNetwork, bitcoinPubkeyFromXpub, decodeBitcoinPsbt } from "@qntx/wallet-chains/bitcoin";
7
16
  import { chains } from "@qntx/wallet/chains";
8
17
  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
18
  import { base64, hex } from "@scure/base";
15
19
  import { p2wpkh } from "@scure/btc-signer/payment.js";
16
20
  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
21
  import { getSetComputeUnitLimitInstruction, getSetComputeUnitPriceInstruction } from "@solana-program/compute-budget";
21
22
  import { getTransferSolInstruction } from "@solana-program/system";
22
23
  import { TOKEN_PROGRAM_ADDRESS, findAssociatedTokenPda, getCreateAssociatedTokenIdempotentInstruction, getTransferCheckedInstruction } from "@solana-program/token";
@@ -133,6 +134,1146 @@ function createMemoryEngineStore(backing) {
133
134
  };
134
135
  }
135
136
  //#endregion
137
+ //#region src/addresses/errors.ts
138
+ var AddressBookError = class extends Error {
139
+ category;
140
+ constructor(category, message) {
141
+ super(message);
142
+ this.name = "AddressBookError";
143
+ this.category = category;
144
+ }
145
+ };
146
+ //#endregion
147
+ //#region src/addresses/normalize.ts
148
+ /**
149
+ * The canonical form of `address` for its family — EIP-55 for EVM, the re-encoded form for bitcoin
150
+ * (accepted on any known bitcoin network, since a contact is not tied to the wallet's network) and
151
+ * Spark (identity key only, no invoice fields) — or `undefined` when it is not a valid address.
152
+ */
153
+ function normalizeAddress(family, address) {
154
+ try {
155
+ if (family === "evm") return isEvmAddress(address) ? checksumEvmAddress(address) : void 0;
156
+ if (family === "solana") return isSolanaAddress(address) ? address : void 0;
157
+ if (family === "spark") {
158
+ const decoded = decodeSparkAddress(address);
159
+ return encodeSparkAddress({
160
+ network: decoded.network,
161
+ identityPublicKey: decoded.identityPublicKey
162
+ });
163
+ }
164
+ for (const network of Object.values(BITCOIN_NETWORKS)) try {
165
+ const codec = Address(network.params);
166
+ return codec.encode(codec.decode(address));
167
+ } catch {}
168
+ return;
169
+ } catch {
170
+ return;
171
+ }
172
+ }
173
+ //#endregion
174
+ //#region src/addresses/record.ts
175
+ /**
176
+ * The address-book record shared by the controller, the sync merge and the metadata document: a
177
+ * live entry, or a tombstone that carries a deletion to the other devices.
178
+ */
179
+ const isTombstone = (record) => "deleted" in record;
180
+ const isFamily = (v) => v === "evm" || v === "solana" || v === "bitcoin" || v === "spark";
181
+ const ENTRY_KEYS = /* @__PURE__ */ new Set([
182
+ "id",
183
+ "name",
184
+ "family",
185
+ "address",
186
+ "note",
187
+ "createdAt",
188
+ "updatedAt"
189
+ ]);
190
+ const TOMBSTONE_KEYS = /* @__PURE__ */ new Set([
191
+ "id",
192
+ "deleted",
193
+ "updatedAt"
194
+ ]);
195
+ const isObject$12 = (v) => typeof v === "object" && v !== null && !Array.isArray(v);
196
+ const isNonNegativeInt$2 = (v) => typeof v === "number" && Number.isSafeInteger(v) && v >= 0;
197
+ /** Trimmed name, or `undefined` when it is not 1 to {@link NAME_MAX} characters. */
198
+ const cleanName = (raw) => {
199
+ const name = raw.trim();
200
+ return name.length > 0 && name.length <= 32 ? name : void 0;
201
+ };
202
+ /** Trimmed note; an empty one means no note. `undefined` return with `ok: false` when too long. */
203
+ const cleanNote = (raw) => {
204
+ const note = raw?.trim();
205
+ if (note === void 0 || note.length === 0) return {
206
+ ok: true,
207
+ note: void 0
208
+ };
209
+ return note.length <= 120 ? {
210
+ ok: true,
211
+ note
212
+ } : { ok: false };
213
+ };
214
+ const hasOnly = (value, keys) => Object.keys(value).every((k) => keys.has(k));
215
+ /**
216
+ * Strict decode with the controller's rules; the address is re-normalized. Anything else is
217
+ * `undefined`.
218
+ */
219
+ function decodeRecord(value) {
220
+ if (!isObject$12(value) || typeof value["id"] !== "string" || !isNonNegativeInt$2(value["updatedAt"])) return;
221
+ if ("deleted" in value) return value["deleted"] === true && hasOnly(value, TOMBSTONE_KEYS) ? {
222
+ id: value["id"],
223
+ deleted: true,
224
+ updatedAt: value["updatedAt"]
225
+ } : void 0;
226
+ const { family, name, address, note } = value;
227
+ if (!hasOnly(value, ENTRY_KEYS) || !isFamily(family) || typeof name !== "string" || typeof address !== "string" || !isNonNegativeInt$2(value["createdAt"]) || note !== void 0 && typeof note !== "string") return;
228
+ const cleanedName = cleanName(name);
229
+ const normalized = normalizeAddress(family, address);
230
+ const cleanedNote = cleanNote(note);
231
+ if (cleanedName === void 0 || normalized === void 0 || !cleanedNote.ok || note !== void 0 && cleanedNote.note === void 0) return;
232
+ return {
233
+ id: value["id"],
234
+ name: cleanedName,
235
+ family,
236
+ address: normalized,
237
+ ...cleanedNote.note === void 0 ? {} : { note: cleanedNote.note },
238
+ createdAt: value["createdAt"],
239
+ updatedAt: value["updatedAt"]
240
+ };
241
+ }
242
+ const serialized = (record) => JSON.stringify(record);
243
+ /** The winner of two versions of one id: larger `updatedAt`, then the larger serialization. */
244
+ function pickWinner(a, b) {
245
+ if (a.updatedAt !== b.updatedAt) return a.updatedAt > b.updatedAt ? a : b;
246
+ return serialized(a) >= serialized(b) ? a : b;
247
+ }
248
+ const pruneTombstones = (records, now) => records.filter((r) => !isTombstone(r) || now - r.updatedAt <= 7776e6);
249
+ /** Newest first, id as the tiebreak, so every device orders (and truncates) the same way. */
250
+ const newestFirst = (a, b) => b.updatedAt - a.updatedAt || (a.id < b.id ? -1 : 1);
251
+ /** Drops the oldest tombstones until at most `max` records remain, or no tombstone is left. */
252
+ function evictTombstones(records, max) {
253
+ const excess = records.length - max;
254
+ if (excess <= 0) return records;
255
+ 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));
256
+ return records.filter((r) => !oldest.has(r.id));
257
+ }
258
+ /** {@link mergeRecords}, also counting the live entries the cap forced out. */
259
+ function mergeWithReport(local, remote, now) {
260
+ const byId = /* @__PURE__ */ new Map();
261
+ for (const record of [...local, ...remote]) {
262
+ const existing = byId.get(record.id);
263
+ byId.set(record.id, existing === void 0 ? record : pickWinner(existing, record));
264
+ }
265
+ const live = /* @__PURE__ */ new Map();
266
+ const kept = [];
267
+ for (const record of [...pruneTombstones([...byId.values()], now)].sort(newestFirst)) {
268
+ if (isTombstone(record)) {
269
+ kept.push(record);
270
+ continue;
271
+ }
272
+ const key = `${record.family}:${record.address}`;
273
+ if (!live.has(key)) {
274
+ live.set(key, record);
275
+ kept.push(record);
276
+ }
277
+ }
278
+ const evicted = evictTombstones(kept, 128);
279
+ return {
280
+ records: [...evicted.slice(0, 128)].sort((a, b) => a.id < b.id ? -1 : 1),
281
+ droppedLive: evicted.slice(128).filter((r) => !isTombstone(r)).length
282
+ };
283
+ }
284
+ //#endregion
285
+ //#region src/addresses/controller.ts
286
+ /**
287
+ * Address book: named recipients, validated and normalized per chain family, persisted in the
288
+ * engine store and cleared from memory on lock. Deletions are kept as tombstones so they reach the
289
+ * other devices; `syncEntries`/`merge` are the whole surface the metadata sync needs. Request risk
290
+ * checks treat its addresses as known.
291
+ */
292
+ const COLLECTION$3 = "contacts";
293
+ const entriesOf = (records) => Object.fromEntries(records.flatMap((r) => isTombstone(r) ? [] : [[r.id, r]]));
294
+ var AddressBookController = class extends Controller {
295
+ options;
296
+ engineStore;
297
+ /** Every record, tombstones included, by id. */
298
+ records = /* @__PURE__ */ new Map();
299
+ constructor(options) {
300
+ super({
301
+ name: "address-book",
302
+ defaultState: () => ({ entries: {} })
303
+ });
304
+ this.options = options;
305
+ }
306
+ async init() {
307
+ const store = await this.options.openStore();
308
+ this.engineStore = store;
309
+ const collection = store.collection(COLLECTION$3);
310
+ const keys = await collection.list();
311
+ const rows = await Promise.all(keys.map(async (key) => collection.get(key)));
312
+ const records = /* @__PURE__ */ new Map();
313
+ for (const raw of rows) {
314
+ if (raw === void 0) continue;
315
+ const record = decodeRecord(JSON.parse(new TextDecoder().decode(raw)));
316
+ if (record === void 0) this.options.logger.warn("address book record skipped: malformed");
317
+ else records.set(record.id, record);
318
+ }
319
+ this.records = records;
320
+ this.update(() => ({ entries: entriesOf([...records.values()]) }));
321
+ }
322
+ async destroy() {
323
+ this.lock();
324
+ await Promise.resolve();
325
+ }
326
+ lock() {
327
+ this.engineStore = void 0;
328
+ this.records = /* @__PURE__ */ new Map();
329
+ this.update(() => ({ entries: {} }));
330
+ }
331
+ /** Entries and tombstones, for the metadata document. */
332
+ syncEntries() {
333
+ return [...this.records.values()].sort((a, b) => a.id < b.id ? -1 : 1);
334
+ }
335
+ /**
336
+ * Merges records from another device (last writer wins, tombstones win over older entries).
337
+ * Returns whether anything local changed and whether the merged set holds something `remote`
338
+ * lacks, i.e. whether the remote copy needs a newer publish.
339
+ */
340
+ async merge(remote) {
341
+ const store = this.requireStore();
342
+ const before = JSON.stringify(this.syncEntries());
343
+ const { records: merged, droppedLive } = mergeWithReport(this.syncEntries(), remote, this.options.now());
344
+ if (droppedLive > 0) this.options.logger.warn(`address book sync: ${droppedLive} contacts dropped, over the 128-record cap`);
345
+ const remoteSorted = JSON.stringify([...remote].sort((a, b) => a.id < b.id ? -1 : 1));
346
+ const localNewer = JSON.stringify(merged) !== remoteSorted;
347
+ if (JSON.stringify(merged) === before) return {
348
+ changed: false,
349
+ localNewer
350
+ };
351
+ const collection = store.collection(COLLECTION$3);
352
+ const next = new Map(merged.map((r) => [r.id, r]));
353
+ 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))]);
354
+ this.records = next;
355
+ this.update(() => ({ entries: entriesOf(merged) }));
356
+ return {
357
+ changed: true,
358
+ localNewer
359
+ };
360
+ }
361
+ async add(params) {
362
+ const store = this.requireStore();
363
+ const name = cleanName(params.name);
364
+ if (name === void 0) throw new AddressBookError("invalid-input", `name must be 1 to 32 characters`);
365
+ const note = this.checkedNote(params.note);
366
+ const address = normalizeAddress(params.family, params.address.trim());
367
+ if (address === void 0) throw new AddressBookError("invalid-input", `not a valid ${params.family} address`);
368
+ 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");
369
+ if (this.records.size >= 128) {
370
+ const kept = evictTombstones([...this.records.values()], 127);
371
+ if (kept.length >= 128) throw new AddressBookError("limit", `the address book holds at most 128 contacts`);
372
+ const keptIds = new Set(kept.map((r) => r.id));
373
+ await Promise.all([...this.records.keys()].filter((id) => !keptIds.has(id)).map(async (id) => store.collection(COLLECTION$3).delete(id)));
374
+ this.records = new Map(kept.map((r) => [r.id, r]));
375
+ }
376
+ const now = this.options.now();
377
+ const entry = {
378
+ id: `contact-${[...this.options.random(8)].map((b) => b.toString(16).padStart(2, "0")).join("")}`,
379
+ name,
380
+ family: params.family,
381
+ address,
382
+ ...note === void 0 ? {} : { note },
383
+ createdAt: now,
384
+ updatedAt: now
385
+ };
386
+ await this.persist(store, entry);
387
+ return entry;
388
+ }
389
+ async rename(id, name) {
390
+ const store = this.requireStore();
391
+ const existing = this.requireEntry(id);
392
+ const cleaned = cleanName(name);
393
+ if (cleaned === void 0) throw new AddressBookError("invalid-input", `name must be 1 to 32 characters`);
394
+ const entry = {
395
+ ...existing,
396
+ name: cleaned,
397
+ updatedAt: this.options.now()
398
+ };
399
+ await this.persist(store, entry);
400
+ return entry;
401
+ }
402
+ /** Sets or clears (empty or `undefined`) the note. */
403
+ async setNote(id, note) {
404
+ const store = this.requireStore();
405
+ const existing = this.requireEntry(id);
406
+ const cleaned = this.checkedNote(note);
407
+ const { note: _previous, ...rest } = existing;
408
+ const entry = {
409
+ ...rest,
410
+ ...cleaned === void 0 ? {} : { note: cleaned },
411
+ updatedAt: this.options.now()
412
+ };
413
+ await this.persist(store, entry);
414
+ return entry;
415
+ }
416
+ /** Removes the entry, leaving a tombstone so other devices drop it too. */
417
+ async remove(id) {
418
+ const store = this.requireStore();
419
+ this.requireEntry(id);
420
+ await this.persist(store, {
421
+ id,
422
+ deleted: true,
423
+ updatedAt: this.options.now()
424
+ });
425
+ }
426
+ checkedNote(note) {
427
+ const cleaned = cleanNote(note);
428
+ if (!cleaned.ok) throw new AddressBookError("invalid-input", `note must be at most 120 characters`);
429
+ return cleaned.note;
430
+ }
431
+ requireEntry(id) {
432
+ const entry = this.getState().entries[id];
433
+ if (entry === void 0) throw new AddressBookError("not-found", `unknown contact ${id}`);
434
+ return entry;
435
+ }
436
+ async persist(store, record) {
437
+ await store.collection(COLLECTION$3).put(record.id, new TextEncoder().encode(JSON.stringify(record)));
438
+ this.records.set(record.id, record);
439
+ this.update(() => ({ entries: entriesOf([...this.records.values()]) }));
440
+ }
441
+ requireStore() {
442
+ if (this.engineStore === void 0) throw new AddressBookError("locked", "the address book requires an unlocked vault");
443
+ return this.engineStore;
444
+ }
445
+ };
446
+ //#endregion
447
+ //#region src/spark/errors.ts
448
+ var SparkControllerError = class extends Error {
449
+ category;
450
+ constructor(category, message, cause) {
451
+ super(message, { cause });
452
+ this.name = "SparkControllerError";
453
+ this.category = category;
454
+ }
455
+ };
456
+ /** Maps a thrown client/kernel error into a category without leaking payloads. */
457
+ function categorize(error) {
458
+ const name = error instanceof Error ? error.name : "";
459
+ const message = error instanceof Error ? error.message : "";
460
+ if (/approval|denied|rejected/i.test(message)) return /required/i.test(message) ? "approval-required" : "rejected";
461
+ if (/insufficient|not enough|balance/i.test(message)) return "insufficient-funds";
462
+ if (name === "SparkRequestError" || /fetch|network|timeout|unavailable|closed/i.test(message)) return "network";
463
+ return "internal";
464
+ }
465
+ const feeCheck = (amountSats, maxFeeSats) => maxFeeSats !== void 0 && maxFeeSats >= amountSats ? ["fee-exceeds-amount"] : [];
466
+ //#endregion
467
+ //#region src/payments/errors.ts
468
+ var PaymentError = class extends Error {
469
+ category;
470
+ constructor(category, message, cause) {
471
+ super(message, { cause });
472
+ this.name = "PaymentError";
473
+ this.category = category;
474
+ }
475
+ };
476
+ //#endregion
477
+ //#region src/payments/pay.ts
478
+ const lightningDetails = (invoice) => {
479
+ const decoded = decodeBolt11(invoice);
480
+ return {
481
+ payee: bytesToHex(decoded.payee),
482
+ expiresAt: (decoded.timestamp + decoded.expirySeconds) * 1e3
483
+ };
484
+ };
485
+ /**
486
+ * Validates one parsed payment option and fixes everything the approval needs — including the
487
+ * LNURL-pay invoice, fetched here exactly once. Every input validation happens before the approval
488
+ * round, so a `PaymentError` here never consumed a verification. `lnurl-withdraw` receives funds
489
+ * and has its own entry, {@link withdrawLnurl}.
490
+ */
491
+ async function preparePay(context, params) {
492
+ const ref = params.account;
493
+ const { network } = ref;
494
+ if (params.option.kind === "lnurl-withdraw") throw new PaymentError("invalid-input", "lnurl-withdraw receives funds: use payments.withdrawLnurl");
495
+ switch (params.option.kind) {
496
+ case "spark": {
497
+ if (params.amountSats === void 0) throw new PaymentError("invalid-input", "spark payment: amountSats is required");
498
+ const { address } = params.option;
499
+ const { amountSats } = params;
500
+ return {
501
+ display: {
502
+ family: "spark",
503
+ network,
504
+ kind: "transfer",
505
+ amountSats,
506
+ receiver: address
507
+ },
508
+ checks: [],
509
+ staleReason: () => void 0,
510
+ execute: async (spark, verify) => ({
511
+ kind: "spark",
512
+ transfer: await spark.send(ref, {
513
+ receiver: address,
514
+ amountSats,
515
+ verify
516
+ })
517
+ })
518
+ };
519
+ }
520
+ case "spark-invoice": {
521
+ const { option } = params;
522
+ if (option.expiresAt !== void 0 && option.expiresAt <= context.now()) throw new PaymentError("rejected", "spark invoice expired");
523
+ 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");
524
+ const amountSats = option.amountSats ?? params.amountSats;
525
+ if (amountSats === void 0) throw new PaymentError("invalid-input", "spark invoice: amountless invoice requires amountSats");
526
+ const openAmount = option.amountSats === void 0 ? params.amountSats : void 0;
527
+ const parsed = parseSparkInvoice(option.invoice, {
528
+ network,
529
+ now: context.now()
530
+ });
531
+ const { expiresAt } = option;
532
+ return {
533
+ display: {
534
+ family: "spark",
535
+ network,
536
+ kind: "transfer",
537
+ amountSats,
538
+ receiver: encodeSparkAddress({
539
+ network,
540
+ identityPublicKey: parsed.receiverIdentityPublicKey
541
+ }),
542
+ ...option.memo === void 0 ? {} : { description: option.memo }
543
+ },
544
+ checks: [],
545
+ staleReason: (now) => expiresAt !== void 0 && expiresAt <= now ? "spark invoice expired" : void 0,
546
+ execute: async (spark, verify) => ({
547
+ kind: "spark",
548
+ transfer: await spark.paySparkInvoice(ref, {
549
+ invoice: option.invoice,
550
+ ...openAmount === void 0 ? {} : { amountSats: openAmount },
551
+ verify
552
+ })
553
+ })
554
+ };
555
+ }
556
+ case "lightning": {
557
+ const { option } = params;
558
+ if (option.amountSats === void 0 && params.amountSats === void 0) throw new PaymentError("invalid-input", "amountless invoice: amountSats is required");
559
+ if (params.maxFeeSats === void 0) throw new PaymentError("invalid-input", "lightning payment: maxFeeSats is required");
560
+ if (option.expiresAt <= context.now() + 6e4) throw new PaymentError("rejected", "lightning invoice expired");
561
+ if (option.amountSats !== void 0 && params.amountSats !== void 0 && params.amountSats !== option.amountSats) throw new PaymentError("invalid-input", "amountSats conflicts with the invoice amount");
562
+ const { maxFeeSats } = params;
563
+ const openAmount = option.amountSats === void 0 ? params.amountSats : void 0;
564
+ const amountSats = option.amountSats ?? params.amountSats ?? 0;
565
+ const { payee } = lightningDetails(option.invoice);
566
+ const { expiresAt } = option;
567
+ return {
568
+ display: {
569
+ family: "spark",
570
+ network,
571
+ kind: "lightning",
572
+ amountSats,
573
+ maxFeeSats,
574
+ receiver: payee,
575
+ ...option.description === void 0 ? {} : { description: option.description }
576
+ },
577
+ checks: feeCheck(amountSats, maxFeeSats),
578
+ staleReason: (now) => expiresAt <= now + 6e4 ? "lightning invoice expired" : void 0,
579
+ execute: async (spark, verify) => ({
580
+ kind: "lightning",
581
+ payment: await spark.payInvoice(ref, {
582
+ invoice: option.invoice,
583
+ maxFeeSats,
584
+ ...openAmount === void 0 ? {} : { amountSats: openAmount },
585
+ verify
586
+ })
587
+ })
588
+ };
589
+ }
590
+ case "lnurl-pay": {
591
+ const { request } = params.option;
592
+ if (params.amountSats === void 0) throw new PaymentError("invalid-input", "lnurl-pay: amountSats is required");
593
+ if (params.maxFeeSats === void 0) throw new PaymentError("invalid-input", "lnurl-pay: maxFeeSats is required");
594
+ const { amountSats, maxFeeSats } = params;
595
+ const amountMsat = amountSats * 1e3;
596
+ if (amountMsat < request.minSendableMsat || amountMsat > request.maxSendableMsat) throw new PaymentError("rejected", "lnurl-pay: amount outside the service's range");
597
+ if (request.commentAllowed === 0 && params.comment !== void 0) throw new PaymentError("rejected", "lnurl-pay: comments not accepted by this service");
598
+ if (params.comment !== void 0 && params.comment.length > request.commentAllowed) throw new PaymentError("rejected", "lnurl-pay: comment exceeds the service's limit");
599
+ let requested;
600
+ try {
601
+ requested = await requestInvoice(request, {
602
+ fetch: context.fetch,
603
+ network,
604
+ amountMsat,
605
+ ...params.comment === void 0 ? {} : { comment: params.comment }
606
+ });
607
+ } catch (error) {
608
+ if (error instanceof PaymentError || error instanceof SparkControllerError) throw error;
609
+ throw new PaymentError("network", "lnurl-pay: invoice request failed", error);
610
+ }
611
+ const { invoice } = requested;
612
+ const { payee, expiresAt } = lightningDetails(invoice);
613
+ return {
614
+ display: {
615
+ family: "spark",
616
+ network,
617
+ kind: "lightning",
618
+ amountSats,
619
+ maxFeeSats,
620
+ receiver: payee,
621
+ description: request.description
622
+ },
623
+ checks: feeCheck(amountSats, maxFeeSats),
624
+ staleReason: (now) => expiresAt <= now + 6e4 ? "lightning invoice expired" : void 0,
625
+ execute: async (spark, verify) => ({
626
+ kind: "lightning",
627
+ payment: await spark.payInvoice(ref, {
628
+ invoice,
629
+ maxFeeSats,
630
+ verify
631
+ })
632
+ })
633
+ };
634
+ }
635
+ default: {
636
+ if (params.amountSats === void 0) throw new PaymentError("invalid-input", "bitcoin payment: amountSats is required");
637
+ const { address } = params.option;
638
+ const { amountSats, maxFeeSats } = params;
639
+ const speed = params.speed ?? "MEDIUM";
640
+ return {
641
+ display: {
642
+ family: "spark",
643
+ network,
644
+ kind: "exit",
645
+ amountSats,
646
+ ...maxFeeSats === void 0 ? {} : { maxFeeSats },
647
+ receiver: address
648
+ },
649
+ checks: feeCheck(amountSats, maxFeeSats),
650
+ staleReason: () => void 0,
651
+ execute: async (spark, verify) => ({
652
+ kind: "withdraw",
653
+ exit: await spark.withdraw(ref, {
654
+ address,
655
+ speed,
656
+ verify,
657
+ amountSats,
658
+ ...maxFeeSats === void 0 ? {} : { maxFeeSats }
659
+ })
660
+ })
661
+ };
662
+ }
663
+ }
664
+ }
665
+ /**
666
+ * LNURL-withdraw: asks the service to pay an invoice this wallet creates. It receives funds, so it
667
+ * needs no verification factor and does not go through the request pipeline.
668
+ */
669
+ async function withdrawLnurl(context, params) {
670
+ const { request } = params.option;
671
+ const amountMsat = params.amountSats * 1e3;
672
+ if (amountMsat < request.minWithdrawableMsat || amountMsat > request.maxWithdrawableMsat) throw new PaymentError("rejected", "lnurl-withdraw: amount outside the service's range");
673
+ const invoice = await context.spark.createInvoice(params.account, {
674
+ amountSats: params.amountSats,
675
+ memo: request.defaultDescription
676
+ });
677
+ try {
678
+ await submitWithdraw(request, {
679
+ fetch: context.fetch,
680
+ invoice: invoice.invoice
681
+ });
682
+ } catch (error) {
683
+ if (error instanceof PaymentError || error instanceof SparkControllerError) throw error;
684
+ throw new PaymentError("network", "lnurl-withdraw: submission failed", error);
685
+ }
686
+ return {
687
+ kind: "lnurl-withdraw",
688
+ invoice
689
+ };
690
+ }
691
+ //#endregion
692
+ //#region src/payments/zap.ts
693
+ /** Everything up to payment: resolve LNURL, sign the 9734, request the invoice. */
694
+ async function prepareZap(params, context) {
695
+ const amountMsat = params.amountSats * 1e3;
696
+ if (!Number.isSafeInteger(params.amountSats) || params.amountSats <= 0 || !Number.isSafeInteger(amountMsat)) throw new PaymentError("invalid-input", "zap amount is not a positive safe integer");
697
+ try {
698
+ const resolved = await resolveLnurl(params.recipient.lightning, { fetch: context.fetch });
699
+ if (resolved.tag !== "payRequest") throw new PaymentError("rejected", "recipient is not an LNURL-pay endpoint");
700
+ if (!resolved.allowsNostr || resolved.nostrPubkey === void 0) throw new PaymentError("rejected", "LNURL service does not accept nostr zaps");
701
+ if (amountMsat < resolved.minSendableMsat || amountMsat > resolved.maxSendableMsat) throw new PaymentError("rejected", "zap amount outside the service's bounds");
702
+ if (params.comment !== void 0) {
703
+ if (resolved.commentAllowed === 0) throw new PaymentError("rejected", "the service does not accept comments");
704
+ if (params.comment.length > resolved.commentAllowed) throw new PaymentError("rejected", "comment exceeds the service's limit");
705
+ }
706
+ const lnurl = params.recipient.lightning.toLowerCase().startsWith("lnurl1") ? params.recipient.lightning.toLowerCase() : encodeLnurl(lightningAddressUrl(params.recipient.lightning));
707
+ const base = {
708
+ amount: amountMsat,
709
+ relays: [...params.relays],
710
+ comment: params.comment,
711
+ lnurl
712
+ };
713
+ const unsigned = {
714
+ ..."event" in params.recipient ? makeZapRequest({
715
+ event: params.recipient.event,
716
+ ...base
717
+ }) : makeZapRequest({
718
+ pubkey: params.recipient.pubkey,
719
+ ...base
720
+ }),
721
+ pubkey: await params.signer.getPublicKey()
722
+ };
723
+ const zapRequest = await params.signer.signEvent(unsigned);
724
+ if (!verifyEvent(zapRequest) || !signedMatchesUnsigned(zapRequest, unsigned)) throw new PaymentError("rejected", "signer returned a zap request that does not match");
725
+ const { invoice } = await requestInvoice(resolved, {
726
+ fetch: context.fetch,
727
+ network: params.network,
728
+ amountMsat,
729
+ ...params.comment === void 0 ? {} : { comment: params.comment },
730
+ zapRequest: JSON.stringify(zapRequest)
731
+ });
732
+ const decoded = decodeBolt11(invoice);
733
+ const expiresAt = (decoded.timestamp + decoded.expirySeconds) * 1e3;
734
+ if (expiresAt <= context.now() + 6e4) throw new PaymentError("rejected", "zap invoice expires too soon");
735
+ const eventId = "event" in params.recipient ? params.recipient.event.id : void 0;
736
+ return {
737
+ zapRequest,
738
+ invoice,
739
+ paymentHash: bytesToHex(decoded.paymentHash),
740
+ expiresAt,
741
+ amountSats: params.amountSats,
742
+ receiptPubkey: bytesToHex(resolved.nostrPubkey),
743
+ recipientPubkey: "event" in params.recipient ? params.recipient.event.pubkey : params.recipient.pubkey,
744
+ ...eventId === void 0 ? {} : { eventId },
745
+ ...params.comment === void 0 ? {} : { comment: params.comment },
746
+ lightning: params.recipient.lightning
747
+ };
748
+ } catch (error) {
749
+ if (error instanceof PaymentError) throw error;
750
+ if (error instanceof SparkInputError || error instanceof Error && error.cause instanceof SparkInputError) throw new PaymentError("rejected", "zap failed invoice validation");
751
+ throw new PaymentError(categorizePayment(error), "zap failed");
752
+ }
753
+ }
754
+ /**
755
+ * Fixes a prepared zap for approval: the invoice (fetched by {@link prepareZap}) is paid as-is, and
756
+ * the zap note is written under the payment's transfer id afterwards. A nearly-expired invoice is
757
+ * refused here and again at approval — the user may sit on the confirm screen.
758
+ */
759
+ function prepareZapAction(account, prepared, options) {
760
+ if (prepared.expiresAt <= options.now() + 6e4) throw new PaymentError("rejected", "zap invoice expired");
761
+ const { maxFeeSats } = options;
762
+ const { payee } = lightningInvoiceDetails(prepared.invoice);
763
+ return {
764
+ display: {
765
+ family: "spark",
766
+ network: account.network,
767
+ kind: "lightning",
768
+ amountSats: prepared.amountSats,
769
+ maxFeeSats,
770
+ receiver: payee,
771
+ ...prepared.comment === void 0 ? {} : { description: prepared.comment }
772
+ },
773
+ checks: feeCheck(prepared.amountSats, maxFeeSats),
774
+ staleReason: (now) => prepared.expiresAt <= now + 6e4 ? "zap invoice expired" : void 0,
775
+ execute: async (spark, verify) => ({
776
+ kind: "lightning",
777
+ payment: await spark.payInvoice(account, {
778
+ invoice: prepared.invoice,
779
+ maxFeeSats,
780
+ verify,
781
+ note: {
782
+ kind: "zap",
783
+ recipientPubkey: prepared.recipientPubkey,
784
+ ...prepared.eventId === void 0 ? {} : { eventId: prepared.eventId },
785
+ ...prepared.comment === void 0 ? {} : { comment: prepared.comment },
786
+ lightning: prepared.lightning
787
+ }
788
+ })
789
+ })
790
+ };
791
+ }
792
+ const lightningInvoiceDetails = (invoice) => ({ payee: bytesToHex(decodeBolt11(invoice).payee) });
793
+ function categorizePayment(error) {
794
+ return categorize(error) === "network" ? "network" : "rejected";
795
+ }
796
+ //#endregion
797
+ //#region src/transactions/route.ts
798
+ const hexId = (prefix, random) => `${prefix}-${[...random(8)].map((b) => b.toString(16).padStart(2, "0")).join("")}`;
799
+ const newId = (random) => hexId("tx", random);
800
+ const newRequestId = (random) => hexId("req", random);
801
+ const errorMessage$1 = (error) => error instanceof Error ? error.message : String(error);
802
+ const LIVE_REPLACEMENT_STATES = /* @__PURE__ */ new Set([
803
+ "draft",
804
+ "signed",
805
+ "submitted",
806
+ "pending"
807
+ ]);
808
+ /**
809
+ * Approval-time check for a replacement: the original must still be unconfirmed and no other
810
+ * replacement of it may already be on its way.
811
+ */
812
+ const replacementStaleReason = (ctx, originalId) => {
813
+ const txs = ctx.txs();
814
+ const original = txs[originalId];
815
+ if (original?.state !== "submitted" && original?.state !== "pending") return "original transaction already finished — nothing to replace";
816
+ return Object.values(txs).some((t) => t.replaces === originalId && LIVE_REPLACEMENT_STATES.has(t.state)) ? "original transaction was already replaced — start over" : void 0;
817
+ };
818
+ //#endregion
819
+ //#region src/requests/checks.ts
820
+ /** Two reads of one fee differ by more than this share of the smaller one. */
821
+ const FEE_MISMATCH_PERCENT = 25n;
822
+ /** A fee ceiling above this multiple of the independent estimate is flagged. */
823
+ const FEE_CAP_MULTIPLIER = 2n;
824
+ /**
825
+ * `|a − b| / min(a, b)` is strictly above {@link FEE_MISMATCH_PERCENT}; a zero against a non-zero
826
+ * always is.
827
+ */
828
+ const feesDiffer = (a, b) => {
829
+ const low = a < b ? a : b;
830
+ return ((a < b ? b : a) - low) * 100n > low * FEE_MISMATCH_PERCENT;
831
+ };
832
+ /**
833
+ * The final ceiling exceeds {@link FEE_CAP_MULTIPLIER} times the independent estimate of the same
834
+ * tier.
835
+ */
836
+ const exceedsNetworkCap = (final, independent) => final > independent * FEE_CAP_MULTIPLIER;
837
+ /** The worst-case fee is not below the amount sent (native-asset sends only). */
838
+ const feeReachesAmount = (maxFee, amount) => maxFee >= amount;
839
+ const min = (a, b) => a < b ? a : b;
840
+ const max = (a, b) => a > b ? a : b;
841
+ /** EIP-1559 quotes are `maxFee = baseFee × 2 + priority`; this recovers the base fee. */
842
+ const baseFeeOf = (quote) => (quote.maxFeePerGas - (quote.maxPriorityFeePerGas ?? 0n)) / 2n;
843
+ /**
844
+ * EIP-1559: priority fee is the lower of the two, `maxFeePerGas = max(baseFee) × 2 + priority` (a
845
+ * high ceiling only prevents a stuck transaction, the base fee actually paid is the chain's).
846
+ * Legacy: the lower gas price. Two different shapes cannot be merged — the independent quote wins.
847
+ */
848
+ const mergeEvmQuote = (primary, independent) => {
849
+ const a = primary.maxPriorityFeePerGas;
850
+ const b = independent.maxPriorityFeePerGas;
851
+ if (a !== void 0 && b !== void 0) {
852
+ const priority = min(a, b);
853
+ const maxFeePerGas = max(baseFeeOf(primary), baseFeeOf(independent)) * 2n + priority;
854
+ return {
855
+ quote: {
856
+ tier: primary.tier,
857
+ wei: maxFeePerGas,
858
+ maxFeePerGas,
859
+ maxPriorityFeePerGas: priority
860
+ },
861
+ mismatch: feesDiffer(a, b) || feesDiffer(baseFeeOf(primary), baseFeeOf(independent))
862
+ };
863
+ }
864
+ if (a === void 0 && b === void 0) {
865
+ const price = min(primary.maxFeePerGas, independent.maxFeePerGas);
866
+ return {
867
+ quote: {
868
+ tier: primary.tier,
869
+ wei: price,
870
+ maxFeePerGas: price,
871
+ maxPriorityFeePerGas: void 0
872
+ },
873
+ mismatch: feesDiffer(primary.maxFeePerGas, independent.maxFeePerGas)
874
+ };
875
+ }
876
+ return {
877
+ quote: independent,
878
+ mismatch: true
879
+ };
880
+ };
881
+ const mergeSolanaQuote = (primary, independent) => ({
882
+ quote: {
883
+ tier: primary.tier,
884
+ microLamportsPerCu: min(primary.microLamportsPerCu, independent.microLamportsPerCu)
885
+ },
886
+ mismatch: feesDiffer(primary.microLamportsPerCu, independent.microLamportsPerCu)
887
+ });
888
+ /** Bitcoin fee rates (sat/vB) per confirmation target. */
889
+ const mergeSatPerByte = (primary, independent) => ({
890
+ rate: Math.min(primary, independent),
891
+ mismatch: feesDiffer(BigInt(Math.ceil(primary)), BigInt(Math.ceil(independent)))
892
+ });
893
+ /** The checks a prepared build carries, in a stable order and without duplicates. */
894
+ const collectChecks = (flags) => [...new Set(flags.filter((f) => f !== void 0))];
895
+ /** EVM addresses compare without the `0x` prefix and case-insensitively. */
896
+ const comparable = (family, address) => family === "evm" ? address.replace(/^0x/i, "").toLowerCase() : address;
897
+ 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);
898
+ /** Findings for every recipient of one request, deduplicated, `danger` first. */
899
+ function assessRecipients(family, recipients, known) {
900
+ const knownSet = new Set(known.map((a) => comparable(family, a)));
901
+ const found = /* @__PURE__ */ new Map();
902
+ for (const recipient of recipients) {
903
+ const candidate = comparable(family, recipient);
904
+ if (knownSet.has(candidate)) continue;
905
+ found.set("first-recipient", {
906
+ code: "first-recipient",
907
+ level: "warning"
908
+ });
909
+ if ([...knownSet].some((k) => looksLike(candidate, k))) found.set("address-poisoning", {
910
+ code: "address-poisoning",
911
+ level: "danger"
912
+ });
913
+ }
914
+ return [...found.values()].sort((a, b) => Number(b.level === "danger") - Number(a.level === "danger"));
915
+ }
916
+ //#endregion
917
+ //#region src/requests/sign-log.ts
918
+ const SIGN_LOG_LIMIT = 1e3;
919
+ const COLLECTION$2 = "sign-log";
920
+ const isObject$11 = (v) => typeof v === "object" && v !== null;
921
+ const METHODS = /* @__PURE__ */ new Set([
922
+ "send",
923
+ "speed-up",
924
+ "cancel",
925
+ "cpfp",
926
+ "pay",
927
+ "zap"
928
+ ]);
929
+ const OUTCOMES = /* @__PURE__ */ new Set([
930
+ "submitted",
931
+ "rejected",
932
+ "failed",
933
+ "expired",
934
+ "invalidated"
935
+ ]);
936
+ const FAMILIES = /* @__PURE__ */ new Set([
937
+ "evm",
938
+ "solana",
939
+ "bitcoin"
940
+ ]);
941
+ const isAccount = (value) => {
942
+ if (!isObject$11(value)) return false;
943
+ if (value["kind"] === "hd") return typeof value["walletId"] === "string" && typeof value["groupIndex"] === "number" && typeof value["template"] === "string";
944
+ if (value["kind"] === "imported") return typeof value["keyId"] === "string" && FAMILIES.has(value["family"]);
945
+ return value["kind"] === void 0 && typeof value["walletId"] === "string" && typeof value["groupIndex"] === "number" && typeof value["network"] === "string";
946
+ };
947
+ const isEntry = (value) => isObject$11(value) && typeof value["id"] === "string" && typeof value["at"] === "number" && METHODS.has(value["method"]) && OUTCOMES.has(value["outcome"]) && isAccount(value["account"]);
948
+ /** Sortable key: time first, request id as the tiebreak. */
949
+ const keyOf = (entry) => `${String(entry.at).padStart(16, "0")}-${entry.id}`;
950
+ function createSignLog(logger) {
951
+ let store;
952
+ return {
953
+ open: (next) => {
954
+ store = next;
955
+ },
956
+ close: () => {
957
+ store = void 0;
958
+ },
959
+ append: async (entry) => {
960
+ const current = store;
961
+ if (current === void 0) {
962
+ logger.warn(`sign log entry for request ${entry.id} (${entry.outcome}) not written: vault locked`);
963
+ return;
964
+ }
965
+ const collection = current.collection(COLLECTION$2);
966
+ await collection.put(keyOf(entry), new TextEncoder().encode(JSON.stringify(entry)));
967
+ const keys = [...await collection.list()].sort();
968
+ await Promise.all(keys.slice(0, Math.max(0, keys.length - SIGN_LOG_LIMIT)).map(async (k) => collection.delete(k)));
969
+ },
970
+ list: async () => {
971
+ if (store === void 0) return [];
972
+ const collection = store.collection(COLLECTION$2);
973
+ const keys = [...await collection.list()].sort();
974
+ const rows = await Promise.all(keys.map(async (k) => collection.get(k)));
975
+ const entries = [];
976
+ for (const raw of rows) {
977
+ if (raw === void 0) continue;
978
+ const parsed = JSON.parse(new TextDecoder().decode(raw));
979
+ if (isEntry(parsed)) entries.push(parsed);
980
+ }
981
+ return entries.reverse();
982
+ },
983
+ clear: async () => {
984
+ if (store === void 0) return;
985
+ const collection = store.collection(COLLECTION$2);
986
+ const keys = [...await collection.list()].sort();
987
+ await Promise.all(keys.map(async (k) => collection.delete(k)));
988
+ }
989
+ };
990
+ }
991
+ //#endregion
992
+ //#region src/requests/controller.ts
993
+ /**
994
+ * Request pipeline for internal sends, speed-ups, cancellations and CPFP: `prepare` builds,
995
+ * estimates and cross-checks without signing; `approve(id, method, verify)` is the only way to
996
+ * sign, and only for a live, unexpired request of the right method; locking invalidates everything
997
+ * that has not finished. The unsigned content stays in a private table, never in the state
998
+ * snapshot, and cannot change after prepare.
999
+ */
1000
+ /** Requests expire this long after creation. */
1001
+ const REQUEST_TTL_MS = 6e5;
1002
+ const isSparkInput = (input) => input.method === "pay" || input.method === "zap";
1003
+ const LIVE = /* @__PURE__ */ new Set(["awaiting-approval", "signing"]);
1004
+ const errorMessage = (error) => error instanceof Error ? error.message : String(error);
1005
+ var RequestController = class extends Controller {
1006
+ options;
1007
+ entries = /* @__PURE__ */ new Map();
1008
+ log;
1009
+ /** Original transaction id → the unfinished request replacing or accelerating it. */
1010
+ targets = /* @__PURE__ */ new Map();
1011
+ locked = true;
1012
+ /** Bumped on every unlock and lock so work that outlives a session never touches the next one. */
1013
+ session = 0;
1014
+ constructor(options) {
1015
+ super({
1016
+ name: "requests",
1017
+ defaultState: () => ({ requests: {} })
1018
+ });
1019
+ this.options = options;
1020
+ this.log = createSignLog(options.logger);
1021
+ }
1022
+ async init() {
1023
+ this.session += 1;
1024
+ this.locked = false;
1025
+ this.entries.clear();
1026
+ this.targets.clear();
1027
+ this.update(() => ({ requests: {} }));
1028
+ this.log.open(await this.options.openStore());
1029
+ }
1030
+ async destroy() {
1031
+ this.lock();
1032
+ await Promise.resolve();
1033
+ }
1034
+ /** Locking is not consent: every unfinished request becomes `invalidated` and frees its resources. */
1035
+ lock() {
1036
+ this.session += 1;
1037
+ this.locked = true;
1038
+ for (const [id, request] of Object.entries(this.getState().requests)) if (LIVE.has(request.stage)) this.finish(id, "invalidated");
1039
+ this.log.close();
1040
+ }
1041
+ /** The signature record, newest first. */
1042
+ async signLog() {
1043
+ return this.log.list();
1044
+ }
1045
+ async clearSignLog() {
1046
+ await this.log.clear();
1047
+ }
1048
+ async prepare(input) {
1049
+ if (this.locked) throw new Error("requests require an unlocked vault");
1050
+ const { session } = this;
1051
+ const id = newRequestId(this.options.random);
1052
+ const target = input.method === "send" || isSparkInput(input) ? void 0 : input.txId;
1053
+ if (target !== void 0) {
1054
+ if (this.targets.has(target)) throw new Error(`transaction ${target} already has an unfinished replacement request`);
1055
+ this.targets.set(target, id);
1056
+ }
1057
+ let built;
1058
+ try {
1059
+ built = await this.build(input, id);
1060
+ } catch (error) {
1061
+ this.unclaim(id);
1062
+ throw error;
1063
+ }
1064
+ if (session !== this.session) {
1065
+ built.run.release();
1066
+ this.unclaim(id);
1067
+ throw new Error("the vault was locked while the request was being prepared");
1068
+ }
1069
+ const { checks } = built;
1070
+ const createdAt = this.options.now();
1071
+ const request = {
1072
+ id,
1073
+ origin: { kind: "internal" },
1074
+ method: input.method,
1075
+ account: built.account,
1076
+ network: built.network,
1077
+ ...target === void 0 ? {} : { target },
1078
+ stage: "awaiting-approval",
1079
+ display: built.display,
1080
+ verification: checks.length > 0 ? "unverifiable" : "verified",
1081
+ checks,
1082
+ risk: built.risk,
1083
+ createdAt,
1084
+ expiresAt: createdAt + REQUEST_TTL_MS
1085
+ };
1086
+ const stopTimer = this.options.setTimer(REQUEST_TTL_MS, () => {
1087
+ if (this.getState().requests[id]?.stage === "awaiting-approval") this.finish(id, "expired");
1088
+ });
1089
+ this.entries.set(id, {
1090
+ prepared: built.run,
1091
+ stopTimer
1092
+ });
1093
+ this.put(request);
1094
+ return request;
1095
+ }
1096
+ async build(input, id) {
1097
+ if (input.method === "pay" || input.method === "zap") {
1098
+ const action = input.method === "pay" ? await preparePay({
1099
+ fetch: this.options.fetch,
1100
+ now: this.options.now
1101
+ }, input) : prepareZapAction(input.account, input.zap, {
1102
+ maxFeeSats: input.maxFeeSats,
1103
+ now: this.options.now
1104
+ });
1105
+ return {
1106
+ account: input.account,
1107
+ network: action.display.network,
1108
+ display: action.display,
1109
+ checks: collectChecks(action.checks),
1110
+ risk: this.sparkRisk(action.display),
1111
+ run: {
1112
+ staleReason: () => action.staleReason(this.options.now()),
1113
+ submit: async (verify) => action.execute(this.options.spark, verify),
1114
+ release: () => void 0
1115
+ }
1116
+ };
1117
+ }
1118
+ const prepared = input.method === "send" ? await this.options.transactions.prepareSend(input, id) : await this.options.transactions.prepareReplacement(input, id);
1119
+ return {
1120
+ account: prepared.account,
1121
+ network: prepared.network,
1122
+ display: prepared.intent,
1123
+ checks: collectChecks([prepared.intent.verifiability === "full" ? void 0 : "decode-partial", ...prepared.checks]),
1124
+ risk: input.method === "send" ? this.chainRisk(prepared.intent) : [],
1125
+ run: prepared
1126
+ };
1127
+ }
1128
+ async approve(id, method, verify) {
1129
+ const request = this.getState().requests[id];
1130
+ if (request === void 0) throw new Error(`unknown request ${id}`);
1131
+ if (request.method !== method) throw new Error(`request ${id} is a ${request.method} request, not ${method}`);
1132
+ const entry = this.entries.get(id);
1133
+ if (request.stage !== "awaiting-approval" || entry === void 0) throw new Error(`request ${id} is ${request.stage}, not awaiting approval`);
1134
+ if (this.options.now() >= request.expiresAt) {
1135
+ this.finish(id, "expired");
1136
+ throw new Error(`request ${id} expired`);
1137
+ }
1138
+ const { session } = this;
1139
+ this.put({
1140
+ ...request,
1141
+ stage: "signing"
1142
+ });
1143
+ let stale;
1144
+ try {
1145
+ stale = await entry.prepared.staleReason();
1146
+ } catch (error) {
1147
+ stale = errorMessage(error);
1148
+ }
1149
+ if (session !== this.session) throw new Error(`request ${id} was invalidated`);
1150
+ if (stale !== void 0) {
1151
+ this.finish(id, "failed", stale);
1152
+ throw new Error(stale);
1153
+ }
1154
+ try {
1155
+ const result = await entry.prepared.submit(verify);
1156
+ if (session === this.session) {
1157
+ entry.stopTimer();
1158
+ this.entries.delete(id);
1159
+ this.unclaim(id);
1160
+ const done = {
1161
+ ...this.requireRequest(id),
1162
+ stage: "submitted",
1163
+ ..."id" in result ? { txId: result.id } : {}
1164
+ };
1165
+ this.put(done);
1166
+ this.record(done, "submitted");
1167
+ } else this.record(request, "submitted");
1168
+ return result;
1169
+ } catch (error) {
1170
+ if (session === this.session) this.finish(id, this.failureStage(entry.prepared, error), errorMessage(error));
1171
+ else this.record(request, "failed", errorMessage(error));
1172
+ throw error;
1173
+ }
1174
+ }
1175
+ /**
1176
+ * A cancelled or refused verification is `rejected`; anything after it is `failed`. Chain
1177
+ * requests record which step failed on the tracked transaction; for Spark, the approval round is
1178
+ * the only source of `WalletError`.
1179
+ */
1180
+ failureStage(run, error) {
1181
+ if (run.txId === void 0) return error instanceof WalletError ? "rejected" : "failed";
1182
+ return this.options.transactions.getState().txs[run.txId]?.state === "rejected" ? "rejected" : "failed";
1183
+ }
1184
+ reject(id) {
1185
+ const request = this.getState().requests[id];
1186
+ if (request === void 0) throw new Error(`unknown request ${id}`);
1187
+ if (request.stage !== "awaiting-approval") throw new Error(`request ${id} is ${request.stage}, not awaiting approval`);
1188
+ this.finish(id, "rejected");
1189
+ }
1190
+ requireRequest(id) {
1191
+ const request = this.getState().requests[id];
1192
+ if (request === void 0) throw new Error(`unknown request ${id}`);
1193
+ return request;
1194
+ }
1195
+ put(request) {
1196
+ this.update((s) => ({
1197
+ ...s,
1198
+ requests: {
1199
+ ...s.requests,
1200
+ [request.id]: request
1201
+ }
1202
+ }));
1203
+ }
1204
+ unclaim(requestId) {
1205
+ for (const [target, holder] of this.targets) if (holder === requestId) this.targets.delete(target);
1206
+ }
1207
+ /** Ends a request in a terminal stage and frees what it reserved. */
1208
+ finish(id, stage, error) {
1209
+ this.unclaim(id);
1210
+ const entry = this.entries.get(id);
1211
+ if (entry !== void 0) {
1212
+ entry.stopTimer();
1213
+ this.entries.delete(id);
1214
+ try {
1215
+ entry.prepared.release();
1216
+ } catch (releaseError) {
1217
+ this.options.logger.warn("request release failed", releaseError);
1218
+ }
1219
+ }
1220
+ const request = this.getState().requests[id];
1221
+ if (request !== void 0) {
1222
+ const ended = {
1223
+ ...request,
1224
+ stage,
1225
+ ...error === void 0 ? {} : { error }
1226
+ };
1227
+ this.put(ended);
1228
+ this.record(ended, stage, error);
1229
+ }
1230
+ }
1231
+ /** Appends the final stage to the signature record; a failed write is a warning, not an error. */
1232
+ record(request, outcome, error) {
1233
+ const { display } = request;
1234
+ const entry = {
1235
+ id: request.id,
1236
+ at: this.options.now(),
1237
+ origin: request.origin,
1238
+ method: request.method,
1239
+ account: request.account,
1240
+ network: request.network,
1241
+ summary: "summary" in display ? display.summary : `${display.kind} ${display.amountSats}`,
1242
+ verification: request.verification,
1243
+ checks: request.checks,
1244
+ risk: request.risk,
1245
+ outcome,
1246
+ ...error === void 0 ? {} : { error }
1247
+ };
1248
+ this.appendLog(entry);
1249
+ }
1250
+ async appendLog(entry) {
1251
+ try {
1252
+ await this.log.append(entry);
1253
+ } catch (error) {
1254
+ this.options.logger.warn("sign log write failed", error);
1255
+ }
1256
+ }
1257
+ /**
1258
+ * Addresses the wallet already knows for a family: contacts, plus recipients of tracked chain
1259
+ * sends (Spark payments are not tracked).
1260
+ */
1261
+ knownAddresses(family) {
1262
+ const contacts = Object.values(this.options.addressBook.getState().entries).filter((e) => e.family === family).map((e) => e.address);
1263
+ if (family === "spark") return contacts;
1264
+ 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) ?? []);
1265
+ return [...contacts, ...paid];
1266
+ }
1267
+ chainRisk(intent) {
1268
+ return assessRecipients(intent.family, intent.outflows.map((o) => o.to), this.knownAddresses(intent.family));
1269
+ }
1270
+ /** Only a Spark transfer has a recipient to check; Lightning and exits are not assessed. */
1271
+ sparkRisk(display) {
1272
+ if (display.kind !== "transfer") return [];
1273
+ return assessRecipients("spark", [normalizeAddress("spark", display.receiver) ?? display.receiver], this.knownAddresses("spark"));
1274
+ }
1275
+ };
1276
+ //#endregion
136
1277
  //#region src/chains/bitcoin-scan.ts
137
1278
  /**
138
1279
  * Gap-20 BIP-44/86 account scanning over Esplora — derives /0/* and /1/* from the account xpub,
@@ -280,6 +1421,10 @@ var BtcUtxoStore = class BtcUtxoStore {
280
1421
  });
281
1422
  this.persist();
282
1423
  }
1424
+ /** True while a pending request or an unconfirmed transaction holds this output. */
1425
+ isLocked(u) {
1426
+ return this.utxos.get(BtcUtxoStore.key(u))?.status === "locked";
1427
+ }
283
1428
  /** Released on failed/rejected/dropped; spent-removed on confirmed. */
284
1429
  endTx(txId, final) {
285
1430
  if (final === "confirmed") {
@@ -308,10 +1453,6 @@ var BtcUtxoStore = class BtcUtxoStore {
308
1453
  }
309
1454
  };
310
1455
  //#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
1456
  //#region src/accounts/errors.ts
316
1457
  var AccountTreeError = class extends Error {
317
1458
  category;
@@ -461,7 +1602,7 @@ const check = async (family, run) => {
461
1602
  } catch (error) {
462
1603
  return [family, {
463
1604
  used: false,
464
- error: errorMessage(error)
1605
+ error: errorMessage$1(error)
465
1606
  }];
466
1607
  }
467
1608
  };
@@ -726,7 +1867,6 @@ var AccountTreeController = class extends Controller {
726
1867
  ...g.boundPubkey === void 0 ? {} : { boundPubkey: g.boundPubkey }
727
1868
  });
728
1869
  return {
729
- v: 1,
730
1870
  wallet,
731
1871
  groups
732
1872
  };
@@ -1144,8 +2284,9 @@ function persistedToState(tree) {
1144
2284
  * EVM chain adapter: native transfers through a viem PublicClient injected by NetworkController.
1145
2285
  * Fee tiers come from eth_feeHistory (20 blocks, p10/p50/p90 → slow/normal/fast, maxFee = 2×baseFee +
1146
2286
  * 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.
2287
+ * 1.2 (ceil). The next nonce is read on a per-(network, from) serial queue as max(pending count,
2288
+ * last broadcast + 1) and is only recorded once a broadcast succeeds. Finalization uses the
2289
+ * `finalized` block tag; receipts with status 0 fail.
1149
2290
  */
1150
2291
  const ERC20_ABI = [
1151
2292
  {
@@ -1229,20 +2370,35 @@ var EvmChainAdapter = class {
1229
2370
  if (typeof result !== "string") throw new TypeError(`${method} returned a malformed result`);
1230
2371
  return result;
1231
2372
  }
1232
- /** Allocate the next nonce for (network, from) — serial per key. */
1233
- async nonce(client, network, from) {
2373
+ /** The endpoint's pending transaction count for `from`. */
2374
+ async pendingCount(client, from) {
2375
+ return Number(await this.rpcHex(client, "eth_getTransactionCount", [from, "pending"]));
2376
+ }
2377
+ /**
2378
+ * The next nonce for (network, from): the larger of the endpoint's pending count and one past the
2379
+ * highest nonce this engine already broadcast. Reading it reserves nothing — only
2380
+ * {@link commitNonce} after a successful broadcast advances the local record.
2381
+ */
2382
+ async nextNonce(client, network, from) {
1234
2383
  const key = `${network}:${from}`;
1235
2384
  return this.serial(key, async () => {
1236
- const pending = Number(await this.rpcHex(client, "eth_getTransactionCount", [from, "pending"]));
2385
+ const pending = await this.pendingCount(client, from);
1237
2386
  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;
2387
+ return Math.max(pending, local === void 0 ? 0 : local + 1);
1241
2388
  });
1242
2389
  }
2390
+ /** True once a broadcast at `nonce` or later is recorded for (network, from). */
2391
+ nonceConsumed(network, from, nonce) {
2392
+ const local = this.lastNonce.get(`${network}:${from}`);
2393
+ return local !== void 0 && local >= nonce;
2394
+ }
2395
+ commitNonce(network, from, nonce) {
2396
+ const key = `${network}:${from}`;
2397
+ this.lastNonce.set(key, Math.max(this.lastNonce.get(key) ?? nonce, nonce));
2398
+ }
1243
2399
  /** 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);
2400
+ async build(client, network, from, intent, nonceOverride) {
2401
+ const nonce = nonceOverride ?? await this.nextNonce(client, network, from);
1246
2402
  const erc20 = intent.kind === "erc20";
1247
2403
  const to = checksumEvmAddress(erc20 ? intent.token : intent.to);
1248
2404
  const value = erc20 ? 0n : intent.value;
@@ -2085,7 +3241,8 @@ const PUBKEY_RE = /^[0-9a-f]{64}$/;
2085
3241
  const KNOWN_DOC_KEYS = /* @__PURE__ */ new Set([
2086
3242
  "v",
2087
3243
  "wallet",
2088
- "groups"
3244
+ "groups",
3245
+ "contacts"
2089
3246
  ]);
2090
3247
  const KNOWN_WALLET_KEYS = /* @__PURE__ */ new Set(["name", "updatedAt"]);
2091
3248
  const KNOWN_GROUP_KEYS = /* @__PURE__ */ new Set([
@@ -2099,11 +3256,14 @@ const isObject$7 = (v) => typeof v === "object" && v !== null && !Array.isArray(
2099
3256
  const isNonNegativeInt = (v) => typeof v === "number" && Number.isSafeInteger(v) && v >= 0;
2100
3257
  const validName = (v) => v === void 0 || typeof v === "string" && v.trim().length > 0 && v.trim().length <= NAME_MAX;
2101
3258
  const trimmedName = (v) => typeof v === "string" ? v.trim() : void 0;
2102
- /** Strict decode — any malformed shape, unknown key or duplicate index returns `undefined`. */
3259
+ /**
3260
+ * Strict decode — any malformed shape, unknown key, duplicate index or id, or invalid contact
3261
+ * returns `undefined`; only `v: 2` is read.
3262
+ */
2103
3263
  function decodeMetaDoc(value) {
2104
3264
  if (!isObject$7(value)) return;
2105
3265
  for (const k of Object.keys(value)) if (!KNOWN_DOC_KEYS.has(k)) return;
2106
- if (value["v"] !== 1) return;
3266
+ if (value["v"] !== 2) return;
2107
3267
  const { wallet } = value;
2108
3268
  if (!isObject$7(wallet)) return;
2109
3269
  for (const k of Object.keys(wallet)) if (!KNOWN_WALLET_KEYS.has(k)) return;
@@ -2132,9 +3292,18 @@ function decodeMetaDoc(value) {
2132
3292
  });
2133
3293
  }
2134
3294
  out.sort((a, b) => a.index - b.index);
3295
+ const { contacts } = value;
3296
+ if (!Array.isArray(contacts) || contacts.length > 128) return;
3297
+ const records = /* @__PURE__ */ new Map();
3298
+ for (const item of contacts) {
3299
+ const record = decodeRecord(item);
3300
+ if (record === void 0 || records.has(record.id)) return;
3301
+ records.set(record.id, record);
3302
+ }
2135
3303
  const name = trimmedName(wallet["name"]);
2136
3304
  return {
2137
- v: 1,
3305
+ v: 2,
3306
+ contacts: [...records.values()].sort((a, b) => a.id < b.id ? -1 : 1),
2138
3307
  wallet: {
2139
3308
  updatedAt: wallet["updatedAt"],
2140
3309
  ...name === void 0 ? {} : { name }
@@ -2184,6 +3353,7 @@ var MetaSyncController = class extends Controller {
2184
3353
  timers = /* @__PURE__ */ new Map();
2185
3354
  inflight = /* @__PURE__ */ new Set();
2186
3355
  accountsUnsubscribe;
3356
+ addressBookUnsubscribe;
2187
3357
  constructor(options) {
2188
3358
  super({
2189
3359
  name: "metaSync",
@@ -2197,6 +3367,9 @@ var MetaSyncController = class extends Controller {
2197
3367
  this.accountsUnsubscribe = this.options.accounts.store.subscribe(() => {
2198
3368
  this.onTreeChange();
2199
3369
  });
3370
+ this.addressBookUnsubscribe = this.options.addressBook.store.subscribe(() => {
3371
+ this.onTreeChange();
3372
+ });
2200
3373
  await this.pullAll(gen);
2201
3374
  }
2202
3375
  async destroy() {
@@ -2207,6 +3380,8 @@ var MetaSyncController = class extends Controller {
2207
3380
  this.generation += 1;
2208
3381
  this.accountsUnsubscribe?.();
2209
3382
  this.accountsUnsubscribe = void 0;
3383
+ this.addressBookUnsubscribe?.();
3384
+ this.addressBookUnsubscribe = void 0;
2210
3385
  for (const timer of this.timers.values()) clearTimeout(timer);
2211
3386
  this.timers.clear();
2212
3387
  this.pulled.clear();
@@ -2284,8 +3459,8 @@ var MetaSyncController = class extends Controller {
2284
3459
  if (remote === void 0 || e.created_at > remote.created_at) remote = e;
2285
3460
  }
2286
3461
  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);
3462
+ const local = this.exportDoc(walletId);
3463
+ const hasEdits = local.wallet.updatedAt > 0 || local.groups.some((g) => g.updatedAt > 0) || local.contacts.length > 0;
2289
3464
  if (gen !== this.generation) return;
2290
3465
  this.pulled.add(walletId);
2291
3466
  this.rememberExport(walletId);
@@ -2306,12 +3481,13 @@ var MetaSyncController = class extends Controller {
2306
3481
  return;
2307
3482
  }
2308
3483
  const merge = this.options.accounts.mergeMeta(walletId, doc);
3484
+ const contacts = await this.options.addressBook.merge(doc.contacts);
2309
3485
  if (gen !== this.generation) return;
2310
3486
  this.lastRemoteCreatedAt.set(walletId, remote.created_at);
2311
3487
  this.pulled.add(walletId);
2312
3488
  this.rememberExport(walletId);
2313
3489
  this.setStatus(walletId, { status: "synced" });
2314
- if (merge.localNewer) await this.publishNow(walletId, gen);
3490
+ if (merge.localNewer || contacts.localNewer) await this.publishNow(walletId, gen);
2315
3491
  }
2316
3492
  if (remoteSkipped > 0) this.options.logger.warn(`meta sync: ignored ${remoteSkipped} invalid remote events`);
2317
3493
  } catch (error) {
@@ -2326,6 +3502,14 @@ var MetaSyncController = class extends Controller {
2326
3502
  this.inflight.delete(walletId);
2327
3503
  }
2328
3504
  }
3505
+ /** The wallet's tree plus the shared address book — every wallet's document carries both. */
3506
+ exportDoc(walletId) {
3507
+ return {
3508
+ v: 2,
3509
+ ...this.options.accounts.exportMeta(walletId),
3510
+ contacts: this.options.addressBook.syncEntries()
3511
+ };
3512
+ }
2329
3513
  filterFor(pubkey, tag) {
2330
3514
  return {
2331
3515
  kinds: [META_SYNC_KIND],
@@ -2341,8 +3525,7 @@ var MetaSyncController = class extends Controller {
2341
3525
  /** Snapshot the wallet's exported doc — the debounce publishes only on a real change. */
2342
3526
  rememberExport(walletId) {
2343
3527
  try {
2344
- const doc = this.options.accounts.exportMeta(walletId);
2345
- this.lastExported.set(walletId, JSON.stringify(doc));
3528
+ this.lastExported.set(walletId, JSON.stringify(this.exportDoc(walletId)));
2346
3529
  } catch {}
2347
3530
  }
2348
3531
  onTreeChange() {
@@ -2350,7 +3533,7 @@ var MetaSyncController = class extends Controller {
2350
3533
  for (const walletId of this.pulled) {
2351
3534
  let current;
2352
3535
  try {
2353
- current = JSON.stringify(this.options.accounts.exportMeta(walletId));
3536
+ current = JSON.stringify(this.exportDoc(walletId));
2354
3537
  } catch {
2355
3538
  continue;
2356
3539
  }
@@ -2374,7 +3557,7 @@ var MetaSyncController = class extends Controller {
2374
3557
  return;
2375
3558
  }
2376
3559
  try {
2377
- const bytes = encodeMetaDoc(this.options.accounts.exportMeta(walletId));
3560
+ const bytes = encodeMetaDoc(this.exportDoc(walletId));
2378
3561
  if (bytes === void 0) throw new MetaSyncError("internal", "meta doc exceeds 64 KB");
2379
3562
  const encrypt = signer.nip44Encrypt;
2380
3563
  if (encrypt === void 0) throw new MetaSyncError("internal", "signer lacks nip44 encryption");
@@ -2405,11 +3588,11 @@ var MetaSyncController = class extends Controller {
2405
3588
  function categorizeSync(error) {
2406
3589
  return error instanceof MetaSyncError ? error.category : "network";
2407
3590
  }
2408
- /** A decrypted doc that parses as JSON with a numeric `v` above 1 is a newer schema, not garbage. */
3591
+ /** A decrypted doc that parses as JSON with a numeric `v` above 2 is a newer schema, not garbage. */
2409
3592
  function isUnsupportedDoc(plaintext) {
2410
3593
  try {
2411
3594
  const doc = JSON.parse(plaintext);
2412
- return typeof doc === "object" && doc !== null && "v" in doc && typeof doc.v === "number" && doc.v > 1;
3595
+ return typeof doc === "object" && doc !== null && "v" in doc && typeof doc.v === "number" && doc.v > 2;
2413
3596
  } catch {
2414
3597
  return false;
2415
3598
  }
@@ -2608,102 +3791,6 @@ var BitcoinChainAdapter = class {
2608
3791
  }
2609
3792
  };
2610
3793
  //#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
3794
  //#region src/networks/controller.ts
2708
3795
  /**
2709
3796
  * EVM network controller: the built-in registry plus host-provided and user-added RPC endpoints.
@@ -2715,21 +3802,86 @@ const SOLANA_NETWORKS = [{
2715
3802
  const esploraSleep = async (ms) => new Promise((resolve) => {
2716
3803
  setTimeout(resolve, ms);
2717
3804
  });
3805
+ /** Bound on the unlock-time `/v1/networks` read so a dead gateway cannot stall unlocking. */
3806
+ const GATEWAY_PROBE_TIMEOUT_MS = 5e3;
2718
3807
  const isObject$5 = (v) => typeof v === "object" && v !== null;
3808
+ const makeClient = (info, urls, fetchFn) => createPublicClient({
3809
+ chain: {
3810
+ id: info.chainId,
3811
+ name: info.name,
3812
+ nativeCurrency: {
3813
+ name: info.symbol,
3814
+ symbol: info.symbol,
3815
+ decimals: info.decimals
3816
+ },
3817
+ rpcUrls: { default: { http: [...urls] } }
3818
+ },
3819
+ transport: fallback(urls.map((url) => http(url, { fetchFn }))),
3820
+ batch: { multicall: true }
3821
+ });
3822
+ const esploraClient = (urls, fetchFn) => esplora(urls, async (url, init) => {
3823
+ const r = await fetchFn(url, init);
3824
+ return {
3825
+ ok: r.ok,
3826
+ status: r.status,
3827
+ headers: { get: (h) => r.headers.get(h) ?? void 0 },
3828
+ json: async () => r.json(),
3829
+ text: async () => r.text()
3830
+ };
3831
+ }, esploraSleep);
3832
+ const solanaCaller = (urls, fetchFn) => ({ call: async (method, params) => {
3833
+ const attempt = async (rest, last) => {
3834
+ const [url, ...next] = rest;
3835
+ if (url === void 0) throw last instanceof Error ? last : new Error(typeof last === "string" ? last : method);
3836
+ try {
3837
+ const body = await (await fetchFn(url, {
3838
+ method: "POST",
3839
+ headers: { "content-type": "application/json" },
3840
+ body: JSON.stringify({
3841
+ jsonrpc: "2.0",
3842
+ id: 1,
3843
+ method,
3844
+ params
3845
+ })
3846
+ })).json();
3847
+ if (!isObject$5(body)) return await attempt(next, /* @__PURE__ */ new Error(`malformed response from ${url}`));
3848
+ if (body["error"] !== void 0) {
3849
+ const msg = isObject$5(body["error"]) ? body["error"]["message"] : void 0;
3850
+ return await attempt(next, new Error(typeof msg === "string" ? msg : method));
3851
+ }
3852
+ return body["result"];
3853
+ } catch (error) {
3854
+ return attempt(next, error);
3855
+ }
3856
+ };
3857
+ return attempt(urls, void 0);
3858
+ } });
2719
3859
  var NetworkController = class extends Controller {
2720
3860
  options;
2721
3861
  engineStore;
2722
3862
  clients = /* @__PURE__ */ new Map();
3863
+ gatewayBase;
3864
+ gatewayOrigin;
3865
+ /** Networks the gateway reported at unlock; empty while locked or when the read failed. */
3866
+ gatewayServed = /* @__PURE__ */ new Set();
2723
3867
  constructor(options) {
2724
3868
  super({
2725
3869
  name: "networks",
2726
3870
  defaultState: () => ({ customRpc: {} })
2727
3871
  });
2728
3872
  this.options = options;
3873
+ if (options.gateway !== void 0) {
3874
+ this.gatewayBase = options.gateway.url.replace(/\/+$/, "");
3875
+ this.gatewayOrigin = new URL(options.gateway.url).origin;
3876
+ }
2729
3877
  }
2730
3878
  async init() {
2731
3879
  this.engineStore = await this.options.openStore();
2732
- const raw = await this.engineStore.collection("networks").get("customRpc");
3880
+ await this.loadCustomRpc(this.engineStore);
3881
+ await this.loadGatewayNetworks();
3882
+ }
3883
+ async loadCustomRpc(store) {
3884
+ const raw = await store.collection("networks").get("customRpc");
2733
3885
  if (raw === void 0) return;
2734
3886
  const parsed = JSON.parse(new TextDecoder().decode(raw));
2735
3887
  if (typeof parsed === "object" && parsed !== null) {
@@ -2741,16 +3893,56 @@ var NetworkController = class extends Controller {
2741
3893
  }));
2742
3894
  }
2743
3895
  }
3896
+ /** Reads the gateway's served networks; any failure leaves the gateway unused until next unlock. */
3897
+ async loadGatewayNetworks() {
3898
+ if (this.gatewayBase === void 0) return;
3899
+ const url = `${this.gatewayBase}${networksPath}`;
3900
+ try {
3901
+ const res = await this.gatewayFetch(url, { signal: AbortSignal.timeout(GATEWAY_PROBE_TIMEOUT_MS) });
3902
+ if (!res.ok) throw new Error(`status ${res.status}`);
3903
+ const body = await res.json();
3904
+ const served = parseWith(servedNetworksResponse, body);
3905
+ if (served === void 0) throw new Error("malformed response");
3906
+ this.gatewayServed = new Set(served.networks);
3907
+ } catch {
3908
+ this.gatewayServed = /* @__PURE__ */ new Set();
3909
+ this.options.logger.warn(`gateway unavailable, not used for this session: ${url}`);
3910
+ }
3911
+ }
3912
+ /** `fetch` that adds the install id to requests bound for the gateway's origin only. */
3913
+ gatewayFetch = async (input, init) => {
3914
+ const { gateway } = this.options;
3915
+ const target = input instanceof Request ? input.url : String(input);
3916
+ if (gateway === void 0 || new URL(target).origin !== this.gatewayOrigin) return this.options.fetch(input, init);
3917
+ const headers = new Headers(init?.headers);
3918
+ headers.set(INSTALL_ID_HEADER, gateway.installId);
3919
+ return this.options.fetch(input, {
3920
+ ...init,
3921
+ headers
3922
+ });
3923
+ };
2744
3924
  async destroy() {
2745
3925
  this.lock();
2746
3926
  await Promise.resolve();
2747
3927
  }
2748
3928
  lock() {
3929
+ this.gatewayServed = /* @__PURE__ */ new Set();
2749
3930
  this.clients.clear();
2750
3931
  this.engineStore = void 0;
2751
3932
  }
3933
+ /**
3934
+ * Ordered endpoints: a network with custom endpoints uses custom → host and never the gateway;
3935
+ * otherwise a gateway-served network uses gateway → host; else host only.
3936
+ */
2752
3937
  endpoints(network) {
2753
- return [...this.getState().customRpc[network] ?? [], ...this.options.rpc[network] ?? []];
3938
+ const custom = this.getState().customRpc[network] ?? [];
3939
+ const host = this.options.rpc[network] ?? [];
3940
+ if (custom.length > 0) return [...custom, ...host];
3941
+ if (this.gatewayBase !== void 0 && this.gatewayServed.has(network)) {
3942
+ const path = networkById(network)?.family === "bitcoin" ? esploraBasePath(network) : rpcPath(network);
3943
+ return [`${this.gatewayBase}${path}`, ...host];
3944
+ }
3945
+ return host;
2754
3946
  }
2755
3947
  info(network) {
2756
3948
  const info = networkById(network);
@@ -2763,78 +3955,56 @@ var NetworkController = class extends Controller {
2763
3955
  if (cached !== void 0) return cached;
2764
3956
  const urls = this.endpoints(network);
2765
3957
  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
- });
3958
+ const client = makeClient(info, urls, this.gatewayFetch);
2780
3959
  this.clients.set(network, client);
2781
3960
  return client;
2782
3961
  }
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. */
3962
+ /** True when the gateway heads this network's endpoint list (no custom endpoints, gateway-served). */
3963
+ gatewayFirst(network) {
3964
+ return (this.getState().customRpc[network] ?? []).length === 0 && this.gatewayBase !== void 0 && this.gatewayServed.has(network);
3965
+ }
3966
+ independent(network, make) {
3967
+ if (!this.gatewayFirst(network)) return { kind: "not-needed" };
3968
+ const hosts = this.options.rpc[network] ?? [];
3969
+ return hosts.length === 0 ? { kind: "unavailable" } : {
3970
+ kind: "available",
3971
+ client: make(hosts)
3972
+ };
3973
+ }
3974
+ /** Host-endpoint EVM client for cross-checking reads; never sends the install id. */
3975
+ independentClient(network) {
3976
+ return this.independent(network, (hosts) => makeClient(this.info(network), hosts, this.options.fetch));
3977
+ }
3978
+ independentSolanaRpc(network) {
3979
+ return this.independent(network, (hosts) => solanaCaller(hosts, this.options.fetch));
3980
+ }
3981
+ independentEsplora(network) {
3982
+ return this.independent(network, (hosts) => esploraClient(hosts, this.options.fetch));
3983
+ }
3984
+ /** Networks with at least one configured endpoint or served by the gateway, by family. */
2786
3985
  enabled(family) {
2787
- return [.../* @__PURE__ */ new Set([...Object.keys(this.options.rpc), ...Object.keys(this.getState().customRpc)])].filter((id) => {
3986
+ return [.../* @__PURE__ */ new Set([
3987
+ ...Object.keys(this.options.rpc),
3988
+ ...Object.keys(this.getState().customRpc),
3989
+ ...this.gatewayServed
3990
+ ])].filter((id) => {
2788
3991
  const info = networkById(id);
2789
3992
  return info !== void 0 && info.family === family;
2790
3993
  });
2791
3994
  }
3995
+ /** Solana JSON-RPC over the ordered endpoint list — kit's transport is single-endpoint. */
2792
3996
  solanaRpc(network) {
2793
3997
  const urls = this.endpoints(network);
2794
3998
  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
- } };
3999
+ return solanaCaller(urls, this.gatewayFetch);
2822
4000
  }
2823
4001
  /** Bounded-concurrency + 429-aware ordered-failover Esplora client. */
2824
4002
  esplora(network) {
2825
4003
  const urls = this.endpoints(network);
2826
4004
  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);
4005
+ return esploraClient(urls, this.gatewayFetch);
2837
4006
  }
4007
+ /** Validate a custom endpoint's reported chain id, then persist it first in the list. */
2838
4008
  async addRpc(network, url) {
2839
4009
  const info = this.info(network);
2840
4010
  if (info.family === "bitcoin") {
@@ -3166,156 +4336,6 @@ function parsePayment(input, options) {
3166
4336
  }
3167
4337
  }
3168
4338
  //#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
4339
  //#region src/payments/resolve.ts
3320
4340
  /**
3321
4341
  * `parsePayment` plus LNURL dereferencing: an `lnurl` result is fetched and turned into a concrete
@@ -3352,91 +4372,6 @@ async function resolvePayment(input, options) {
3352
4372
  };
3353
4373
  }
3354
4374
  //#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
4375
  //#region src/spark/notes.ts
3441
4376
  const HEX64 = /^[0-9a-f]{64}$/;
3442
4377
  const isObject$4 = (v) => typeof v === "object" && v !== null && !Array.isArray(v);
@@ -4038,10 +4973,27 @@ const createBitcoinRoute = () => {
4038
4973
  const bitcoin = new BitcoinChainAdapter();
4039
4974
  const bitcoinUtxos = new BtcUtxoStore();
4040
4975
  const pending = /* @__PURE__ */ new Map();
4041
- const send = async (ctx, params) => {
4042
- const { account, network, to, amount, fee, verify } = params;
4976
+ /**
4977
+ * Outputs held by a request that has not been broadcast yet ("txid:vout" → request id). Memory
4978
+ * only: the request queue is not persisted, so a crash must not leave anything locked. The
4979
+ * persistent lock (keyed by tx id) is written only once a broadcast succeeds.
4980
+ */
4981
+ const reserved = /* @__PURE__ */ new Map();
4982
+ const isHeld = (key) => {
4983
+ const [txid = "", vout = "0"] = key.split(":");
4984
+ return reserved.has(key) || bitcoinUtxos.isLocked({
4985
+ txid,
4986
+ vout: Number(vout)
4987
+ });
4988
+ };
4989
+ const releaseReservation = (requestId) => {
4990
+ for (const [key, holder] of reserved) if (holder === requestId) reserved.delete(key);
4991
+ };
4992
+ const prepare = async (ctx, params, requestId) => {
4993
+ const { account, network, to, amount, fee } = params;
4043
4994
  if (account.kind !== "hd") throw new Error("imported accounts are not yet supported");
4044
4995
  const es = ctx.options.networks.esplora(network);
4996
+ const independent = ctx.options.networks.independentEsplora(network);
4045
4997
  const apis = ctx.options.chains ?? chains(ctx.options.wallet);
4046
4998
  const template = [
4047
4999
  "btc.bip44",
@@ -4103,9 +5055,25 @@ const createBitcoinRoute = () => {
4103
5055
  const quotes = await feeTiers(es);
4104
5056
  const quote = quotes.find((q) => q.tier === (fee === "fast" ? "fast" : fee === "slow" ? "slow" : "normal")) ?? quotes[1];
4105
5057
  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);
5058
+ const flags = [independent.kind === "unavailable" ? "reads-unverified" : void 0];
5059
+ let rate = quote.satPerByte;
5060
+ let independentRate;
5061
+ if (independent.kind === "available") try {
5062
+ independentRate = (await feeTiers(independent.client)).find((q) => q.tier === quote.tier)?.satPerByte;
5063
+ if (independentRate === void 0) flags.push("reads-unverified");
5064
+ else {
5065
+ const merged = mergeSatPerByte(quote.satPerByte, independentRate);
5066
+ ({rate} = merged);
5067
+ flags.push(merged.mismatch ? "fee-mismatch" : void 0);
5068
+ }
5069
+ } catch {
5070
+ flags.push("reads-unverified");
5071
+ }
5072
+ const free = inputs.filter((i) => !isHeld(`${String(i.txid ?? "")}:${String(i.index ?? 0)}`));
5073
+ const { psbt, inputs: selected } = bitcoin.buildPsbt(free, to, amount, BigInt(rate), changeAddr, net.params);
4107
5074
  const selectedKeys = new Set(selected.map((i) => `${String(i.txid ?? "")}:${String(i.index ?? 0)}`));
4108
5075
  const selectedUtxos = utxos.filter((u) => selectedKeys.has(`${u.txid}:${u.vout}`));
5076
+ for (const key of selectedKeys) reserved.set(key, requestId);
4109
5077
  const txP = Transaction.fromPSBT(psbt);
4110
5078
  const ownScripts = new Set(change.filter((a) => !a.used).map((a) => hex.encode(scriptPubKey(a.address, net.params))));
4111
5079
  const request = {
@@ -4130,7 +5098,7 @@ const createBitcoinRoute = () => {
4130
5098
  const inTotal = selectedUtxos.reduce((a, u) => a + u.value, 0);
4131
5099
  const outTotal = outValues.reduce((a, o) => a + o.value, 0);
4132
5100
  const id0 = newId(ctx.options.random);
4133
- pending.set(id0, {
5101
+ const stash = {
4134
5102
  inputs,
4135
5103
  allUtxos: utxos,
4136
5104
  pathOf: (u) => {
@@ -4152,62 +5120,86 @@ const createBitcoinRoute = () => {
4152
5120
  tier: fee === "fast" ? "fast" : fee === "slow" ? "slow" : "normal",
4153
5121
  template,
4154
5122
  network
4155
- });
4156
- let tx = {
4157
- id: id0,
4158
- family: "bitcoin",
4159
- network,
4160
- account: account.walletId,
4161
- hash: void 0,
4162
- state: "draft",
4163
- 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
4179
5123
  };
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
- }
5124
+ const record = {
5125
+ inputs: selectedUtxos.map((u) => ({
5126
+ txid: u.txid,
5127
+ vout: u.vout,
5128
+ value: u.value,
5129
+ address: u.address
5130
+ })),
5131
+ outputs: outValues,
5132
+ vsize,
5133
+ fee: inTotal - outTotal,
5134
+ feeRate: Math.ceil((inTotal - outTotal) / vsize)
5135
+ };
5136
+ if (independentRate !== void 0) flags.push(exceedsNetworkCap(BigInt(record.feeRate), BigInt(Math.ceil(independentRate))) ? "fee-above-network" : void 0);
5137
+ flags.push(feeReachesAmount(BigInt(record.fee), amount) ? "fee-exceeds-amount" : void 0);
5138
+ const submit = async (verify) => {
5139
+ pending.set(id0, stash);
5140
+ let tx = {
5141
+ id: id0,
5142
+ family: "bitcoin",
5143
+ network,
5144
+ account: account.walletId,
5145
+ hash: void 0,
5146
+ state: "draft",
5147
+ intent,
5148
+ nonce: 0,
5149
+ btc: record,
5150
+ submittedAt: ctx.options.now(),
5151
+ error: void 0
5152
+ };
5153
+ tx = await ctx.transition(tx, {});
5154
+ try {
5155
+ const result = await apis.sign({
5156
+ request,
5157
+ verify
5158
+ });
5159
+ tx = await ctx.transition(tx, { state: "signed" });
5160
+ if (result.kind !== "bitcoin.psbt" || !(result.signedPsbt instanceof Uint8Array)) throw new Error("unexpected bitcoin sign result");
5161
+ const signed = Transaction.fromPSBT(result.signedPsbt);
5162
+ signed.finalize();
5163
+ const raw = signed.extract();
5164
+ const hash = String(await es.post("/tx", hex.encode(raw)));
5165
+ bitcoinUtxos.lock(selectedUtxos, tx.id);
5166
+ releaseReservation(requestId);
5167
+ tx = await ctx.transition(tx, {
5168
+ state: "submitted",
5169
+ hash,
5170
+ submittedAt: ctx.options.now()
5171
+ });
5172
+ ctx.track(tx.id, hash);
5173
+ return tx;
5174
+ } catch (error) {
5175
+ const failed = tx.state === "signed" ? "failed" : "rejected";
5176
+ await ctx.transition(tx, {
5177
+ state: failed,
5178
+ error: errorMessage$1(error)
5179
+ });
5180
+ throw error;
5181
+ }
5182
+ };
5183
+ return {
5184
+ txId: id0,
5185
+ account,
5186
+ network,
5187
+ intent,
5188
+ checks: collectChecks(flags),
5189
+ staleReason: () => void 0,
5190
+ submit,
5191
+ release: () => {
5192
+ releaseReservation(requestId);
5193
+ }
5194
+ };
4208
5195
  };
4209
5196
  /** Esplora `/tx/:txid/status` → terminal state or `undefined` to keep polling. */
4210
- const finish = async (ctx, stash, es, account, old, psbt, record, link, verify) => {
5197
+ /**
5198
+ * Builds the approval-ready request for a replacement or CPFP child. `held` are the extra outputs
5199
+ * it spends beyond the original's own inputs; they are reserved in memory here and only locked
5200
+ * persistently, under the new transaction id, once the broadcast succeeds.
5201
+ */
5202
+ const prepareFinish = (ctx, stash, es, account, old, psbt, record, link, requestId, held, flags) => {
4211
5203
  const apis = ctx.options.chains ?? chains(ctx.options.wallet);
4212
5204
  const txP = Transaction.fromPSBT(psbt);
4213
5205
  const request = {
@@ -4219,59 +5211,78 @@ const createBitcoinRoute = () => {
4219
5211
  };
4220
5212
  const ownScripts = new Set(stash.changeAddresses.map((a) => hex.encode(scriptPubKey(a, bitcoinNetwork(stash.network).params))));
4221
5213
  const intent = decodeBitcoinPsbt(request, ownScripts);
4222
- let tx = {
4223
- id: newId(ctx.options.random),
4224
- family: "bitcoin",
5214
+ const txId = newId(ctx.options.random);
5215
+ for (const key of held) reserved.set(key, requestId);
5216
+ const submit = async (verify) => {
5217
+ let tx = {
5218
+ id: txId,
5219
+ family: "bitcoin",
5220
+ network: old.network,
5221
+ account: old.account,
5222
+ hash: void 0,
5223
+ state: "draft",
5224
+ intent,
5225
+ nonce: 0,
5226
+ btc: record,
5227
+ submittedAt: ctx.options.now(),
5228
+ error: void 0,
5229
+ ...link
5230
+ };
5231
+ tx = await ctx.transition(tx, {});
5232
+ try {
5233
+ const result = await apis.sign({
5234
+ request,
5235
+ verify
5236
+ });
5237
+ tx = await ctx.transition(tx, { state: "signed" });
5238
+ if (result.kind !== "bitcoin.psbt" || !(result.signedPsbt instanceof Uint8Array)) throw new Error("unexpected bitcoin sign result");
5239
+ const signed = Transaction.fromPSBT(result.signedPsbt);
5240
+ signed.finalize();
5241
+ const raw = signed.extract();
5242
+ const hash = String(await es.post("/tx", hex.encode(raw)));
5243
+ if (link.replaces !== void 0) bitcoinUtxos.endTx(old.id, "dropped");
5244
+ bitcoinUtxos.lock(record.inputs.map((i) => ({
5245
+ ...i,
5246
+ status: "confirmed",
5247
+ height: 0
5248
+ })), tx.id);
5249
+ releaseReservation(requestId);
5250
+ tx = await ctx.transition(tx, {
5251
+ state: "submitted",
5252
+ hash,
5253
+ submittedAt: ctx.options.now()
5254
+ });
5255
+ ctx.track(tx.id, hash);
5256
+ return tx;
5257
+ } catch (error) {
5258
+ const failed = tx.state === "signed" ? "failed" : "rejected";
5259
+ await ctx.transition(tx, {
5260
+ state: failed,
5261
+ error: errorMessage$1(error)
5262
+ });
5263
+ throw error;
5264
+ }
5265
+ };
5266
+ return {
5267
+ txId,
5268
+ account,
4225
5269
  network: old.network,
4226
- account: old.account,
4227
- hash: void 0,
4228
- state: "draft",
5270
+ target: old.id,
4229
5271
  intent,
4230
- nonce: 0,
4231
- btc: record,
4232
- submittedAt: ctx.options.now(),
4233
- error: void 0,
4234
- ...link
5272
+ checks: collectChecks(flags),
5273
+ staleReason: () => link.replaces === void 0 ? void 0 : replacementStaleReason(ctx, old.id),
5274
+ submit,
5275
+ release: () => {
5276
+ releaseReservation(requestId);
5277
+ }
4235
5278
  };
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
5279
  };
4270
- const replace = async (ctx, old, mode, verify) => {
5280
+ const prepareReplace = async (ctx, old, mode, requestId) => {
4271
5281
  const stash = pending.get(old.id);
4272
5282
  const { btc } = old;
4273
5283
  if (stash === void 0 || btc === void 0) throw new Error("the original build context is gone — cannot replace after restart");
4274
5284
  const es = ctx.options.networks.esplora(old.network);
5285
+ const independent = ctx.options.networks.independentEsplora(old.network);
4275
5286
  const apis = ctx.options.chains ?? chains(ctx.options.wallet);
4276
5287
  const { template } = stash;
4277
5288
  if (!("xpub" in await apis.account({
@@ -4288,7 +5299,20 @@ const createBitcoinRoute = () => {
4288
5299
  };
4289
5300
  const tiers = await feeTiers(es);
4290
5301
  const tierQuote = tiers.find((t) => t.tier === stash.tier) ?? tiers[1];
4291
- const rate = tierQuote === void 0 ? 1 : tierQuote.satPerByte;
5302
+ let rate = tierQuote === void 0 ? 1 : tierQuote.satPerByte;
5303
+ const flags = [independent.kind === "unavailable" ? "reads-unverified" : void 0];
5304
+ let independentRate;
5305
+ if (independent.kind === "available") try {
5306
+ independentRate = (await feeTiers(independent.client)).find((q) => q.tier === tierQuote?.tier)?.satPerByte;
5307
+ if (independentRate === void 0) flags.push("reads-unverified");
5308
+ else {
5309
+ const merged = mergeSatPerByte(rate, independentRate);
5310
+ ({rate} = merged);
5311
+ flags.push(merged.mismatch ? "fee-mismatch" : void 0);
5312
+ }
5313
+ } catch {
5314
+ flags.push("reads-unverified");
5315
+ }
4292
5316
  const newFee = Math.max(btc.fee + btc.vsize, Math.ceil(btc.vsize * rate));
4293
5317
  const [changeAddr] = stash.changeAddresses;
4294
5318
  if (changeAddr === void 0) throw new Error("no unused change address");
@@ -4300,7 +5324,7 @@ const createBitcoinRoute = () => {
4300
5324
  amount: BigInt(o.value)
4301
5325
  }));
4302
5326
  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}`));
5327
+ const spare = stash.allUtxos.filter((u) => !usedKeys.has(`${u.txid}:${u.vout}`) && !isHeld(`${u.txid}:${u.vout}`));
4304
5328
  const extraInputs = [];
4305
5329
  const extraUtxos = [];
4306
5330
  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 +5395,15 @@ const createBitcoinRoute = () => {
4371
5395
  fee: newFee,
4372
5396
  feeRate: Math.ceil(newFee / vsize)
4373
5397
  };
4374
- return finish(ctx, stash, es, account, old, psbt, record, { replaces: old.id }, verify);
5398
+ if (independentRate !== void 0) flags.push(exceedsNetworkCap(BigInt(record.feeRate), BigInt(Math.ceil(independentRate))) ? "fee-above-network" : void 0);
5399
+ return prepareFinish(ctx, stash, es, account, old, psbt, record, { replaces: old.id }, requestId, extraUtxos.map((u) => `${u.txid}:${u.vout}`), flags);
4375
5400
  };
4376
- const bumpChild = async (ctx, old, targetRate, verify) => {
5401
+ const prepareCpfp = async (ctx, old, targetRate, requestId) => {
4377
5402
  const stash = pending.get(old.id);
4378
5403
  const { btc } = old;
4379
5404
  if (stash === void 0 || btc === void 0) throw new Error("the parent build context is gone — cannot bump after restart");
4380
5405
  const es = ctx.options.networks.esplora(old.network);
5406
+ const independent = ctx.options.networks.independentEsplora(old.network);
4381
5407
  const { template } = stash;
4382
5408
  const account = {
4383
5409
  kind: "hd",
@@ -4390,6 +5416,15 @@ const createBitcoinRoute = () => {
4390
5416
  vout
4391
5417
  }] : []);
4392
5418
  if (own.length === 0) throw new Error("parent has no own outputs to bump");
5419
+ const parentKeys = own.map((o) => `${old.hash ?? ""}:${o.vout}`);
5420
+ if (parentKeys.some((key) => isHeld(key))) throw new Error("the parent's outputs are already reserved by another request");
5421
+ const flags = [independent.kind === "unavailable" ? "reads-unverified" : void 0];
5422
+ if (independent.kind === "available") try {
5423
+ const fastest = (await feeTiers(independent.client)).find((q) => q.tier === "fast");
5424
+ flags.push(fastest === void 0 ? "reads-unverified" : exceedsNetworkCap(BigInt(Math.ceil(targetRate)), BigInt(Math.ceil(fastest.satPerByte))) ? "fee-above-network" : void 0);
5425
+ } catch {
5426
+ flags.push("reads-unverified");
5427
+ }
4393
5428
  const childInputs = bitcoin.toInputs(own.map((o) => ({
4394
5429
  txid: old.hash ?? "",
4395
5430
  vout: o.vout,
@@ -4412,9 +5447,9 @@ const createBitcoinRoute = () => {
4412
5447
  amount: BigInt(inTotal - childFee)
4413
5448
  }], net.params);
4414
5449
  const record = {
4415
- inputs: own.map((o, i) => ({
5450
+ inputs: own.map((o) => ({
4416
5451
  txid: old.hash ?? "",
4417
- vout: i,
5452
+ vout: o.vout,
4418
5453
  value: o.value,
4419
5454
  address: o.address
4420
5455
  })),
@@ -4427,13 +5462,23 @@ const createBitcoinRoute = () => {
4427
5462
  fee: childFee,
4428
5463
  feeRate: Math.ceil(childFee / vsize)
4429
5464
  };
4430
- return finish(ctx, stash, es, account, old, psbt, record, { parentId: old.id }, verify);
5465
+ return prepareFinish(ctx, stash, es, account, old, psbt, record, { parentId: old.id }, requestId, parentKeys, flags);
5466
+ };
5467
+ const pollAndRelease = async (ctx, tx) => {
5468
+ const state = await poll(ctx, tx);
5469
+ if (state === "confirmed") bitcoinUtxos.endTx(tx.id, "confirmed");
5470
+ return state;
4431
5471
  };
4432
5472
  return {
4433
- send,
4434
- poll,
4435
- replace,
4436
- bumpChild
5473
+ prepare,
5474
+ poll: pollAndRelease,
5475
+ prepareReplace,
5476
+ prepareCpfp,
5477
+ unlock: async (store) => bitcoinUtxos.unlock(store),
5478
+ lock: () => {
5479
+ reserved.clear();
5480
+ bitcoinUtxos.lockStore();
5481
+ }
4437
5482
  };
4438
5483
  };
4439
5484
  //#endregion
@@ -4445,12 +5490,13 @@ const createEvmRoute = () => {
4445
5490
  const evm = new EvmChainAdapter();
4446
5491
  /** In-memory build context keyed by tx id — the inputs a replace rebuilds from. */
4447
5492
  const pending = /* @__PURE__ */ new Map();
4448
- const send = async (ctx, params) => {
4449
- const { account, network, to, amount, fee, verify } = params;
5493
+ const prepare = async (ctx, params) => {
5494
+ const { account, network, to, amount, fee } = params;
4450
5495
  const { networks } = ctx.options;
4451
5496
  if (account.kind !== "hd") throw new Error("imported accounts are not yet supported");
4452
5497
  const info = networks.info(network);
4453
5498
  const client = networks.client(network);
5499
+ const independent = networks.independentClient(network);
4454
5500
  const apis = ctx.options.chains ?? chains(ctx.options.wallet);
4455
5501
  const evmAccount = await apis.account({
4456
5502
  walletId: account.walletId,
@@ -4459,6 +5505,15 @@ const createEvmRoute = () => {
4459
5505
  network
4460
5506
  });
4461
5507
  const from = "address" in evmAccount ? evmAccount.address : "";
5508
+ const flags = [independent.kind === "unavailable" ? "reads-unverified" : void 0];
5509
+ let nonce = await evm.nextNonce(client, network, from);
5510
+ if (independent.kind === "available") try {
5511
+ const [primary, other] = await Promise.all([evm.pendingCount(client, from), evm.pendingCount(independent.client, from)]);
5512
+ nonce = Math.max(nonce, other);
5513
+ flags.push(primary === other ? void 0 : "nonce-mismatch");
5514
+ } catch {
5515
+ flags.push("reads-unverified");
5516
+ }
4462
5517
  const unsigned = await evm.build(client, network, from, params.token === void 0 ? {
4463
5518
  kind: "native",
4464
5519
  to,
@@ -4468,81 +5523,124 @@ const createEvmRoute = () => {
4468
5523
  token: params.token,
4469
5524
  to,
4470
5525
  value: amount
4471
- });
5526
+ }, nonce);
4472
5527
  const quotes = await evm.estimateFee(client);
4473
- const quote = quotes.find((q) => q.tier === fee) ?? quotes[1];
5528
+ let quote = quotes.find((q) => q.tier === fee) ?? quotes[1];
4474
5529
  if (quote === void 0) throw new Error("no fee quote");
5530
+ if (independent.kind === "available") try {
5531
+ const other = (await evm.estimateFee(independent.client)).find((q) => q.tier === quote?.tier);
5532
+ if (other === void 0) flags.push("reads-unverified");
5533
+ else {
5534
+ const merged = mergeEvmQuote(quote, other);
5535
+ ({quote} = merged);
5536
+ flags.push(merged.mismatch ? "fee-mismatch" : void 0, exceedsNetworkCap(quote.maxFeePerGas, other.maxFeePerGas) ? "fee-above-network" : void 0);
5537
+ }
5538
+ } catch {
5539
+ flags.push("reads-unverified");
5540
+ }
4475
5541
  const feed = evm.applyFee(unsigned, quote);
5542
+ if (params.token === void 0 && feeReachesAmount(feed.gas * (feed.maxFeePerGas ?? feed.gasPrice ?? 0n), amount)) flags.push("fee-exceeds-amount");
4476
5543
  const request = evm.toSignRequest(feed, account, info.chainId);
4477
5544
  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",
5545
+ const txId = newId(ctx.options.random);
5546
+ const chosen = quote;
5547
+ const submit = async (verify) => {
5548
+ pending.set(txId, {
5549
+ unsigned: feed,
5550
+ request,
5551
+ quote: chosen,
5552
+ chainId: info.chainId
5553
+ });
5554
+ let tx = {
5555
+ id: txId,
5556
+ family: "evm",
5557
+ network,
5558
+ account: account.walletId,
5559
+ hash: void 0,
5560
+ state: "draft",
5561
+ intent,
5562
+ nonce: unsigned.nonce,
5563
+ submittedAt: ctx.options.now(),
5564
+ error: void 0
5565
+ };
5566
+ tx = await ctx.transition(tx, {});
5567
+ let signed;
5568
+ try {
5569
+ const result = await apis.sign({
5570
+ request,
5571
+ verify
5572
+ });
5573
+ tx = await ctx.transition(tx, { state: "signed" });
5574
+ signed = result;
5575
+ } catch (error) {
5576
+ await ctx.transition(tx, {
5577
+ state: "rejected",
5578
+ error: errorMessage$1(error)
5579
+ });
5580
+ throw error;
5581
+ }
5582
+ try {
5583
+ const raw = evm.finalize(feed, signed);
5584
+ const hash = await evm.broadcast(client, raw);
5585
+ evm.commitNonce(network, from, unsigned.nonce);
5586
+ tx = await ctx.transition(tx, {
5587
+ state: "submitted",
5588
+ hash,
5589
+ submittedAt: ctx.options.now()
5590
+ });
5591
+ ctx.track(tx.id, hash);
5592
+ return tx;
5593
+ } catch (error) {
5594
+ await ctx.transition(tx, {
5595
+ state: "failed",
5596
+ error: errorMessage$1(error)
5597
+ });
5598
+ throw error;
5599
+ }
5600
+ };
5601
+ return {
5602
+ txId,
5603
+ account,
4488
5604
  network,
4489
- account: account.walletId,
4490
- hash: void 0,
4491
- state: "draft",
4492
5605
  intent,
4493
- nonce: unsigned.nonce,
4494
- submittedAt: ctx.options.now(),
4495
- error: void 0
5606
+ checks: collectChecks(flags),
5607
+ staleReason: () => evm.nonceConsumed(network, from, unsigned.nonce) ? "nonce already used by a newer transaction — start over" : void 0,
5608
+ submit,
5609
+ release: () => void 0
4496
5610
  };
4497
- tx = await ctx.transition(tx, {});
4498
- let signed;
4499
- try {
4500
- const result = await apis.sign({
4501
- 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()
4520
- });
4521
- ctx.track(tx.id, hash);
4522
- return tx;
4523
- } catch (error) {
4524
- await ctx.transition(tx, {
4525
- state: "failed",
4526
- error: errorMessage(error)
4527
- });
4528
- throw error;
4529
- }
4530
5611
  };
4531
5612
  /**
4532
5613
  * 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.
5614
+ * 0-value transfer to self). The fee is cross-checked and merged first, then the replacement
5615
+ * floor (+10% on both fee fields, never below the merged estimate) is applied on top, so a low
5616
+ * quote can never push the replacement under the node's replacement rule. A rejected signature
5617
+ * never reaches the network and leaves the original.
4535
5618
  */
4536
- const replace = async (ctx, old, mode, verify) => {
5619
+ const prepareReplace = async (ctx, old, mode) => {
4537
5620
  const stash = pending.get(old.id);
4538
5621
  if (stash === void 0) throw new Error("the original build context is gone — cannot replace after restart");
4539
5622
  const { networks } = ctx.options;
4540
5623
  const client = networks.client(old.network);
5624
+ const independent = networks.independentClient(old.network);
4541
5625
  const apis = ctx.options.chains ?? chains(ctx.options.wallet);
4542
5626
  const { tier } = stash.quote;
4543
5627
  const estimates = await evm.estimateFee(client);
4544
- const estimate = estimates.find((q) => q.tier === tier) ?? estimates[1];
5628
+ let estimate = estimates.find((q) => q.tier === tier) ?? estimates[1];
4545
5629
  if (estimate === void 0) throw new Error("no fee quote");
5630
+ const flags = [independent.kind === "unavailable" ? "reads-unverified" : void 0];
5631
+ let independentCap;
5632
+ if (independent.kind === "available") try {
5633
+ const other = (await evm.estimateFee(independent.client)).find((q) => q.tier === estimate?.tier);
5634
+ if (other === void 0) flags.push("reads-unverified");
5635
+ else {
5636
+ const merged = mergeEvmQuote(estimate, other);
5637
+ ({quote: estimate} = merged);
5638
+ independentCap = other.maxFeePerGas;
5639
+ flags.push(merged.mismatch ? "fee-mismatch" : void 0);
5640
+ }
5641
+ } catch {
5642
+ flags.push("reads-unverified");
5643
+ }
4546
5644
  const oldFee = stash.unsigned;
4547
5645
  const bumpedFee = bump(oldFee.maxFeePerGas ?? oldFee.gasPrice ?? 0n);
4548
5646
  const maxFeePerGas = bumpedFee > estimate.maxFeePerGas ? bumpedFee : estimate.maxFeePerGas;
@@ -4551,6 +5649,7 @@ const createEvmRoute = () => {
4551
5649
  maxFeePerGas,
4552
5650
  maxPriorityFeePerGas: priorityBump
4553
5651
  };
5652
+ if (independentCap !== void 0) flags.push(exceedsNetworkCap(maxFeePerGas, independentCap) ? "fee-above-network" : void 0);
4554
5653
  const unsigned = mode === "cancel" ? {
4555
5654
  ...stash.unsigned,
4556
5655
  to: stash.unsigned.from,
@@ -4564,59 +5663,72 @@ const createEvmRoute = () => {
4564
5663
  };
4565
5664
  const request = evm.toSignRequest(unsigned, stash.request.account, stash.chainId);
4566
5665
  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",
5666
+ const txId = newId(ctx.options.random);
5667
+ const submit = async (verify) => {
5668
+ pending.set(txId, {
5669
+ unsigned,
5670
+ request,
5671
+ quote: stash.quote,
5672
+ chainId: stash.chainId
5673
+ });
5674
+ let tx = {
5675
+ id: txId,
5676
+ family: "evm",
5677
+ network: old.network,
5678
+ account: old.account,
5679
+ hash: void 0,
5680
+ state: "draft",
5681
+ intent,
5682
+ nonce: old.nonce,
5683
+ replaces: old.id,
5684
+ submittedAt: ctx.options.now(),
5685
+ error: void 0
5686
+ };
5687
+ tx = await ctx.transition(tx, {});
5688
+ let signed;
5689
+ try {
5690
+ const result = await apis.sign({
5691
+ request,
5692
+ verify
5693
+ });
5694
+ tx = await ctx.transition(tx, { state: "signed" });
5695
+ signed = result;
5696
+ } catch (error) {
5697
+ await ctx.transition(tx, {
5698
+ state: "rejected",
5699
+ error: errorMessage$1(error)
5700
+ });
5701
+ throw error;
5702
+ }
5703
+ try {
5704
+ const raw = evm.finalize(unsigned, signed);
5705
+ const hash = await evm.broadcast(client, raw);
5706
+ tx = await ctx.transition(tx, {
5707
+ state: "submitted",
5708
+ hash,
5709
+ submittedAt: ctx.options.now()
5710
+ });
5711
+ ctx.track(tx.id, hash);
5712
+ return tx;
5713
+ } catch (error) {
5714
+ await ctx.transition(tx, {
5715
+ state: "failed",
5716
+ error: errorMessage$1(error)
5717
+ });
5718
+ throw error;
5719
+ }
5720
+ };
5721
+ return {
5722
+ txId,
5723
+ account: stash.request.account,
4577
5724
  network: old.network,
4578
- account: old.account,
4579
- hash: void 0,
4580
- state: "draft",
5725
+ target: old.id,
4581
5726
  intent,
4582
- nonce: old.nonce,
4583
- replaces: old.id,
4584
- submittedAt: ctx.options.now(),
4585
- error: void 0
5727
+ checks: collectChecks(flags),
5728
+ staleReason: () => replacementStaleReason(ctx, old.id),
5729
+ submit,
5730
+ release: () => void 0
4586
5731
  };
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
5732
  };
4621
5733
  /** Returns the terminal state once the receipt lands, or `undefined` to keep polling. */
4622
5734
  const poll = async (ctx, tx) => {
@@ -4627,9 +5739,9 @@ const createEvmRoute = () => {
4627
5739
  return state === "finalized" ? "confirmed" : state;
4628
5740
  };
4629
5741
  return {
4630
- send,
5742
+ prepare,
4631
5743
  poll,
4632
- replace
5744
+ prepareReplace
4633
5745
  };
4634
5746
  };
4635
5747
  PROGRAM_IDS.token2022;
@@ -4819,16 +5931,19 @@ var SolanaChainAdapter = class {
4819
5931
  //#endregion
4820
5932
  //#region src/transactions/solana.ts
4821
5933
  /** Solana route: SOL/SPL send → sign → broadcast, plus status poll with rebroadcast/drop. */
5934
+ /** The network's base signature fee, lamports per signature. */
5935
+ const BASE_FEE_LAMPORTS = 5000n;
4822
5936
  const isObject$1 = (v) => typeof v === "object" && v !== null;
4823
5937
  const obj = (v) => isObject$1(v) ? v : void 0;
4824
5938
  const arr = (v) => Array.isArray(v) ? v : void 0;
4825
5939
  const solana = new SolanaChainAdapter();
4826
5940
  /** Signed wire transactions kept for re-broadcast until the tx confirms or drops. */
4827
5941
  const wires = /* @__PURE__ */ new Map();
4828
- const sendSolana = async (ctx, params) => {
4829
- const { account, network, to, amount, fee, verify } = params;
5942
+ const prepareSolana = async (ctx, params) => {
5943
+ const { account, network, to, amount, fee } = params;
4830
5944
  if (account.kind !== "hd") throw new Error("imported accounts are not yet supported");
4831
5945
  const rpc = ctx.options.networks.solanaRpc(network);
5946
+ const independent = ctx.options.networks.independentSolanaRpc(network);
4832
5947
  const apis = ctx.options.chains ?? chains(ctx.options.wallet);
4833
5948
  const solAccount = await apis.account({
4834
5949
  walletId: account.walletId,
@@ -4870,8 +5985,25 @@ const sendSolana = async (ctx, params) => {
4870
5985
  ...tokenProgram === void 0 ? {} : { tokenProgram }
4871
5986
  });
4872
5987
  const quotes = await solana.estimateFee(rpc, unsigned.writable);
4873
- const quote = quotes.find((q) => q.tier === fee) ?? quotes[1];
5988
+ let quote = quotes.find((q) => q.tier === fee) ?? quotes[1];
4874
5989
  if (quote === void 0) throw new Error("no fee quote");
5990
+ const flags = [independent.kind === "unavailable" ? "reads-unverified" : void 0];
5991
+ if (independent.kind === "available") try {
5992
+ const other = (await solana.estimateFee(independent.client, unsigned.writable)).find((q) => q.tier === quote?.tier);
5993
+ if (other === void 0) flags.push("reads-unverified");
5994
+ else {
5995
+ const merged = mergeSolanaQuote(quote, other);
5996
+ ({quote} = merged);
5997
+ flags.push(merged.mismatch ? "fee-mismatch" : void 0, exceedsNetworkCap(quote.microLamportsPerCu, other.microLamportsPerCu) ? "fee-above-network" : void 0);
5998
+ }
5999
+ } catch {
6000
+ flags.push("reads-unverified");
6001
+ }
6002
+ if (params.token === void 0) {
6003
+ const units = BigInt(Math.ceil((unsigned.unitsConsumed === 0 ? 14e5 : unsigned.unitsConsumed) * 1.1));
6004
+ const maxFee = BASE_FEE_LAMPORTS + (units * quote.microLamportsPerCu + 999999n) / 1000000n;
6005
+ flags.push(feeReachesAmount(maxFee, amount) ? "fee-exceeds-amount" : void 0);
6006
+ }
4875
6007
  const messageBytes = solana.applyFee(unsigned, quote);
4876
6008
  const request = {
4877
6009
  kind: "solana.transaction",
@@ -4881,45 +6013,57 @@ const sendSolana = async (ctx, params) => {
4881
6013
  };
4882
6014
  const intent = ext === void 0 || params.token === void 0 ? decodeSolana(request, from) : token2022Intent(decodeSolana(request, from), ext, params.token, amount);
4883
6015
  const id = newId(ctx.options.random);
4884
- let tx = {
4885
- id,
4886
- family: "solana",
6016
+ const submit = async (verify) => {
6017
+ let tx = {
6018
+ id,
6019
+ family: "solana",
6020
+ network,
6021
+ account: account.walletId,
6022
+ hash: void 0,
6023
+ state: "draft",
6024
+ intent,
6025
+ nonce: 0,
6026
+ lastValidBlockHeight: Number(unsigned.lastValidBlockHeight),
6027
+ submittedAt: ctx.options.now(),
6028
+ error: void 0
6029
+ };
6030
+ tx = await ctx.transition(tx, {});
6031
+ try {
6032
+ const result = await apis.sign({
6033
+ request,
6034
+ verify
6035
+ });
6036
+ tx = await ctx.transition(tx, { state: "signed" });
6037
+ if (result.kind !== "solana.transaction" || !(result.signature instanceof Uint8Array)) throw new Error("unexpected solana sign result");
6038
+ const wire = solana.finalize(messageBytes, [result.signature]);
6039
+ const hash = await solana.broadcast(rpc, wire);
6040
+ wires.set(id, wire);
6041
+ tx = await ctx.transition(tx, {
6042
+ state: "submitted",
6043
+ hash,
6044
+ submittedAt: ctx.options.now()
6045
+ });
6046
+ ctx.track(tx.id, hash);
6047
+ return tx;
6048
+ } catch (error) {
6049
+ const failed = tx.state === "signed" ? "failed" : "rejected";
6050
+ await ctx.transition(tx, {
6051
+ state: failed,
6052
+ error: errorMessage$1(error)
6053
+ });
6054
+ throw error;
6055
+ }
6056
+ };
6057
+ return {
6058
+ txId: id,
6059
+ account,
4887
6060
  network,
4888
- account: account.walletId,
4889
- hash: void 0,
4890
- state: "draft",
4891
6061
  intent,
4892
- nonce: 0,
4893
- lastValidBlockHeight: Number(unsigned.lastValidBlockHeight),
4894
- submittedAt: ctx.options.now(),
4895
- error: void 0
6062
+ checks: collectChecks(flags),
6063
+ staleReason: async () => await solana.blockHeight(rpc) > unsigned.lastValidBlockHeight ? "blockhash expired — start over" : void 0,
6064
+ submit,
6065
+ release: () => void 0
4896
6066
  };
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
6067
  };
4924
6068
  /** Returns the terminal state, `dropped` on blockhash expiry, or `undefined` to keep polling. */
4925
6069
  const pollSolana = async (ctx, tx) => {
@@ -5044,6 +6188,7 @@ var TransactionController = class extends Controller {
5044
6188
  async init() {
5045
6189
  this.locked = false;
5046
6190
  this.engineStore = await this.options.openStore();
6191
+ await this.bitcoinRoute.unlock(this.engineStore);
5047
6192
  const collection = this.engineStore.collection("transactions");
5048
6193
  const keys = await collection.list();
5049
6194
  const rows = await Promise.all(keys.map(async (key) => collection.get(key)));
@@ -5070,10 +6215,16 @@ var TransactionController = class extends Controller {
5070
6215
  this.timers.clear();
5071
6216
  this.engineStore = void 0;
5072
6217
  this.evmRoute = createEvmRoute();
6218
+ this.bitcoinRoute.lock();
6219
+ this.bitcoinRoute = createBitcoinRoute();
5073
6220
  }
5074
6221
  async persist(tx) {
5075
6222
  const store = this.engineStore;
5076
- if (store !== void 0) await store.collection("transactions").put(tx.id, encode(tx));
6223
+ if (store === void 0) {
6224
+ this.options.logger.warn(`transaction ${tx.id} (${tx.state}) not persisted: vault locked`);
6225
+ return;
6226
+ }
6227
+ await store.collection("transactions").put(tx.id, encode(tx));
5077
6228
  }
5078
6229
  async transition(tx, next) {
5079
6230
  const updated = {
@@ -5095,45 +6246,48 @@ var TransactionController = class extends Controller {
5095
6246
  options: this.options,
5096
6247
  transition: async (tx, next) => this.transition(tx, next),
5097
6248
  track: (id, hash) => this.track(id, hash),
6249
+ txs: () => this.getState().txs,
5098
6250
  newId: () => newId(this.options.random)
5099
6251
  };
5100
6252
  }
5101
- async send(params) {
6253
+ /**
6254
+ * Internal entry for the request pipeline: builds, estimates and cross-checks a send without
6255
+ * signing it. Bitcoin outputs it selects stay locked under `requestId` until the returned
6256
+ * `release` runs or the broadcast moves them to the transaction.
6257
+ */
6258
+ async prepareSend(params, requestId) {
5102
6259
  if (this.locked) throw new Error("transactions require an unlocked vault");
5103
6260
  const { account, network } = params;
5104
6261
  const info = this.options.networks.info(network);
5105
6262
  if (account.kind !== "hd") throw new Error("imported accounts are not yet supported");
5106
6263
  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);
5110
- }
5111
- /**
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.
5114
- */
5115
- async replace(id, mode, verify) {
5116
- 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);
6264
+ if (info.family === "solana") return prepareSolana(ctx, params);
6265
+ if (info.family === "bitcoin") return this.bitcoinRoute.prepare(ctx, params, requestId);
6266
+ return this.evmRoute.prepare(ctx, params);
5123
6267
  }
5124
6268
  /**
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`.
6269
+ * Internal entry for replacements and CPFP: builds, cross-checks and reserves what the
6270
+ * replacement needs without signing it. The original must be `submitted` or `pending`.
5127
6271
  */
5128
- async bumpChild(id, targetRate, verify) {
6272
+ async prepareReplacement(input, requestId) {
5129
6273
  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);
6274
+ const old = this.getState().txs[input.txId];
6275
+ if (old === void 0) throw new Error(`unknown transaction ${input.txId}`);
6276
+ if (old.state !== "pending" && old.state !== "submitted") throw new Error(`cannot ${input.method} a ${old.state} transaction`);
6277
+ const ctx = this.routeCtx();
6278
+ if (input.method === "cpfp") {
6279
+ if (old.family !== "bitcoin") throw new Error("cpfp is Bitcoin-only");
6280
+ return this.bitcoinRoute.prepareCpfp(ctx, old, input.feeRate, requestId);
6281
+ }
6282
+ if (old.family === "bitcoin") return this.bitcoinRoute.prepareReplace(ctx, old, input.method, requestId);
6283
+ if (old.family === "evm") return this.evmRoute.prepareReplace(ctx, old, input.method);
6284
+ throw new Error(`${input.method} is EVM and Bitcoin only`);
5135
6285
  }
5136
6286
  track(id, hash) {
6287
+ if (this.locked) {
6288
+ this.options.logger.warn(`transaction ${id} was broadcast after the vault locked; it is neither persisted nor tracked`);
6289
+ return;
6290
+ }
5137
6291
  if (this.timers.has(id) || hash === "") return;
5138
6292
  const poll = this.options.pollMs ?? 4e3;
5139
6293
  const stop = this.options.setTimer(poll, async () => {
@@ -5193,7 +6347,8 @@ function createWalletEngine(options) {
5193
6347
  fetch: options.fetch,
5194
6348
  rpc: options.rpc ?? {},
5195
6349
  openStore: options.openStore,
5196
- logger: options.logger
6350
+ logger: options.logger,
6351
+ gateway: options.gateway
5197
6352
  });
5198
6353
  const assets = new AssetsController({
5199
6354
  networks,
@@ -5220,6 +6375,28 @@ function createWalletEngine(options) {
5220
6375
  now: options.now,
5221
6376
  logger: options.logger
5222
6377
  });
6378
+ const addressBook = new AddressBookController({
6379
+ openStore: options.openStore,
6380
+ random: options.random,
6381
+ now: options.now,
6382
+ logger: options.logger
6383
+ });
6384
+ const requests = new RequestController({
6385
+ transactions,
6386
+ addressBook,
6387
+ openStore: options.openStore,
6388
+ spark: controller,
6389
+ fetch: options.fetch,
6390
+ random: options.random,
6391
+ now: options.now,
6392
+ setTimer: options.setTimer ?? ((ms, fn) => {
6393
+ const id = setTimeout(() => {
6394
+ fn();
6395
+ }, ms);
6396
+ return () => clearTimeout(id);
6397
+ }),
6398
+ logger: options.logger
6399
+ });
5223
6400
  const accounts = new AccountTreeController({
5224
6401
  wallet: options.wallet,
5225
6402
  spark: controller,
@@ -5231,6 +6408,7 @@ function createWalletEngine(options) {
5231
6408
  const metaSync = new MetaSyncController({
5232
6409
  wallet: options.wallet,
5233
6410
  accounts,
6411
+ addressBook,
5234
6412
  nostr: { signer: (ref) => nostr(options.wallet).signer(ref) },
5235
6413
  metaSync: options.metaSync,
5236
6414
  logger: options.logger,
@@ -5257,12 +6435,16 @@ function createWalletEngine(options) {
5257
6435
  backup.trigger();
5258
6436
  await accounts.init();
5259
6437
  await controller.init();
6438
+ await addressBook.init();
5260
6439
  await metaSync.init();
5261
6440
  await networks.init();
5262
6441
  await assets.init();
5263
6442
  await transactions.init();
6443
+ await requests.init();
5264
6444
  } else if (!unlocked && started) {
5265
6445
  started = false;
6446
+ requests.lock();
6447
+ addressBook.lock();
5266
6448
  transactions.lock();
5267
6449
  assets.lock();
5268
6450
  networks.lock();
@@ -5302,20 +6484,16 @@ function createWalletEngine(options) {
5302
6484
  networks,
5303
6485
  assets,
5304
6486
  transactions,
6487
+ requests,
6488
+ addressBook,
5305
6489
  spark: controller,
5306
6490
  accounts,
5307
6491
  metaSync,
5308
6492
  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
- },
6493
+ zaps: { prepare: async (params) => prepareZap(params, {
6494
+ fetch: options.fetch,
6495
+ now: options.now
6496
+ }) },
5319
6497
  payments: {
5320
6498
  parse: (input, network) => parsePayment(input, {
5321
6499
  network,
@@ -5326,7 +6504,7 @@ function createWalletEngine(options) {
5326
6504
  now: options.now(),
5327
6505
  fetch: options.fetch
5328
6506
  }),
5329
- pay: async (params) => pay({
6507
+ withdrawLnurl: async (params) => withdrawLnurl({
5330
6508
  spark: controller,
5331
6509
  fetch: options.fetch,
5332
6510
  now: options.now
@@ -5369,6 +6547,6 @@ function createMemoryCloudStore(backing) {
5369
6547
  };
5370
6548
  }
5371
6549
  //#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 };
6550
+ export { AccountTreeController, AccountTreeError, AddressBookController, AddressBookError, BackupController, BackupControllerError, Controller, EngineStoreError, MetaSyncController, MetaSyncError, PaymentError, REQUEST_TTL_MS, RequestController, SparkController, SparkControllerError, createEventBus, createMemoryCloudStore, createMemoryEngineStore, createWalletEngine, decodeExitChain, encodeExitChain, parsePayment, prepareZap, resolvePayment, sparkStore };
5373
6551
 
5374
6552
  //# sourceMappingURL=index.mjs.map