@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/README.md +21 -0
- package/dist/contract-D5pguqiZ.mjs +284 -0
- package/dist/contract-D5pguqiZ.mjs.map +1 -0
- package/dist/gateway-contract.d.mts +297 -0
- package/dist/gateway-contract.mjs +1 -0
- package/dist/index.d.mts +672 -342
- package/dist/index.mjs +1904 -726
- package/dist/index.mjs.map +1 -1
- package/dist/registry-9oeWs_yZ.d.mts +25 -0
- package/package.json +12 -6
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).
|
|
1148
|
-
*
|
|
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
|
-
/**
|
|
1233
|
-
async
|
|
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 =
|
|
2385
|
+
const pending = await this.pendingCount(client, from);
|
|
1237
2386
|
const local = this.lastNonce.get(key);
|
|
1238
|
-
|
|
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.
|
|
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
|
-
/**
|
|
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"] !==
|
|
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:
|
|
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.
|
|
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
|
-
|
|
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.
|
|
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.
|
|
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
|
|
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 >
|
|
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
|
-
|
|
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
|
-
|
|
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 =
|
|
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
|
-
/**
|
|
2784
|
-
|
|
2785
|
-
|
|
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([
|
|
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
|
|
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
|
|
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
|
-
|
|
4042
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
4181
|
-
|
|
4182
|
-
|
|
4183
|
-
|
|
4184
|
-
|
|
4185
|
-
|
|
4186
|
-
|
|
4187
|
-
|
|
4188
|
-
|
|
4189
|
-
|
|
4190
|
-
|
|
4191
|
-
|
|
4192
|
-
|
|
4193
|
-
|
|
4194
|
-
|
|
4195
|
-
|
|
4196
|
-
|
|
4197
|
-
|
|
4198
|
-
|
|
4199
|
-
|
|
4200
|
-
|
|
4201
|
-
|
|
4202
|
-
|
|
4203
|
-
|
|
4204
|
-
|
|
4205
|
-
|
|
4206
|
-
|
|
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
|
-
|
|
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
|
-
|
|
4223
|
-
|
|
4224
|
-
|
|
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
|
-
|
|
4227
|
-
hash: void 0,
|
|
4228
|
-
state: "draft",
|
|
5270
|
+
target: old.id,
|
|
4229
5271
|
intent,
|
|
4230
|
-
|
|
4231
|
-
|
|
4232
|
-
|
|
4233
|
-
|
|
4234
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
5450
|
+
inputs: own.map((o) => ({
|
|
4416
5451
|
txid: old.hash ?? "",
|
|
4417
|
-
vout:
|
|
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
|
|
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
|
-
|
|
4434
|
-
poll,
|
|
4435
|
-
|
|
4436
|
-
|
|
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
|
|
4449
|
-
const { account, network, to, amount, fee
|
|
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
|
-
|
|
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
|
|
4479
|
-
|
|
4480
|
-
|
|
4481
|
-
|
|
4482
|
-
|
|
4483
|
-
|
|
4484
|
-
|
|
4485
|
-
|
|
4486
|
-
|
|
4487
|
-
|
|
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
|
-
|
|
4494
|
-
|
|
4495
|
-
|
|
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
|
|
4534
|
-
*
|
|
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
|
|
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
|
-
|
|
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
|
|
4568
|
-
|
|
4569
|
-
|
|
4570
|
-
|
|
4571
|
-
|
|
4572
|
-
|
|
4573
|
-
|
|
4574
|
-
|
|
4575
|
-
|
|
4576
|
-
|
|
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
|
-
|
|
4579
|
-
hash: void 0,
|
|
4580
|
-
state: "draft",
|
|
5725
|
+
target: old.id,
|
|
4581
5726
|
intent,
|
|
4582
|
-
|
|
4583
|
-
|
|
4584
|
-
|
|
4585
|
-
|
|
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
|
-
|
|
5742
|
+
prepare,
|
|
4631
5743
|
poll,
|
|
4632
|
-
|
|
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
|
|
4829
|
-
const { account, network, to, amount, fee
|
|
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
|
-
|
|
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
|
-
|
|
4885
|
-
|
|
4886
|
-
|
|
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
|
-
|
|
4893
|
-
|
|
4894
|
-
|
|
4895
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
5108
|
-
if (info.family === "bitcoin") return this.bitcoinRoute.
|
|
5109
|
-
return this.evmRoute.
|
|
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
|
-
*
|
|
5126
|
-
*
|
|
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
|
|
6272
|
+
async prepareReplacement(input, requestId) {
|
|
5129
6273
|
if (this.locked) throw new Error("transactions require an unlocked vault");
|
|
5130
|
-
const old = this.getState().txs[
|
|
5131
|
-
if (old === void 0) throw new Error(`unknown transaction ${
|
|
5132
|
-
if (old.
|
|
5133
|
-
|
|
5134
|
-
|
|
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
|
-
|
|
5311
|
-
|
|
5312
|
-
|
|
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
|
-
|
|
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,
|
|
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
|