@arkade-os/swap 0.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js ADDED
@@ -0,0 +1,2242 @@
1
+ import {
2
+ ARKADE_ASSET,
3
+ ARKADE_BTC,
4
+ AddressMismatch,
5
+ LIGHTNING_BTC,
6
+ LIGHTNING_RECEIVE_PAIR,
7
+ LIGHTNING_SEND_PAIR,
8
+ LockupRegistrationFailed,
9
+ MAX_MIN_CONFIRMATIONS,
10
+ MIN_CLAIM_WINDOW_SECONDS,
11
+ MIN_HEADROOM_SECONDS,
12
+ ONCHAIN_BTC,
13
+ ONCHAIN_CLAIM_MARGIN_SECONDS,
14
+ ONCHAIN_DUST_SATS,
15
+ ONCHAIN_ORDER_MARGIN_SECONDS,
16
+ ONCHAIN_RECEIVE_PAIR,
17
+ ONCHAIN_SECONDS_PER_BLOCK,
18
+ ONCHAIN_SEND_PAIR,
19
+ RFQ_PREIMAGE_TAG,
20
+ RFQ_TERMINAL_STATES,
21
+ RefundNotLocallyPossibleError,
22
+ SWAP_LOCKUP_CONTRACT_KIND,
23
+ SWAP_LOCKUP_CONTRACT_LABEL,
24
+ SWAP_LOCKUP_CONTRACT_TYPE,
25
+ SwapRefusal,
26
+ adoptSwapDescriptor,
27
+ arkadeSwapRequest,
28
+ assertFundable,
29
+ assertReceivable,
30
+ awaitOnchainFill,
31
+ buildHtlcClaim,
32
+ buildHtlcRefund,
33
+ buildPreimageMessage,
34
+ claimOnchainFill,
35
+ classifyOnchainHtlc,
36
+ deriveLightningReceive,
37
+ deriveOnchainReceive,
38
+ deriveOnchainSend,
39
+ derivePreimage,
40
+ deriveSwapSecrets,
41
+ extractPreimage,
42
+ httpTransport,
43
+ isDeterministicSigner,
44
+ lightningReceiveRequest,
45
+ lightningSendRequest,
46
+ lightningSendVtxoScript,
47
+ newPreimage,
48
+ newRfqId,
49
+ offerTermsFromQuote,
50
+ onchainHtlcScript,
51
+ onchainReceiveRequest,
52
+ onchainSendRequest,
53
+ paymentHashOf,
54
+ preimageForRfqSecrets,
55
+ randomSwapSecrets,
56
+ receiveVtxoScript,
57
+ registerLockupContract,
58
+ relayTransport,
59
+ requestLightningReceive,
60
+ requestLightningSend,
61
+ requestOnchainReceive,
62
+ requestOnchainSend,
63
+ rfqPair,
64
+ rfqSecretsOfRecord,
65
+ rfqSecretsToRecord,
66
+ sealClaimPacket,
67
+ senderIdentityForRfqSecrets,
68
+ senderIdentityForSwapRecord,
69
+ senderPubkeyForRfqSecrets,
70
+ unilateralClaimDelay,
71
+ unilateralRefundDelay,
72
+ unilateralRefundWithoutReceiverDelay,
73
+ verifyLockupAddress,
74
+ verifyReceiveInvoice
75
+ } from "./chunk-C5P7R7JT.js";
76
+
77
+ // src/offer.ts
78
+ import { hex } from "@scure/base";
79
+ import { concatBytes } from "@scure/btc-signer/utils.js";
80
+ import {
81
+ ArkAddress,
82
+ RestArkProvider,
83
+ RestIndexerProvider,
84
+ arkade,
85
+ asset,
86
+ getNetwork
87
+ } from "@arkade-os/sdk";
88
+
89
+ // src/swap-want-asset.program.json
90
+ var swap_want_asset_program_default = {
91
+ version: 0,
92
+ name: "banco-btc-to-asset",
93
+ params: [
94
+ { name: "makerWP", type: "pubkey" },
95
+ { name: "wantAmount", type: "int" },
96
+ { name: "wantAssetTxid", type: "hash" },
97
+ { name: "wantAssetGroupIndex", type: "int" },
98
+ { name: "server", type: "pubkey" },
99
+ { name: "user", type: "pubkey" }
100
+ ],
101
+ functions: {
102
+ fulfill: {
103
+ tapscript: { signers: ["$server"] },
104
+ arkadeScript: {
105
+ asm: [
106
+ 0,
107
+ "$wantAssetTxid",
108
+ "$wantAssetGroupIndex",
109
+ "INSPECTOUTASSETLOOKUP",
110
+ "VERIFY",
111
+ "$wantAmount",
112
+ "GREATERTHANOREQUAL",
113
+ "VERIFY",
114
+ 0,
115
+ "INSPECTOUTPUTSCRIPTPUBKEY",
116
+ 1,
117
+ "EQUALVERIFY",
118
+ "$makerWP",
119
+ "EQUAL"
120
+ ]
121
+ }
122
+ },
123
+ cancel: {
124
+ tapscript: { signers: ["$user", "$server"] }
125
+ }
126
+ }
127
+ };
128
+
129
+ // src/swap-want-btc.program.json
130
+ var swap_want_btc_program_default = {
131
+ version: 0,
132
+ name: "banco-asset-to-btc",
133
+ params: [
134
+ { name: "makerWP", type: "pubkey" },
135
+ { name: "wantAmount", type: "int" },
136
+ { name: "server", type: "pubkey" },
137
+ { name: "user", type: "pubkey" }
138
+ ],
139
+ functions: {
140
+ fulfill: {
141
+ tapscript: { signers: ["$server"] },
142
+ arkadeScript: {
143
+ asm: [
144
+ 0,
145
+ "INSPECTOUTPUTVALUE",
146
+ "$wantAmount",
147
+ "GREATERTHANOREQUAL",
148
+ "VERIFY",
149
+ 0,
150
+ "INSPECTOUTPUTSCRIPTPUBKEY",
151
+ 1,
152
+ "EQUALVERIFY",
153
+ "$makerWP",
154
+ "EQUAL"
155
+ ]
156
+ }
157
+ },
158
+ cancel: {
159
+ tapscript: { signers: ["$user", "$server"] }
160
+ }
161
+ }
162
+ };
163
+
164
+ // src/coverage.ts
165
+ var RETIRABLE = ["fulfilled", "cancelled"];
166
+ var issuedAt = /* @__PURE__ */ new Map();
167
+ var inFlight = /* @__PURE__ */ new Map();
168
+ async function serialize(script, task) {
169
+ const previous = inFlight.get(script) ?? Promise.resolve();
170
+ const result = previous.then(task, task);
171
+ const settled = result.then(
172
+ () => {
173
+ },
174
+ () => {
175
+ }
176
+ );
177
+ inFlight.set(script, settled);
178
+ void settled.then(() => {
179
+ if (inFlight.get(script) === settled) inFlight.delete(script);
180
+ });
181
+ return result;
182
+ }
183
+ function addressOutstanding(swaps, script) {
184
+ const issued = issuedAt.get(script);
185
+ if (issued === void 0) return false;
186
+ if (swaps.some((s) => s.swapPkScript === script && s.createdAt >= issued)) {
187
+ issuedAt.delete(script);
188
+ return false;
189
+ }
190
+ return true;
191
+ }
192
+ async function promoteOfferContract(manager, script) {
193
+ await serialize(script, async () => {
194
+ await manager.setContractWatchState(script, "watched");
195
+ issuedAt.set(script, Date.now());
196
+ });
197
+ }
198
+ async function retireOfferContract(manager, swaps, script) {
199
+ await serialize(script, async () => {
200
+ if (swaps.some((s) => s.swapPkScript === script && !RETIRABLE.includes(s.status))) return;
201
+ if (addressOutstanding(swaps, script)) return;
202
+ try {
203
+ await manager.setContractWatchState(script, "retained");
204
+ } catch (err) {
205
+ console.warn(`[swap] could not retire offer contract ${script}`, err);
206
+ }
207
+ });
208
+ }
209
+ async function retireSettledOfferContracts(manager, swaps) {
210
+ const settled = new Set(
211
+ swaps.filter((s) => RETIRABLE.includes(s.status)).map((s) => s.swapPkScript)
212
+ );
213
+ for (const script of settled) await retireOfferContract(manager, swaps, script);
214
+ }
215
+
216
+ // src/store.ts
217
+ var BTC_ASSET_ID = "btc";
218
+ var byNewest = (a, b) => b.createdAt - a.createdAt;
219
+ var getAssetSwapsOrThrow = async (repository) => {
220
+ return (await repository.getAllSwaps()).filter(
221
+ (s) => s && typeof s.id === "string" && // offer swaps carry the TLV; onchain-corridor swaps carry
222
+ // the payment hash instead — either marks a valid record
223
+ (typeof s.offerHex === "string" || typeof s.paymentHash === "string")
224
+ ).sort(byNewest);
225
+ };
226
+ var getAssetSwaps = async (repository) => {
227
+ try {
228
+ return await getAssetSwapsOrThrow(repository);
229
+ } catch {
230
+ return [];
231
+ }
232
+ };
233
+ var saveSwapOrThrow = async (repository, swap) => {
234
+ try {
235
+ await repository.saveSwap(swap);
236
+ } catch (error) {
237
+ const reason = error instanceof Error ? error.message : String(error);
238
+ throw new Error(`failed to save swap ${swap.id}: ${reason}`);
239
+ }
240
+ };
241
+ var addAssetSwap = async (repository, swap) => {
242
+ const swaps = await getAssetSwapsOrThrow(repository);
243
+ if (swaps.some((s) => s.id === swap.id)) return swaps;
244
+ await saveSwapOrThrow(repository, swap);
245
+ const at = swaps.findIndex((s) => byNewest(swap, s) <= 0);
246
+ const merged = [...swaps];
247
+ merged.splice(at === -1 ? merged.length : at, 0, swap);
248
+ return merged;
249
+ };
250
+ var updateAssetSwap = async (repository, id, changes) => {
251
+ const swaps = (await getAssetSwapsOrThrow(repository)).map(
252
+ (s) => s.id === id ? { ...s, ...changes } : s
253
+ );
254
+ const updated = swaps.find((s) => s.id === id);
255
+ if (updated) await saveSwapOrThrow(repository, updated);
256
+ return swaps;
257
+ };
258
+ var updateAssetSwapBestEffort = async (repository, id, changes) => {
259
+ try {
260
+ return { swaps: await updateAssetSwap(repository, id, changes), persisted: true };
261
+ } catch (error) {
262
+ console.warn(`[swap] failed to persist update for swap ${id}`, error);
263
+ const swaps = (await getAssetSwaps(repository)).map(
264
+ (s) => s.id === id ? { ...s, ...changes } : s
265
+ );
266
+ return { swaps, persisted: false };
267
+ }
268
+ };
269
+
270
+ // src/offer.ts
271
+ var swapPrograms = {
272
+ wantAsset: arkade.parseArtifact(swap_want_asset_program_default),
273
+ wantBtc: arkade.parseArtifact(swap_want_btc_program_default)
274
+ };
275
+ function swapProgramBinding(offer, serverPubkey) {
276
+ if (offer.makerPkScript.length !== FIELDS.makerPkScript.width) {
277
+ throw new Error("makerPkScript is not a 34-byte taproot scriptPubKey");
278
+ }
279
+ return {
280
+ program: offer.wantAsset ? swapPrograms.wantAsset : swapPrograms.wantBtc,
281
+ args: {
282
+ makerWP: offer.makerPkScript.subarray(2),
283
+ wantAmount: offer.wantAmount,
284
+ server: serverPubkey,
285
+ user: offer.makerPublicKey,
286
+ // internal byte order
287
+ ...offer.wantAsset && {
288
+ wantAssetTxid: offer.wantAsset.txid.slice().reverse(),
289
+ wantAssetGroupIndex: offer.wantAsset.groupIndex
290
+ }
291
+ },
292
+ keys: {
293
+ serverKey: serverPubkey,
294
+ userKey: offer.makerPublicKey,
295
+ emulatorKey: offer.emulatorPubkey
296
+ }
297
+ };
298
+ }
299
+ function offerVtxoScript(offer, serverPubkey) {
300
+ const { program, args, keys } = swapProgramBinding(offer, serverPubkey);
301
+ return new arkade.ArkadeProgramScript(program, args, keys);
302
+ }
303
+ var OFFER_PACKET_TYPE = 3;
304
+ var FIELDS = {
305
+ swapPkScript: { tag: 1, width: 34 },
306
+ wantAmount: { tag: 2, width: 8 },
307
+ wantAsset: { tag: 3, width: void 0 },
308
+ makerPkScript: { tag: 5, width: 34 },
309
+ makerPublicKey: { tag: 7, width: 32 },
310
+ emulatorPubkey: { tag: 8, width: 32 },
311
+ offerAsset: { tag: 11, width: void 0 }
312
+ };
313
+ var NAMES = Object.fromEntries(Object.entries(FIELDS).map(([k, f]) => [f.tag, k]));
314
+ var XONLY_LEN = 32;
315
+ var xOnly = (key, label) => {
316
+ if (key.length === XONLY_LEN) return key;
317
+ if (key.length !== 33 || key[0] !== 2 && key[0] !== 3) {
318
+ throw new Error(`${label} is not a compressed or x-only public key`);
319
+ }
320
+ return key.slice(1);
321
+ };
322
+ function tlv(type, value) {
323
+ if (value.length > 65535) throw new Error("TLV value exceeds the u16 length field");
324
+ return concatBytes(Uint8Array.of(type, value.length >> 8 & 255, value.length & 255), value);
325
+ }
326
+ function encodeOffer(offer) {
327
+ if (Boolean(offer.wantAsset) === Boolean(offer.offerAsset)) {
328
+ throw new Error("offer must carry exactly one of wantAsset or offerAsset");
329
+ }
330
+ for (const name of [
331
+ "swapPkScript",
332
+ "makerPkScript",
333
+ "makerPublicKey",
334
+ "emulatorPubkey"
335
+ ]) {
336
+ if (offer[name].length !== FIELDS[name].width) {
337
+ throw new Error(`${name} must be ${FIELDS[name].width} bytes`);
338
+ }
339
+ }
340
+ if (offer.wantAmount < BigInt(0) || offer.wantAmount >> BigInt(64) > BigInt(0)) {
341
+ throw new Error("wantAmount does not fit the offer wire format (u64)");
342
+ }
343
+ const amount = new Uint8Array(FIELDS.wantAmount.width);
344
+ new DataView(amount.buffer).setBigUint64(0, offer.wantAmount, false);
345
+ const recs = [
346
+ tlv(FIELDS.swapPkScript.tag, offer.swapPkScript),
347
+ tlv(FIELDS.wantAmount.tag, amount)
348
+ ];
349
+ if (offer.wantAsset) recs.push(tlv(FIELDS.wantAsset.tag, offer.wantAsset.serialize()));
350
+ if (offer.offerAsset) recs.push(tlv(FIELDS.offerAsset.tag, offer.offerAsset.serialize()));
351
+ recs.push(
352
+ tlv(FIELDS.makerPkScript.tag, offer.makerPkScript),
353
+ tlv(FIELDS.makerPublicKey.tag, offer.makerPublicKey),
354
+ tlv(FIELDS.emulatorPubkey.tag, offer.emulatorPubkey)
355
+ );
356
+ return concatBytes(...recs);
357
+ }
358
+ function decodeOffer(data) {
359
+ const fields = {};
360
+ let off = 0;
361
+ while (off < data.length) {
362
+ if (off + 3 > data.length) throw new Error("truncated TLV header");
363
+ const type = data[off];
364
+ const length = data[off + 1] << 8 | data[off + 2];
365
+ off += 3;
366
+ if (off + length > data.length)
367
+ throw new Error(`truncated TLV value for type 0x${type.toString(16)}`);
368
+ const name = NAMES[type];
369
+ if (!name) throw new Error(`unknown TLV type: 0x${type.toString(16)}`);
370
+ if (fields[name] !== void 0) throw new Error(`duplicate TLV record: ${name}`);
371
+ fields[name] = data.slice(off, off + length);
372
+ off += length;
373
+ }
374
+ for (const name of ["wantAsset", "offerAsset"]) {
375
+ if (fields[name]?.length === 0) throw new Error(`missing/invalid ${name}`);
376
+ }
377
+ const need = (name) => {
378
+ const v = fields[name];
379
+ const len = FIELDS[name].width;
380
+ if (!v || len !== void 0 && v.length !== len)
381
+ throw new Error(`missing/invalid ${name}`);
382
+ return v;
383
+ };
384
+ const amount = need("wantAmount");
385
+ if (Boolean(fields.wantAsset) === Boolean(fields.offerAsset)) {
386
+ throw new Error("offer must carry exactly one of wantAsset or offerAsset");
387
+ }
388
+ return {
389
+ swapPkScript: need("swapPkScript"),
390
+ wantAmount: new DataView(amount.buffer, amount.byteOffset).getBigUint64(0, false),
391
+ ...fields.wantAsset && { wantAsset: asset.AssetId.fromBytes(fields.wantAsset) },
392
+ ...fields.offerAsset && { offerAsset: asset.AssetId.fromBytes(fields.offerAsset) },
393
+ makerPkScript: need("makerPkScript"),
394
+ makerPublicKey: need("makerPublicKey"),
395
+ emulatorPubkey: need("emulatorPubkey")
396
+ };
397
+ }
398
+ var OFFER_CONTRACT_LABEL = "Arkade swap offer";
399
+ var OFFER_CONTRACT_KIND = "asset-swap-offer";
400
+ async function registerOfferContract(wallet, arkServerUrl, network, binding, serverPubkey, expectedPkScript) {
401
+ const { program, args, keys } = swapProgramBinding(binding, serverPubkey);
402
+ const contractManager = await wallet.getContractManager();
403
+ const client = await arkade.Arkade.connect({
404
+ arkade: new RestArkProvider(arkServerUrl),
405
+ indexer: new RestIndexerProvider(arkServerUrl),
406
+ identity: wallet.identity,
407
+ // without this the row's `address` would be derived against the SDK's
408
+ // default network while its script is right — a row that disagrees with
409
+ // the address the user is about to fund
410
+ network: getNetwork(network),
411
+ contractManager
412
+ });
413
+ const contract = new arkade.ArkadeContract(client, program, args, keys);
414
+ if (hex.encode(contract.pkScript) !== hex.encode(expectedPkScript)) {
415
+ throw new Error("derived covenant does not match the offer's swapPkScript");
416
+ }
417
+ await contract.register({
418
+ label: OFFER_CONTRACT_LABEL,
419
+ metadata: { genericallySpendable: false, kind: OFFER_CONTRACT_KIND }
420
+ });
421
+ await promoteOfferContract(contractManager, hex.encode(expectedPkScript));
422
+ }
423
+ async function createOffer(wallet, arkServerUrl, emulatorPubkey, params) {
424
+ if (Boolean(params.wantAsset) === Boolean(params.offerAsset)) {
425
+ throw new Error("set exactly one of wantAsset (BTC->asset) or offerAsset (asset->BTC)");
426
+ }
427
+ const [info, makerAddress, makerPublicKey] = await Promise.all([
428
+ new RestArkProvider(arkServerUrl).getInfo(),
429
+ wallet.getAddress(),
430
+ wallet.identity.xOnlyPublicKey()
431
+ ]);
432
+ const serverPubKey = xOnly(hex.decode(info.signerPubkey), "ark signer key");
433
+ const emuKey = xOnly(emulatorPubkey, "emulator pubkey");
434
+ const binding = {
435
+ wantAmount: params.wantAmount,
436
+ wantAsset: params.wantAsset,
437
+ offerAsset: params.offerAsset,
438
+ makerPkScript: ArkAddress.decode(makerAddress).pkScript,
439
+ makerPublicKey,
440
+ emulatorPubkey: emuKey
441
+ };
442
+ const script = offerVtxoScript(binding, serverPubKey);
443
+ const offer = { ...binding, swapPkScript: script.pkScript };
444
+ await registerOfferContract(
445
+ wallet,
446
+ arkServerUrl,
447
+ info.network,
448
+ binding,
449
+ serverPubKey,
450
+ script.pkScript
451
+ );
452
+ const payload = encodeOffer(offer);
453
+ return {
454
+ offerHex: hex.encode(payload),
455
+ extension: { type: OFFER_PACKET_TYPE, payload },
456
+ // VtxoScript.address owns address construction; assembling an ArkAddress
457
+ // from tweakedPublicKey here would silently miss any future step it gains
458
+ address: script.address(getNetwork(info.network).hrp, serverPubKey).encode(),
459
+ swapPkScript: script.pkScript
460
+ };
461
+ }
462
+ async function cancelOffer(wallet, arkServerUrl, offerHex, opts) {
463
+ const { repository, fundingTxid, swapAddress } = opts;
464
+ const offer = decodeOffer(hex.decode(offerHex));
465
+ const contractManager = await wallet.getContractManager();
466
+ const client = await arkade.Arkade.connect({
467
+ arkade: new RestArkProvider(arkServerUrl),
468
+ indexer: new RestIndexerProvider(arkServerUrl),
469
+ identity: wallet.identity,
470
+ // registered offers resolve their VTXOs from the contract repository
471
+ // instead of a direct indexer query; the indexer above stays as the
472
+ // fallback for offers created before registration existed
473
+ contractManager
474
+ // no `network`, unlike registerOfferContract: the row lookup is by
475
+ // script and the payout script comes from wallet.getAddress(), so the
476
+ // client's network (which only shapes address derivation) is unused here
477
+ });
478
+ const serverKey = swapAddress ? ArkAddress.decode(swapAddress).serverPubKey : client.serverKey;
479
+ const { program, args, keys } = swapProgramBinding(offer, serverKey);
480
+ const rebuilt = new arkade.ArkadeProgramScript(program, args, keys);
481
+ if (hex.encode(rebuilt.pkScript) !== hex.encode(offer.swapPkScript)) {
482
+ throw new Error(
483
+ "rebuilt covenant does not match the offer's swapPkScript \u2014 the server signing key has likely rotated since funding; pass swapAddress (the funded address) to pin the original key"
484
+ );
485
+ }
486
+ const contract = new arkade.ArkadeContract(client, program, args, keys);
487
+ const [vtxos, makerAddress] = await Promise.all([contract.getUtxos(), wallet.getAddress()]);
488
+ if (!fundingTxid && vtxos.length > 1) {
489
+ throw new Error(
490
+ "multiple spendable deposits at the swap address \u2014 pass fundingTxid to select one"
491
+ );
492
+ }
493
+ const vtxo = fundingTxid ? vtxos.find((v) => v.txid === fundingTxid) : vtxos[0];
494
+ if (!vtxo) throw new Error("no spendable VTXO at the swap address");
495
+ const makerPkScript = ArkAddress.decode(makerAddress).pkScript;
496
+ const cancel = contract.functions.cancel().from({ txid: vtxo.txid, vout: vtxo.vout, value: vtxo.value }).to(makerPkScript, BigInt(vtxo.value));
497
+ for (const a of vtxo.assets ?? []) {
498
+ cancel.withAsset({
499
+ assetId: a.assetId,
500
+ inputs: [{ vin: 0, amount: BigInt(a.amount) }],
501
+ outputs: [{ vout: 0, amount: BigInt(a.amount) }]
502
+ });
503
+ }
504
+ const swapId = fundingTxid ?? vtxo.txid;
505
+ const hasLocalRecord = (await getAssetSwapsOrThrow(repository)).some((s) => s.id === swapId);
506
+ if (hasLocalRecord) await updateAssetSwap(repository, swapId, { status: "cancelling" });
507
+ const { txid } = await cancel.send();
508
+ if (hasLocalRecord) {
509
+ const { persisted, swaps } = await updateAssetSwapBestEffort(repository, swapId, {
510
+ status: "cancelled",
511
+ spentTxid: txid
512
+ });
513
+ if (persisted) {
514
+ await retireOfferContract(contractManager, swaps, hex.encode(offer.swapPkScript));
515
+ }
516
+ }
517
+ return txid;
518
+ }
519
+
520
+ // src/markets.ts
521
+ import {
522
+ bestMarket,
523
+ discover,
524
+ isNetwork,
525
+ sideLimits
526
+ } from "@arkade-os/solver-discovery";
527
+ import { isSubdust } from "@arkade-os/sdk";
528
+ var QUOTE_OPTIONS = { safetyBps: 0 };
529
+ var makeCachedFeedFetch = (ttlMs = 3e4, fetchImpl = fetch) => {
530
+ const cache = /* @__PURE__ */ new Map();
531
+ const inflight = /* @__PURE__ */ new Map();
532
+ return async (input, init) => {
533
+ const url = input instanceof Request ? input.url : String(input);
534
+ const hit = cache.get(url);
535
+ if (hit) {
536
+ if (Date.now() - hit.at < ttlMs) return new Response(hit.body);
537
+ cache.delete(url);
538
+ }
539
+ const pending = inflight.get(url);
540
+ if (pending) {
541
+ const body = await pending;
542
+ if (body !== void 0) return new Response(body);
543
+ }
544
+ let settle = () => {
545
+ };
546
+ inflight.set(
547
+ url,
548
+ new Promise((resolve) => {
549
+ settle = resolve;
550
+ })
551
+ );
552
+ try {
553
+ const response = await fetchImpl(input, init);
554
+ let body;
555
+ if (response.ok) {
556
+ try {
557
+ body = await response.clone().text();
558
+ cache.set(url, { at: Date.now(), body });
559
+ } catch {
560
+ body = void 0;
561
+ }
562
+ }
563
+ settle(body);
564
+ return response;
565
+ } catch (err) {
566
+ settle(void 0);
567
+ throw err;
568
+ } finally {
569
+ inflight.delete(url);
570
+ }
571
+ };
572
+ };
573
+ var MARKETS_CACHE_TTL_MS = 60 * 60 * 1e3;
574
+ var isMarketShaped = (m) => {
575
+ const market = m;
576
+ return typeof market?.pair === "string" && typeof market.base_asset?.id === "string" && typeof market.quote_asset?.id === "string" && typeof market.quote_asset.decimals === "number";
577
+ };
578
+ var readMarketsCache = async (repository, network, registry) => {
579
+ try {
580
+ const entry = await repository.getCachedMarkets(network, registry);
581
+ if (!Array.isArray(entry?.markets) || typeof entry?.fetchedAt !== "number")
582
+ return void 0;
583
+ return entry.markets.every(isMarketShaped) ? entry : void 0;
584
+ } catch {
585
+ return void 0;
586
+ }
587
+ };
588
+ var discoverMarkets = async (options) => {
589
+ const {
590
+ network,
591
+ registryUrl: registry,
592
+ repository,
593
+ localCards = [],
594
+ logger,
595
+ fetchImpl,
596
+ useCache = true
597
+ } = options;
598
+ if (!registry || !isNetwork(network)) return [];
599
+ const cached = repository && await readMarketsCache(repository, network, registry);
600
+ if (useCache && cached && Date.now() - cached.fetchedAt < MARKETS_CACHE_TTL_MS)
601
+ return cached.markets;
602
+ const { markets, sources, warnings } = await discover({
603
+ registries: [registry],
604
+ localCards,
605
+ network,
606
+ fetchImpl
607
+ });
608
+ if (warnings.length) logger?.("solver discovery:", ...warnings);
609
+ const reachable = sources.some((source) => source.ok);
610
+ if (!reachable && cached) return cached.markets;
611
+ if (reachable && repository) {
612
+ try {
613
+ await repository.saveCachedMarkets(network, registry, {
614
+ markets,
615
+ fetchedAt: Date.now()
616
+ });
617
+ } catch {
618
+ }
619
+ }
620
+ return markets;
621
+ };
622
+ var findMarket = (markets, fromId, toId) => {
623
+ if (fromId === toId) return void 0;
624
+ const givingBase = bestMarket(markets, { baseId: fromId, quoteId: toId, wantSide: "quote" });
625
+ if (givingBase) return { market: givingBase, give: "base" };
626
+ return {
627
+ market: bestMarket(markets, { baseId: toId, quoteId: fromId, wantSide: "base" }),
628
+ give: "quote"
629
+ };
630
+ };
631
+ var validatePlan = (plan, giveBalance, dust) => {
632
+ if (plan.deposit.atomic > giveBalance) return "insufficient-balance";
633
+ const { min, max, withinLimits } = plan.limits;
634
+ if (!min || !max) return "side-disabled";
635
+ const giveLimits = sideLimits(plan.market, plan.give);
636
+ if (!giveLimits) return "side-disabled";
637
+ if (plan.deposit.atomic < giveLimits.min) return "below-min";
638
+ if (plan.deposit.atomic > giveLimits.max) return "above-max";
639
+ if (!withinLimits) return plan.receive.atomic < min.atomic ? "below-min" : "above-max";
640
+ const depositIsBtc = plan.deposit.asset.id === BTC_ASSET_ID;
641
+ const receiveIsBtc = plan.receive.asset.id === BTC_ASSET_ID;
642
+ if (depositIsBtc || receiveIsBtc) {
643
+ const btcSide = depositIsBtc ? plan.deposit.atomic : plan.receive.atomic;
644
+ if (isSubdust(btcSide, dust)) return "below-dust";
645
+ }
646
+ return void 0;
647
+ };
648
+
649
+ // src/repository.ts
650
+ var marketsCacheKey = (network, registry) => `arkade-intents-markets-${network}-${registry}`;
651
+ var InMemoryAssetSwapRepository = class {
652
+ version = 1;
653
+ swaps = /* @__PURE__ */ new Map();
654
+ scanned = /* @__PURE__ */ new Set();
655
+ markets = /* @__PURE__ */ new Map();
656
+ async saveSwap(swap) {
657
+ this.swaps.set(swap.id, swap);
658
+ }
659
+ async getAllSwaps() {
660
+ return [...this.swaps.values()];
661
+ }
662
+ async getScannedTxids() {
663
+ return new Set(this.scanned);
664
+ }
665
+ async markTxidsScanned(txids) {
666
+ for (const txid of txids) this.scanned.add(txid);
667
+ }
668
+ async getCachedMarkets(network, registry) {
669
+ return this.markets.get(marketsCacheKey(network, registry));
670
+ }
671
+ async saveCachedMarkets(network, registry, entry) {
672
+ this.markets.set(marketsCacheKey(network, registry), entry);
673
+ }
674
+ async clear() {
675
+ this.swaps.clear();
676
+ this.scanned.clear();
677
+ this.markets.clear();
678
+ }
679
+ async [Symbol.asyncDispose]() {
680
+ }
681
+ };
682
+
683
+ // src/indexedDbRepository.ts
684
+ import { closeDatabase, openDatabase } from "@arkade-os/sdk";
685
+ var DEFAULT_DB_NAME = "arkade-intents";
686
+ var DB_VERSION = 1;
687
+ var STORE_SWAPS = "swaps";
688
+ var STORE_SCANNED = "scannedTxids";
689
+ var STORE_MARKETS = "markets";
690
+ var STORES = [
691
+ [STORE_SWAPS, { keyPath: "id" }],
692
+ [STORE_SCANNED],
693
+ [STORE_MARKETS]
694
+ ];
695
+ function initDatabase(db) {
696
+ for (const [name, options] of STORES) {
697
+ if (!db.objectStoreNames.contains(name)) db.createObjectStore(name, options);
698
+ }
699
+ }
700
+ var request = (req) => new Promise((resolve, reject) => {
701
+ req.onsuccess = () => resolve(req.result);
702
+ req.onerror = () => reject(req.error);
703
+ });
704
+ var txDone = (tx) => new Promise((resolve, reject) => {
705
+ tx.oncomplete = () => resolve();
706
+ tx.onerror = () => reject(tx.error);
707
+ tx.onabort = () => reject(tx.error);
708
+ });
709
+ var IndexedDbAssetSwapRepository = class {
710
+ constructor(dbName = DEFAULT_DB_NAME) {
711
+ this.dbName = dbName;
712
+ }
713
+ dbName;
714
+ version = 1;
715
+ // the promise, not the resolved database: openDatabase bumps a refcount on
716
+ // every call including cache hits, while dispose closes once, so two
717
+ // concurrent first calls would strand the refcount above zero and leak the
718
+ // connection for the process lifetime. Cleared on failure so a failed open
719
+ // can be retried rather than cached forever.
720
+ dbPromise = null;
721
+ ensureDb() {
722
+ return this.dbPromise ??= openDatabase(this.dbName, DB_VERSION, initDatabase).catch(
723
+ (err) => {
724
+ this.dbPromise = null;
725
+ throw err;
726
+ }
727
+ );
728
+ }
729
+ async readStore(name) {
730
+ return (await this.ensureDb()).transaction([name], "readonly").objectStore(name);
731
+ }
732
+ /** Every write in one place, so none of them can forget to await the
733
+ * commit. Requests need no individual await: a failed one aborts the
734
+ * transaction, which `txDone` reports. */
735
+ async write(name, apply) {
736
+ const tx = (await this.ensureDb()).transaction([name], "readwrite");
737
+ const done = txDone(tx);
738
+ apply(tx.objectStore(name));
739
+ await done;
740
+ }
741
+ async saveSwap(swap) {
742
+ await this.write(STORE_SWAPS, (store) => {
743
+ store.put(swap);
744
+ });
745
+ }
746
+ async getAllSwaps() {
747
+ return request((await this.readStore(STORE_SWAPS)).getAll());
748
+ }
749
+ async getScannedTxids() {
750
+ const keys = await request((await this.readStore(STORE_SCANNED)).getAllKeys());
751
+ return new Set(keys);
752
+ }
753
+ async markTxidsScanned(txids) {
754
+ await this.write(STORE_SCANNED, (store) => {
755
+ for (const txid of txids) store.put(txid, txid);
756
+ });
757
+ }
758
+ async getCachedMarkets(network, registry) {
759
+ const store = await this.readStore(STORE_MARKETS);
760
+ return request(store.get(marketsCacheKey(network, registry)));
761
+ }
762
+ async saveCachedMarkets(network, registry, entry) {
763
+ await this.write(STORE_MARKETS, (store) => {
764
+ store.put(entry, marketsCacheKey(network, registry));
765
+ });
766
+ }
767
+ /** All stores in one transaction: clearing swaps but keeping scanned txids
768
+ * would leave the restore scan permanently skipping those funding txs, so
769
+ * a partial clear must not be observable. */
770
+ async clear() {
771
+ const stores = STORES.map(([name]) => name);
772
+ const tx = (await this.ensureDb()).transaction(stores, "readwrite");
773
+ const done = txDone(tx);
774
+ for (const name of stores) tx.objectStore(name).clear();
775
+ await done;
776
+ }
777
+ async [Symbol.asyncDispose]() {
778
+ if (!this.dbPromise) return;
779
+ await closeDatabase(this.dbName);
780
+ this.dbPromise = null;
781
+ }
782
+ };
783
+
784
+ // src/restore.ts
785
+ import { base64, hex as hex2 } from "@scure/base";
786
+ import {
787
+ Extension,
788
+ Transaction,
789
+ scriptFromTapLeafScript
790
+ } from "@arkade-os/sdk";
791
+ var TXS_PER_REQUEST = 50;
792
+ async function fetchParsedTxs(indexer, txids) {
793
+ const parsedByTxid = /* @__PURE__ */ new Map();
794
+ if (txids.length === 0) return parsedByTxid;
795
+ const chunks = [];
796
+ for (let i = 0; i < txids.length; i += TXS_PER_REQUEST) {
797
+ chunks.push(txids.slice(i, i + TXS_PER_REQUEST));
798
+ }
799
+ const chunkResults = await Promise.allSettled(
800
+ chunks.map(async (ids) => (await indexer.getVirtualTxs(ids)).txs)
801
+ );
802
+ for (const result of chunkResults) {
803
+ if (result.status !== "fulfilled") continue;
804
+ for (const psbt of result.value) {
805
+ try {
806
+ const parsed = Transaction.fromPSBT(base64.decode(psbt));
807
+ parsedByTxid.set(parsed.id, parsed);
808
+ } catch {
809
+ }
810
+ }
811
+ }
812
+ return parsedByTxid;
813
+ }
814
+ var unscannedSwapCandidates = (txs, existingIds, scanned) => txs.filter(
815
+ (tx) => tx.type === "sent" && tx.redeemTxid && !existingIds.has(tx.redeemTxid) && !scanned.has(tx.redeemTxid)
816
+ );
817
+ function classifySpend(offer, serverPubkey, spendTx, deposit) {
818
+ let leaves;
819
+ try {
820
+ const script = offerVtxoScript(offer, serverPubkey);
821
+ if (hex2.encode(script.pkScript) !== hex2.encode(offer.swapPkScript)) return "indeterminate";
822
+ leaves = {
823
+ cancel: script.functionByName("cancel")?.leafScript,
824
+ fulfill: script.functionByName("fulfill")?.leafScript
825
+ };
826
+ } catch {
827
+ return "indeterminate";
828
+ }
829
+ for (let i = 0; i < spendTx.inputsLength; i++) {
830
+ const input = spendTx.getInput(i);
831
+ if (!input.txid || input.index !== deposit.vout) continue;
832
+ if (hex2.encode(input.txid) !== deposit.txid) continue;
833
+ for (const leaf of input.tapLeafScript ?? []) {
834
+ const spent = hex2.encode(scriptFromTapLeafScript(leaf));
835
+ if (leaves.cancel && spent === hex2.encode(leaves.cancel)) return "cancelled";
836
+ if (leaves.fulfill && spent === hex2.encode(leaves.fulfill)) return "fulfilled";
837
+ }
838
+ }
839
+ return "indeterminate";
840
+ }
841
+ var spendTxidsOf = (vtxo) => [vtxo.spentBy, vtxo.arkTxId].filter((id) => Boolean(id));
842
+ function classifyDepositSpend(offer, serverPubkey, spendTxs, deposit) {
843
+ for (const tx of spendTxs) {
844
+ const kind = classifySpend(offer, serverPubkey, tx, deposit);
845
+ if (kind !== "indeterminate") return kind;
846
+ }
847
+ return "indeterminate";
848
+ }
849
+ async function restoreAssetSwaps(indexer, txs, existingIds, opts) {
850
+ const { serverPubkey, scanned = /* @__PURE__ */ new Set() } = opts;
851
+ const candidates = unscannedSwapCandidates(txs, existingIds, scanned);
852
+ if (candidates.length === 0) return { restored: [], scannedTxids: [] };
853
+ const byTxid = new Map(candidates.map((tx) => [tx.redeemTxid, tx]));
854
+ const parsedByTxid = await fetchParsedTxs(
855
+ indexer,
856
+ candidates.map((tx) => tx.redeemTxid)
857
+ );
858
+ const fetchedTxids = [];
859
+ const found = [];
860
+ for (const [txid, parsed] of parsedByTxid) {
861
+ const fundingTx = byTxid.get(txid);
862
+ if (!fundingTx) continue;
863
+ fetchedTxids.push(txid);
864
+ try {
865
+ const packet = Extension.fromTx(parsed).getPacketByType(OFFER_PACKET_TYPE);
866
+ if (!packet) continue;
867
+ const payload = packet.serialize();
868
+ found.push({
869
+ fundingTx,
870
+ offer: decodeOffer(payload),
871
+ offerHex: hex2.encode(payload)
872
+ });
873
+ } catch {
874
+ }
875
+ }
876
+ if (found.length === 0) return { restored: [], scannedTxids: fetchedTxids };
877
+ const scripts = [...new Set(found.map((f) => hex2.encode(f.offer.swapPkScript)))];
878
+ const { vtxos } = await indexer.getVtxos({ scripts });
879
+ const vtxoByScriptAndTxid = new Map(vtxos.map((v) => [`${v.script}:${v.txid}`, v]));
880
+ const txByAnyId = /* @__PURE__ */ new Map();
881
+ for (const tx of txs) {
882
+ for (const id of [tx.boardingTxid, tx.redeemTxid, tx.roundTxid]) {
883
+ if (id) txByAnyId.set(id, tx);
884
+ }
885
+ }
886
+ const spendTxids = /* @__PURE__ */ new Set();
887
+ for (const { fundingTx, offer } of found) {
888
+ const vtxo = vtxoByScriptAndTxid.get(
889
+ `${hex2.encode(offer.swapPkScript)}:${fundingTx.redeemTxid}`
890
+ );
891
+ if (vtxo?.virtualStatus.state !== "spent") continue;
892
+ for (const txid of spendTxidsOf(vtxo)) spendTxids.add(txid);
893
+ }
894
+ const spendTxByTxid = await fetchParsedTxs(indexer, [...spendTxids]);
895
+ const restored = [];
896
+ const unresolved = /* @__PURE__ */ new Set();
897
+ for (const { fundingTx, offer, offerHex } of found) {
898
+ const swapPkScript = hex2.encode(offer.swapPkScript);
899
+ const vtxo = vtxoByScriptAndTxid.get(`${swapPkScript}:${fundingTx.redeemTxid}`);
900
+ if (!vtxo) {
901
+ unresolved.add(fundingTx.redeemTxid);
902
+ continue;
903
+ }
904
+ const depositRider = vtxo.assets?.length === 1 && vtxo.assets[0].amount > BigInt(0) ? vtxo.assets[0] : void 0;
905
+ const fromAsset = offer.offerAsset?.toString() ?? depositRider?.assetId ?? BTC_ASSET_ID;
906
+ const toAsset = offer.wantAsset?.toString() ?? BTC_ASSET_ID;
907
+ const depositAmount = fromAsset === BTC_ASSET_ID ? BigInt(vtxo.value) : vtxo.assets?.find((a) => a.assetId === fromAsset)?.amount;
908
+ if (depositAmount === void 0) {
909
+ unresolved.add(fundingTx.redeemTxid);
910
+ continue;
911
+ }
912
+ const fromAmount = depositAmount.toString();
913
+ const state = vtxo.virtualStatus.state;
914
+ const spentTxid = state === "spent" ? vtxo.arkTxId || vtxo.spentBy : void 0;
915
+ let status = "pending";
916
+ if (state === "swept") status = "recoverable";
917
+ else if (state === "spent") {
918
+ const spendTxs = spendTxidsOf(vtxo).map((id) => spendTxByTxid.get(id)).filter((tx) => tx !== void 0);
919
+ const kind = classifyDepositSpend(offer, serverPubkey, spendTxs, {
920
+ txid: vtxo.txid,
921
+ vout: vtxo.vout
922
+ });
923
+ if (kind === "indeterminate") {
924
+ unresolved.add(fundingTx.redeemTxid);
925
+ continue;
926
+ }
927
+ status = kind;
928
+ }
929
+ restored.push({
930
+ id: fundingTx.redeemTxid,
931
+ fromAsset,
932
+ toAsset,
933
+ fromAmount,
934
+ toAmount: offer.wantAmount.toString(),
935
+ // ponytail(arkade-os/ts-sdk#680): empty address makes cancel fall back
936
+ // to the current server key; store the funded address if server-key
937
+ // rotations become real (cancelOffer now at least diagnoses the
938
+ // mismatch instead of reporting a missing VTXO)
939
+ swapAddress: "",
940
+ swapPkScript,
941
+ offerHex,
942
+ fundingTxid: fundingTx.redeemTxid,
943
+ spentTxid,
944
+ status,
945
+ createdAt: fundingTx.createdAt ? fundingTx.createdAt * 1e3 : vtxo.createdAt.getTime(),
946
+ // the completion time is the caller's record of the spend, if it
947
+ // has one — the psbt that classified it carries no timestamp
948
+ ...status === "fulfilled" && spentTxid && txByAnyId.get(spentTxid)?.createdAt ? { completedAt: txByAnyId.get(spentTxid).createdAt * 1e3 } : {}
949
+ });
950
+ }
951
+ return { restored, scannedTxids: fetchedTxids.filter((id) => !unresolved.has(id)) };
952
+ }
953
+
954
+ // src/watch.ts
955
+ import { base64 as base642, hex as hex3 } from "@scure/base";
956
+ import {
957
+ ArkAddress as ArkAddress2,
958
+ RestIndexerProvider as RestIndexerProvider3,
959
+ Transaction as Transaction2
960
+ } from "@arkade-os/sdk";
961
+ var TERMINAL = ["fulfilled", "cancelled", "recoverable"];
962
+ function spendUpdate(swap, spend) {
963
+ if (TERMINAL.includes(swap.status)) return void 0;
964
+ if (spend.kind === "indeterminate") return void 0;
965
+ const status = spend.kind === "cancelled" ? "cancelled" : "fulfilled";
966
+ return {
967
+ status,
968
+ spentTxid: spend.txid,
969
+ // mirrors restore.ts: a completion time is a fill's, not a cancel's
970
+ ...status === "fulfilled" && spend.at ? { completedAt: spend.at } : {}
971
+ };
972
+ }
973
+ async function watchOfferSwaps({
974
+ wallet,
975
+ arkServerUrl,
976
+ repository,
977
+ onUpdate
978
+ }) {
979
+ const manager = await wallet.getContractManager();
980
+ const serverPubkey = ArkAddress2.decode(await wallet.getAddress()).serverPubKey;
981
+ const indexer = new RestIndexerProvider3(arkServerUrl);
982
+ let queue = Promise.resolve();
983
+ const enqueue = (task) => {
984
+ queue = queue.then(task).catch(() => {
985
+ });
986
+ };
987
+ const classify = async (swap, vtxo, spentTxid) => {
988
+ if (swap.spentTxid === spentTxid && swap.status === "cancelling") return "cancelled";
989
+ try {
990
+ const candidates = spendTxidsOf(vtxo);
991
+ if (candidates.length === 0) return "indeterminate";
992
+ const { txs } = await indexer.getVirtualTxs(candidates);
993
+ return classifyDepositSpend(
994
+ decodeOffer(hex3.decode(swap.offerHex)),
995
+ serverPubkey,
996
+ txs.map((psbt) => Transaction2.fromPSBT(base642.decode(psbt))),
997
+ { txid: vtxo.txid, vout: vtxo.vout }
998
+ );
999
+ } catch {
1000
+ return "indeterminate";
1001
+ }
1002
+ };
1003
+ const handleSpend = async (event) => {
1004
+ if (event.contract.metadata?.kind !== OFFER_CONTRACT_KIND) return;
1005
+ for (const vtxo of event.vtxos) {
1006
+ const spentTxid = vtxo.arkTxId || vtxo.spentBy;
1007
+ if (!spentTxid) continue;
1008
+ const swap = (await getAssetSwaps(repository)).find(
1009
+ (s) => s.fundingTxid === vtxo.txid && s.swapPkScript === event.contractScript
1010
+ );
1011
+ if (!swap) continue;
1012
+ const kind = await classify(swap, vtxo, spentTxid);
1013
+ const changes = spendUpdate(swap, { txid: spentTxid, kind, at: event.timestamp });
1014
+ if (!changes) continue;
1015
+ const { persisted, swaps } = await updateAssetSwapBestEffort(
1016
+ repository,
1017
+ swap.id,
1018
+ changes
1019
+ );
1020
+ if (!persisted) continue;
1021
+ onUpdate?.({ ...swap, ...changes });
1022
+ if (changes.status && RETIRABLE.includes(changes.status)) {
1023
+ await retireOfferContract(manager, swaps, event.contractScript);
1024
+ }
1025
+ }
1026
+ };
1027
+ const unsubscribe = manager.onContractEvent((event) => {
1028
+ if (event.type !== "vtxo_spent") return;
1029
+ enqueue(() => handleSpend(event));
1030
+ });
1031
+ return {
1032
+ stop: unsubscribe,
1033
+ idle: () => queue
1034
+ };
1035
+ }
1036
+
1037
+ // src/claim.ts
1038
+ import { hex as hex5 } from "@scure/base";
1039
+ import { ripemd160 } from "@noble/hashes/legacy.js";
1040
+ import { sha256 as sha2562 } from "@noble/hashes/sha2.js";
1041
+ import {
1042
+ CSVMultisigTapscript as CSVMultisigTapscript2,
1043
+ claimWithPreimageIdentity,
1044
+ signAndSubmitOffchainTx
1045
+ } from "@arkade-os/sdk";
1046
+
1047
+ // src/refund.ts
1048
+ import { base64 as base643, hex as hex4 } from "@scure/base";
1049
+ import { sha256 } from "@noble/hashes/sha2.js";
1050
+ import {
1051
+ CSVMultisigTapscript,
1052
+ ConditionWitness,
1053
+ Transaction as Transaction3,
1054
+ assertSubmittedArkTxid,
1055
+ buildOffchainTx,
1056
+ getArkPsbtFields,
1057
+ matchServerCheckpoints
1058
+ } from "@arkade-os/sdk";
1059
+ var sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
1060
+ var isRfqTerminal = (state) => RFQ_TERMINAL_STATES.includes(state);
1061
+ var RFQ_RESOLVED_STATES = ["settled", "refunded"];
1062
+ var isResolved = (state) => RFQ_RESOLVED_STATES.includes(state);
1063
+ async function awaitRfqResolution(transport, rfqId, options = {}) {
1064
+ const pollMs = options.pollMs ?? 5e3;
1065
+ for (; ; ) {
1066
+ const status = await transport.status(rfqId);
1067
+ if (status && isRfqTerminal(status.state)) return status;
1068
+ if (options.deadline !== void 0 && Date.now() / 1e3 >= options.deadline) {
1069
+ const error = new Error(
1070
+ `rfq ${rfqId} did not reach a terminal state before the deadline`
1071
+ );
1072
+ error.reason = "status_timeout";
1073
+ throw error;
1074
+ }
1075
+ await sleep(pollMs);
1076
+ }
1077
+ }
1078
+ var LockupNeedsRecoveryError = class extends Error {
1079
+ name = "LockupNeedsRecoveryError";
1080
+ reason = "needs_recovery";
1081
+ /** `txid:vout` for each output that must be recovered first. */
1082
+ outpoints;
1083
+ /**
1084
+ * The contract's `refundLocktime`. Recovering before this matures is the
1085
+ * hazard described above: `recoverVtxos()` sweeps EVERY recoverable output
1086
+ * into one settlement with no CLTV awareness, so an early attempt can fail
1087
+ * the whole batch — including unrelated outputs that were otherwise fine.
1088
+ *
1089
+ * Exposed as a value, not only inside the message, so a caller can encode
1090
+ * `packages/boltz-swap`'s "pre-CLTV recoverable → skipped" rule without
1091
+ * parsing prose. Seconds-based locktimes mature against the chain tip's
1092
+ * timestamp rather than wall clock, so treat this as a floor to wait past,
1093
+ * not an exact alarm.
1094
+ */
1095
+ recoverableAfter;
1096
+ constructor(outpoints, recoverableAfter) {
1097
+ super(
1098
+ `refund refused: ${outpoints.length} lockup output(s) have been swept and can no longer be spent offchain (${outpoints.join(", ")}). Recover them into a fresh batch first \u2014 IVtxoManager.recoverVtxos() does this for a wallet whose contract manager has the lockup registered, once refundLocktime (${recoverableAfter}) has matured \u2014 then retry the refund. Recovering before then can fail the entire settlement, not just these outputs.`
1099
+ );
1100
+ this.outpoints = outpoints;
1101
+ this.recoverableAfter = recoverableAfter;
1102
+ }
1103
+ };
1104
+ async function findLockupVtxos(indexer, swapPkScript) {
1105
+ const scripts = [hex4.encode(swapPkScript)];
1106
+ const [spendable, recoverable] = await Promise.all([
1107
+ indexer.getVtxos({ scripts, spendableOnly: true }),
1108
+ indexer.getVtxos({ scripts, recoverableOnly: true })
1109
+ ]);
1110
+ const seen = /* @__PURE__ */ new Set();
1111
+ const out = [];
1112
+ for (const [vtxos, isRecoverable] of [
1113
+ [spendable.vtxos ?? [], false],
1114
+ [recoverable.vtxos ?? [], true]
1115
+ ]) {
1116
+ for (const vtxo of vtxos) {
1117
+ const key = `${vtxo.txid}:${vtxo.vout}`;
1118
+ if (seen.has(key)) continue;
1119
+ seen.add(key);
1120
+ out.push({
1121
+ txid: vtxo.txid,
1122
+ vout: vtxo.vout,
1123
+ value: Number(vtxo.value),
1124
+ recoverable: isRecoverable
1125
+ });
1126
+ }
1127
+ }
1128
+ return out;
1129
+ }
1130
+ var hashesTo = (candidate, paymentHash) => hex4.encode(sha256(candidate)) === paymentHash;
1131
+ var candidateWitnessItems = (tx, inputIndex) => [
1132
+ ...getArkPsbtFields(tx, inputIndex, ConditionWitness).flat(),
1133
+ ...tx.getInput(inputIndex).finalScriptWitness ?? []
1134
+ ];
1135
+ async function readLockupFate(indexer, input) {
1136
+ const { vtxos } = await indexer.getVtxos({ scripts: [hex4.encode(input.swapPkScript)] });
1137
+ const all = vtxos ?? [];
1138
+ if (all.length === 0) return { fate: "unknown" };
1139
+ const spentBy = /* @__PURE__ */ new Set();
1140
+ let everySpendNamed = true;
1141
+ for (const vtxo of all) {
1142
+ if (!vtxo.isSpent && !vtxo.spentBy && !vtxo.settledBy) return { fate: "open" };
1143
+ if (vtxo.spentBy) spentBy.add(vtxo.spentBy);
1144
+ else everySpendNamed = false;
1145
+ }
1146
+ const { txs } = await indexer.getVirtualTxs([...spentBy]);
1147
+ const observed = /* @__PURE__ */ new Set();
1148
+ for (const raw of txs) {
1149
+ let tx;
1150
+ try {
1151
+ tx = Transaction3.fromPSBT(base643.decode(raw));
1152
+ } catch {
1153
+ continue;
1154
+ }
1155
+ if (spentBy.has(tx.id)) observed.add(tx.id);
1156
+ for (let i = 0; i < tx.inputsLength; i++) {
1157
+ const spent = tx.getInput(i);
1158
+ if (!spent.txid) continue;
1159
+ const txid = hex4.encode(spent.txid);
1160
+ if (!all.some((vtxo) => vtxo.txid === txid && vtxo.vout === spent.index)) continue;
1161
+ for (const candidate of candidateWitnessItems(tx, i)) {
1162
+ if (hashesTo(candidate, input.paymentHash)) {
1163
+ return { fate: "claimed", preimage: candidate };
1164
+ }
1165
+ }
1166
+ }
1167
+ }
1168
+ return everySpendNamed && observed.size === spentBy.size ? { fate: "returned" } : { fate: "unknown" };
1169
+ }
1170
+ async function pushRefundWithoutReceiver(ark, input) {
1171
+ if (input.vtxos.length === 0) throw new Error("nothing to refund: no funded outputs");
1172
+ const swept = input.vtxos.filter((vtxo) => vtxo.recoverable);
1173
+ if (swept.length > 0) {
1174
+ throw new LockupNeedsRecoveryError(
1175
+ swept.map((vtxo) => `${vtxo.txid}:${vtxo.vout}`),
1176
+ input.script.options.refundLocktime
1177
+ );
1178
+ }
1179
+ const refundPkScript = input.refundPkScript ?? input.script.options.nonInteractiveRefund?.senderPkScript;
1180
+ if (!refundPkScript) {
1181
+ throw new Error(
1182
+ "no refund destination: the contract carries no nonInteractiveRefund leaf, so pass refundPkScript explicitly"
1183
+ );
1184
+ }
1185
+ const info = await ark.getInfo();
1186
+ let serverUnrollScript;
1187
+ try {
1188
+ serverUnrollScript = CSVMultisigTapscript.decode(hex4.decode(info.checkpointTapscript));
1189
+ } catch {
1190
+ throw new Error("invalid checkpointTapscript from the Arkade server");
1191
+ }
1192
+ const leaf = input.script.refundWithoutReceiver();
1193
+ const tapTree = input.script.encode();
1194
+ const amount = input.vtxos.reduce((sum, vtxo) => sum + vtxo.value, 0);
1195
+ const { arkTx, checkpoints } = buildOffchainTx(
1196
+ input.vtxos.map((vtxo) => ({
1197
+ txid: vtxo.txid,
1198
+ vout: vtxo.vout,
1199
+ value: vtxo.value,
1200
+ tapLeafScript: leaf,
1201
+ tapTree
1202
+ })),
1203
+ [{ script: refundPkScript, amount: BigInt(amount) }],
1204
+ serverUnrollScript
1205
+ );
1206
+ const signedArkTx = await input.sender.sign(arkTx);
1207
+ const submitted = await ark.submitTx(
1208
+ base643.encode(signedArkTx.toPSBT()),
1209
+ checkpoints.map((c) => base643.encode(c.toPSBT()))
1210
+ );
1211
+ assertSubmittedArkTxid(submitted, signedArkTx, "refundWithoutReceiver");
1212
+ const matched = matchServerCheckpoints(
1213
+ submitted.signedCheckpointTxs,
1214
+ checkpoints,
1215
+ "refundWithoutReceiver"
1216
+ );
1217
+ const finalCheckpoints = await Promise.all(
1218
+ matched.map(
1219
+ async ({ server }) => base643.encode((await input.sender.sign(server, [0])).toPSBT())
1220
+ )
1221
+ );
1222
+ await ark.finalizeTx(submitted.arkTxid, finalCheckpoints);
1223
+ return { arkTxid: submitted.arkTxid, amount };
1224
+ }
1225
+ var REFUND_MTP_LAG_SECONDS = 2 * 60 * 60;
1226
+ async function refundIfUnresolved(transport, ark, indexer, input) {
1227
+ const pollMs = input.pollMs ?? 5e3;
1228
+ const now = input.now ?? (() => Math.floor(Date.now() / 1e3));
1229
+ const attemptDeadline = input.attemptDeadline ?? input.refundLocktime + REFUND_MTP_LAG_SECONDS;
1230
+ for (; ; ) {
1231
+ const status = await transport.status(input.rfqId);
1232
+ if (status && isResolved(status.state)) return { outcome: "resolved", status };
1233
+ if (now() >= input.refundLocktime) {
1234
+ const vtxos = await findLockupVtxos(indexer, input.script.pkScript);
1235
+ if (vtxos.length === 0) return { outcome: "nothing_to_refund", status };
1236
+ try {
1237
+ const pushed = await pushRefundWithoutReceiver(ark, {
1238
+ script: input.script,
1239
+ sender: input.sender,
1240
+ vtxos,
1241
+ refundPkScript: input.refundPkScript
1242
+ });
1243
+ return { outcome: "refunded", status, ...pushed };
1244
+ } catch (error) {
1245
+ if (error instanceof LockupNeedsRecoveryError) {
1246
+ return {
1247
+ outcome: "needs_recovery",
1248
+ outpoints: error.outpoints,
1249
+ vtxos,
1250
+ status
1251
+ };
1252
+ }
1253
+ if (now() >= attemptDeadline) throw error;
1254
+ }
1255
+ }
1256
+ await sleep(pollMs);
1257
+ }
1258
+ }
1259
+
1260
+ // src/claim.ts
1261
+ var sleep2 = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
1262
+ var LockupAmountMismatchError = class extends Error {
1263
+ name = "LockupAmountMismatchError";
1264
+ reason = "amount_mismatch";
1265
+ expectedAmount;
1266
+ lockedAmount;
1267
+ constructor(expectedAmount, lockedAmount) {
1268
+ super(
1269
+ `lockup holds ${lockedAmount} sats, below the agreed ${expectedAmount} \u2014 refusing to publish the preimage`
1270
+ );
1271
+ this.expectedAmount = expectedAmount;
1272
+ this.lockedAmount = lockedAmount;
1273
+ }
1274
+ };
1275
+ var assertFiniteAmount = (value, reason, label) => {
1276
+ if (Number.isFinite(value)) return;
1277
+ const error = new Error(`${label} is not a finite number (${String(value)})`);
1278
+ error.reason = reason;
1279
+ throw error;
1280
+ };
1281
+ async function pushClaim(ark, input) {
1282
+ if (input.vtxos.length === 0) throw new Error("nothing to claim: no funded outputs");
1283
+ const swept = input.vtxos.filter((vtxo) => vtxo.recoverable);
1284
+ if (swept.length > 0) {
1285
+ throw new LockupNeedsRecoveryError(
1286
+ swept.map((vtxo) => `${vtxo.txid}:${vtxo.vout}`),
1287
+ input.script.options.refundLocktime
1288
+ );
1289
+ }
1290
+ const locked = input.vtxos.reduce((sum, vtxo) => sum + vtxo.value, 0);
1291
+ assertFiniteAmount(locked, "lockup_malformed", "the lockup's summed value");
1292
+ if (!input.partiallyClaimed) {
1293
+ assertFiniteAmount(input.expectedAmount, "invalid_gate_input", "expectedAmount");
1294
+ if (locked < input.expectedAmount) {
1295
+ throw new LockupAmountMismatchError(input.expectedAmount, locked);
1296
+ }
1297
+ }
1298
+ const committed = input.script.options.preimageHash;
1299
+ if (hex5.encode(ripemd160(sha2562(input.preimage))) !== hex5.encode(committed)) {
1300
+ throw new Error("preimage does not match the covenant's payment hash");
1301
+ }
1302
+ const info = await ark.getInfo();
1303
+ let serverUnrollScript;
1304
+ try {
1305
+ serverUnrollScript = CSVMultisigTapscript2.decode(hex5.decode(info.checkpointTapscript));
1306
+ } catch {
1307
+ throw new Error("invalid checkpointTapscript from the Arkade server");
1308
+ }
1309
+ const leaf = input.script.claim();
1310
+ const tapTree = input.script.encode();
1311
+ const arkTxid = await signAndSubmitOffchainTx({
1312
+ identity: claimWithPreimageIdentity(input.receiver, input.preimage),
1313
+ provider: ark,
1314
+ inputs: input.vtxos.map((vtxo) => ({
1315
+ txid: vtxo.txid,
1316
+ vout: vtxo.vout,
1317
+ value: vtxo.value,
1318
+ tapLeafScript: leaf,
1319
+ tapTree
1320
+ })),
1321
+ // One aggregate output: unlike the covenant refund, this leaf inspects
1322
+ // nothing about the output set.
1323
+ outputs: [{ script: input.destinationPkScript, amount: BigInt(locked) }],
1324
+ serverUnrollScript,
1325
+ verifyServerSignatures: { serverPubkey: input.script.options.server }
1326
+ });
1327
+ return { arkTxid, amount: locked };
1328
+ }
1329
+ async function awaitLockupFunding(indexer, swapPkScript, options = {}) {
1330
+ const pollMs = options.pollMs ?? 5e3;
1331
+ for (; ; ) {
1332
+ const vtxos = await findLockupVtxos(indexer, swapPkScript);
1333
+ if (vtxos.length > 0) return vtxos;
1334
+ if (options.deadline !== void 0 && Date.now() / 1e3 >= options.deadline) {
1335
+ const error = new Error("the lockup never appeared at the covenant script");
1336
+ error.reason = "lockup_timeout";
1337
+ throw error;
1338
+ }
1339
+ await sleep2(pollMs);
1340
+ }
1341
+ }
1342
+ async function claimReceiveLockup(indexer, ark, input) {
1343
+ const vtxos = await awaitLockupFunding(indexer, input.swapPkScript, {
1344
+ pollMs: input.pollMs,
1345
+ deadline: input.deadline
1346
+ });
1347
+ return pushClaim(ark, {
1348
+ script: input.script,
1349
+ receiver: input.receiver,
1350
+ preimage: input.preimage,
1351
+ vtxos,
1352
+ destinationPkScript: input.destinationPkScript,
1353
+ expectedAmount: input.expectedAmount,
1354
+ partiallyClaimed: input.partiallyClaimed
1355
+ });
1356
+ }
1357
+
1358
+ // src/swapManager.ts
1359
+ import { hex as hex6 } from "@scure/base";
1360
+ var RFQ_SWAP_TERMINAL_STATES = ["settled", "refunded", "failed"];
1361
+ var isRfqSwapTerminal = (state) => RFQ_SWAP_TERMINAL_STATES.includes(state);
1362
+ function nextOnchainAction(input) {
1363
+ switch (input.phase.phase) {
1364
+ case "unfunded":
1365
+ case "awaiting_confirmations":
1366
+ return "wait";
1367
+ case "claimed":
1368
+ return "claimed";
1369
+ case "swept":
1370
+ return "swept";
1371
+ case "refundable":
1372
+ return "claim_window_closed";
1373
+ case "claimable":
1374
+ return input.htlcLocktime - input.now >= ONCHAIN_CLAIM_MARGIN_SECONDS ? "claim" : "claim_window_closed";
1375
+ }
1376
+ }
1377
+ var notify = (listeners, call) => {
1378
+ for (const listener of listeners) {
1379
+ try {
1380
+ call(listener);
1381
+ } catch {
1382
+ }
1383
+ }
1384
+ };
1385
+ var RfqSwapManager = class {
1386
+ deps;
1387
+ config;
1388
+ callbacks = null;
1389
+ swapUpdateListeners = /* @__PURE__ */ new Set();
1390
+ swapCompletedListeners = /* @__PURE__ */ new Set();
1391
+ swapFailedListeners = /* @__PURE__ */ new Set();
1392
+ actionExecutedListeners = /* @__PURE__ */ new Set();
1393
+ monitored = /* @__PURE__ */ new Map();
1394
+ /** Monitored swaps by lockup script hex, so a contract event — which names
1395
+ * a script and nothing else — can find the swap it belongs to. */
1396
+ byLockupScript = /* @__PURE__ */ new Map();
1397
+ /**
1398
+ * Swaps whose lockup registration has been SETTLED one way or another,
1399
+ * mapped to whether a contract row actually resulted. Membership is what
1400
+ * stops a per-pass retry from becoming a per-pass round trip; the value is
1401
+ * what keeps a swap that could never be registered from later trying to
1402
+ * retire a row that does not exist, which would report a spurious failure
1403
+ * on a swap that in fact succeeded.
1404
+ */
1405
+ registered = /* @__PURE__ */ new Map();
1406
+ /**
1407
+ * Swaps whose `refundArkade` answered {@link RefundNotLocallyPossibleError}
1408
+ * in this process. Membership stops the push from being re-issued every
1409
+ * pass — it cannot start working on its own, and re-issuing it is the
1410
+ * grind `needs_counterparty` exists to remove. Only
1411
+ * {@link RfqSwapManagerCallbacks.canRefundArkade} clears it, so a caller
1412
+ * with no probe learns again on the next start, when the wallet that can
1413
+ * sign may well have been restored.
1414
+ */
1415
+ refundRefused = /* @__PURE__ */ new Set();
1416
+ /**
1417
+ * The last error a receive swap's claim callback threw, by rfqId.
1418
+ *
1419
+ * Kept only to tell two terminal outcomes apart once the claim window
1420
+ * shuts: a swap whose claim was attempted and kept failing ends `failed`
1421
+ * with that reason, while one that simply never became claimable ends
1422
+ * `refunded`. Without it a broken claim callback would resolve a caller's
1423
+ * {@link waitForSwapCompletion} as an ordinary unwind.
1424
+ *
1425
+ * Process-local, like {@link refundRefused}: after a restart the same swap
1426
+ * ends `refunded` instead, which costs the caller a reason and nothing else
1427
+ * — every throw was already reported through `onSwapFailed` as it happened.
1428
+ */
1429
+ lastClaimError = /* @__PURE__ */ new Map();
1430
+ /**
1431
+ * The lockup outpoints a receive swap's claim callback has already been
1432
+ * handed, by rfqId.
1433
+ *
1434
+ * What this exists to prevent: a claim SUCCEEDS, and for the next few
1435
+ * passes the indexer still lists those outputs as unspent. Without a
1436
+ * record of what was already claimed, every one of those passes would
1437
+ * re-submit the same spend, fail against the server, and report a swap
1438
+ * that in fact worked as failing. With one, a re-claim happens only when
1439
+ * an outpoint appears that was never claimed — a lockup funded piecemeal,
1440
+ * which is legitimate and which `partiallyClaimed` exists for.
1441
+ *
1442
+ * Process-local: after a restart a swap with a live claim tries once more.
1443
+ * That is the recovery case rather than the spam one — a claim that never
1444
+ * landed leaves its outputs unspent, and one that did leaves a single
1445
+ * rejection.
1446
+ */
1447
+ claimedOutpoints = /* @__PURE__ */ new Map();
1448
+ /** Live `onContractEvent` subscription, held so `stop()` can drop it. */
1449
+ unsubscribeContracts = null;
1450
+ /** Terminal records, kept so a late {@link waitForSwapCompletion} still
1451
+ * answers instead of throwing "not found". Cleared by {@link removeSwap}. */
1452
+ finished = /* @__PURE__ */ new Map();
1453
+ waiters = /* @__PURE__ */ new Map();
1454
+ /** Records changed during the current pass, flushed through `saveSwap`. */
1455
+ dirty = /* @__PURE__ */ new Set();
1456
+ /** Race guard: one action at a time per swap. */
1457
+ inProgress = /* @__PURE__ */ new Set();
1458
+ timer = null;
1459
+ running = false;
1460
+ constructor(deps, config = {}) {
1461
+ this.deps = deps;
1462
+ this.config = {
1463
+ enableAutoActions: config.enableAutoActions ?? true,
1464
+ pollIntervalMs: config.pollIntervalMs ?? 5e3,
1465
+ now: config.now ?? (() => Math.floor(Date.now() / 1e3))
1466
+ };
1467
+ if (config.events?.onSwapUpdate) this.swapUpdateListeners.add(config.events.onSwapUpdate);
1468
+ if (config.events?.onSwapCompleted) {
1469
+ this.swapCompletedListeners.add(config.events.onSwapCompleted);
1470
+ }
1471
+ if (config.events?.onSwapFailed) this.swapFailedListeners.add(config.events.onSwapFailed);
1472
+ if (config.events?.onActionExecuted) {
1473
+ this.actionExecutedListeners.add(config.events.onActionExecuted);
1474
+ }
1475
+ }
1476
+ /** Wire the money-moving half. Without it the manager only watches. */
1477
+ setCallbacks(callbacks) {
1478
+ this.callbacks = callbacks;
1479
+ }
1480
+ onSwapUpdate(listener) {
1481
+ this.swapUpdateListeners.add(listener);
1482
+ return () => this.swapUpdateListeners.delete(listener);
1483
+ }
1484
+ onSwapCompleted(listener) {
1485
+ this.swapCompletedListeners.add(listener);
1486
+ return () => this.swapCompletedListeners.delete(listener);
1487
+ }
1488
+ onSwapFailed(listener) {
1489
+ this.swapFailedListeners.add(listener);
1490
+ return () => this.swapFailedListeners.delete(listener);
1491
+ }
1492
+ onActionExecuted(listener) {
1493
+ this.actionExecutedListeners.add(listener);
1494
+ return () => this.actionExecutedListeners.delete(listener);
1495
+ }
1496
+ /**
1497
+ * Load records and begin monitoring. Runs one pass immediately — a caller
1498
+ * resuming after a restart may be well past a deadline already — then
1499
+ * every `pollIntervalMs`. Records that are already terminal are kept only
1500
+ * so {@link waitForSwapCompletion} can answer for them.
1501
+ *
1502
+ * Calling it again while running loads the records and returns rather than
1503
+ * re-arming — dropping them silently would strand a funded swap on a
1504
+ * caller's harmless double-start.
1505
+ */
1506
+ async start(swaps = []) {
1507
+ for (const swap of swaps) {
1508
+ if (isRfqSwapTerminal(swap.state)) this.finished.set(swap.rfqId, swap);
1509
+ else this.track(swap);
1510
+ }
1511
+ if (this.running) return;
1512
+ this.running = true;
1513
+ this.subscribe();
1514
+ await this.poll();
1515
+ this.arm();
1516
+ }
1517
+ /**
1518
+ * Stop monitoring and clear the timer. In-flight actions are not
1519
+ * cancellable and run to completion; outstanding
1520
+ * {@link waitForSwapCompletion} promises are left pending, since
1521
+ * stop/start is a pause rather than a cancellation.
1522
+ *
1523
+ * The contract subscription is dropped too — an open stream with nothing
1524
+ * reacting to it is a leak, and {@link start} puts it back. What is NOT
1525
+ * undone is the contract registration: those rows are the wallet's, they
1526
+ * outlive this manager's lifecycle, and dropping them would unwatch a
1527
+ * lockup that is still funded.
1528
+ */
1529
+ async stop() {
1530
+ this.running = false;
1531
+ if (this.timer) {
1532
+ clearTimeout(this.timer);
1533
+ this.timer = null;
1534
+ }
1535
+ this.unsubscribeContracts?.();
1536
+ this.unsubscribeContracts = null;
1537
+ }
1538
+ /** Begin monitoring a swap. Polled immediately when the manager is running,
1539
+ * so a just-funded swap does not wait out a whole interval. */
1540
+ async addSwap(swap) {
1541
+ if (isRfqSwapTerminal(swap.state)) {
1542
+ this.finished.set(swap.rfqId, swap);
1543
+ return;
1544
+ }
1545
+ this.track(swap);
1546
+ if (this.running) await this.pollSwap(swap);
1547
+ }
1548
+ /** Forget a swap entirely, monitored or finished.
1549
+ *
1550
+ * Its contract row is left alone: registration is a wallet-level fact about
1551
+ * a script that may still hold money, and this call says only that THIS
1552
+ * manager stops driving the swap. Retiring the row is reserved for a swap
1553
+ * that reached a terminal state, where the lockup is provably done. */
1554
+ async removeSwap(rfqId) {
1555
+ this.untrack(rfqId);
1556
+ this.finished.delete(rfqId);
1557
+ this.registered.delete(rfqId);
1558
+ const waiting = this.waiters.get(rfqId);
1559
+ if (waiting) {
1560
+ const error = new Error(`swap ${rfqId} was removed from monitoring`);
1561
+ for (const waiter of waiting) waiter.reject(error);
1562
+ }
1563
+ this.waiters.delete(rfqId);
1564
+ this.dirty.delete(rfqId);
1565
+ }
1566
+ /** Every swap still being monitored. */
1567
+ async getPendingSwaps() {
1568
+ return [...this.monitored.values()];
1569
+ }
1570
+ async hasSwap(rfqId) {
1571
+ return this.monitored.has(rfqId);
1572
+ }
1573
+ /** True while an action for this swap holds the per-swap lock. */
1574
+ async isProcessing(rfqId) {
1575
+ return this.inProgress.has(rfqId);
1576
+ }
1577
+ async getStats() {
1578
+ return {
1579
+ isRunning: this.running,
1580
+ monitoredSwaps: this.monitored.size,
1581
+ finishedSwaps: this.finished.size,
1582
+ inProgress: this.inProgress.size,
1583
+ pollIntervalMs: this.config.pollIntervalMs
1584
+ };
1585
+ }
1586
+ /**
1587
+ * Run one monitoring pass over every swap now.
1588
+ *
1589
+ * {@link start} calls this on an interval, but it is public on purpose: a
1590
+ * caller that sleeps its process (a mobile app resuming, a service worker
1591
+ * waking) wants a pass on that event rather than at the next tick. Passes
1592
+ * do not overlap per swap — the in-progress lock makes a concurrent call a
1593
+ * no-op for any swap already being worked on.
1594
+ */
1595
+ async poll() {
1596
+ await Promise.allSettled([...this.monitored.values()].map((swap) => this.pollSwap(swap)));
1597
+ }
1598
+ /**
1599
+ * Resolve once this swap's PAYOUT is decided — which for onchain-send is
1600
+ * the L1 claim, not the end of the record's life: once `claimTxid` is set
1601
+ * the trader has the coins it swapped for, and what remains is the manager
1602
+ * watching the Arkade lockup close. That holds however the record is
1603
+ * labelled afterwards, `needs_counterparty` included. Lightning-send has no
1604
+ * such split and resolves at `settled`/`refunded`, and so does lightning
1605
+ * receive — see {@link isPayoutDecided} for why its own claim txid does not
1606
+ * decide it.
1607
+ *
1608
+ * Rejects only on `failed`. `refunded` resolves: on a send leg a refund is
1609
+ * an outcome the caller asked this manager to drive, not an exception. On a
1610
+ * receive leg it is the swap being lost, which is still an answer and not
1611
+ * an error — read `state`, do not infer success from resolution.
1612
+ */
1613
+ async waitForSwapCompletion(rfqId) {
1614
+ const swap = this.monitored.get(rfqId) ?? this.finished.get(rfqId);
1615
+ if (!swap) throw new Error(`swap ${rfqId} is not monitored`);
1616
+ if (swap.state === "failed") throw new Error(swap.failure ?? `swap ${rfqId} failed`);
1617
+ if (isPayoutDecided(swap)) return outcomeOf(swap);
1618
+ return new Promise((resolve, reject) => {
1619
+ const set = this.waiters.get(rfqId) ?? /* @__PURE__ */ new Set();
1620
+ set.add({ resolve, reject });
1621
+ this.waiters.set(rfqId, set);
1622
+ });
1623
+ }
1624
+ // ── internals ────────────────────────────────────────────────────────────
1625
+ track(swap) {
1626
+ this.monitored.set(swap.rfqId, swap);
1627
+ this.byLockupScript.set(hex6.encode(swap.lockupPkScript), swap);
1628
+ }
1629
+ /** Drops the swap from BOTH indexes. The event index is the one that stops
1630
+ * a late event finding a swap that is gone; `pollSwap`'s own
1631
+ * `monitored` check would also catch it, and deliberately still does —
1632
+ * either alone is sufficient, which is what keeps a future change to one of
1633
+ * them from silently re-driving a cancelled swap. */
1634
+ untrack(rfqId) {
1635
+ const swap = this.monitored.get(rfqId);
1636
+ if (swap) this.byLockupScript.delete(hex6.encode(swap.lockupPkScript));
1637
+ this.monitored.delete(rfqId);
1638
+ this.refundRefused.delete(rfqId);
1639
+ this.lastClaimError.delete(rfqId);
1640
+ this.claimedOutpoints.delete(rfqId);
1641
+ }
1642
+ /**
1643
+ * Turn the indexer's push into an extra reason to run a pass — and nothing
1644
+ * more.
1645
+ *
1646
+ * **This is deliberately not a source of truth.** An event names a script;
1647
+ * the reaction is to run the ordinary pass for the swap at that script, and
1648
+ * that pass re-reads the lockup through {@link readLockupFate} exactly as
1649
+ * the timer's pass does. So an event that is missed, duplicated, reordered
1650
+ * or outright FORGED can only cost or save latency — it can never change
1651
+ * what this manager believes about a swap, and it can never on its own
1652
+ * cause a claim or a refund. That property is what makes it safe to bolt a
1653
+ * best-effort stream onto a money path, and it must survive any future
1654
+ * change here: the moment an event is BELIEVED rather than merely acted on,
1655
+ * a relay outage becomes a correctness problem instead of a latency one.
1656
+ *
1657
+ * The timer stays armed regardless, and is the failsafe. Every deadline
1658
+ * that moves money — `refundLocktime`, the L1 claim window — is an absolute
1659
+ * timelock that passes whether or not a single event ever arrives.
1660
+ */
1661
+ subscribe() {
1662
+ if (!this.deps.contracts || this.unsubscribeContracts) return;
1663
+ this.unsubscribeContracts = this.deps.contracts.onContractEvent((event) => {
1664
+ if (event.type === "connection_reset") {
1665
+ void this.poll().catch(() => {
1666
+ });
1667
+ return;
1668
+ }
1669
+ const swap = this.byLockupScript.get(event.contractScript);
1670
+ if (!swap) return;
1671
+ void this.pollSwap(swap).catch(() => {
1672
+ });
1673
+ });
1674
+ }
1675
+ /**
1676
+ * Register this swap's lockup with the wallet's contract manager, once.
1677
+ *
1678
+ * The backstop, not the primary site: `requestLightningSend` /
1679
+ * `requestOnchainSend` register before the caller can fund, so this covers
1680
+ * swaps whose records predate that — and costs nothing when it does not,
1681
+ * since `createContract` is first-writer-wins.
1682
+ *
1683
+ * Best-effort by design: a failure here is reported and retried on the next
1684
+ * pass, and never aborts the pass it is part of. Registration buys latency
1685
+ * and puts the lockup in the wallet's contract set; it decides nothing. The
1686
+ * money path below it reads the indexer directly and is gated on timelocks
1687
+ * that a missing contract row has no bearing on, so failing the pass over
1688
+ * this would trade a real deadline for a bookkeeping one.
1689
+ */
1690
+ async ensureRegistered(swap) {
1691
+ const contracts = this.deps.contracts;
1692
+ if (!contracts) return;
1693
+ if (this.registered.has(swap.rfqId)) return;
1694
+ const lockup = swap.lockup;
1695
+ if (!lockup) {
1696
+ try {
1697
+ const [existing] = await contracts.getContracts({
1698
+ script: hex6.encode(swap.lockupPkScript)
1699
+ });
1700
+ if (existing) {
1701
+ this.registered.set(swap.rfqId, true);
1702
+ return;
1703
+ }
1704
+ } catch (error) {
1705
+ this.emitFailed(swap, error);
1706
+ return;
1707
+ }
1708
+ this.registered.set(swap.rfqId, false);
1709
+ this.emitFailed(
1710
+ swap,
1711
+ new Error(
1712
+ `swap ${swap.rfqId} carries no lockup script and has no contract row, so it cannot be registered \u2014 pass \`lockup\` to subscribe instead of polling`
1713
+ )
1714
+ );
1715
+ return;
1716
+ }
1717
+ const script = hex6.encode(lockup.script.pkScript);
1718
+ if (script !== hex6.encode(swap.lockupPkScript)) {
1719
+ this.registered.set(swap.rfqId, false);
1720
+ this.emitFailed(
1721
+ swap,
1722
+ new Error(
1723
+ `swap ${swap.rfqId} lockup script ${script} does not match its lockupPkScript ${hex6.encode(swap.lockupPkScript)}`
1724
+ )
1725
+ );
1726
+ return;
1727
+ }
1728
+ try {
1729
+ await registerLockupContract(contracts, lockup.script, lockup.address);
1730
+ this.registered.set(swap.rfqId, true);
1731
+ } catch (error) {
1732
+ this.emitFailed(swap, error);
1733
+ }
1734
+ }
1735
+ /** Stop watching a finished swap's lockup. Retained, not deleted: the row
1736
+ * is what keeps the lockup's own VTXOs annotatable and its history
1737
+ * readable, while `retained` is what drops it from the subscription and
1738
+ * the poll — a settled swap that stayed watched would cost the wallet a
1739
+ * script for its whole life. Best-effort — the swap is over either way. */
1740
+ retireContract(swap) {
1741
+ if (!this.deps.contracts || !this.registered.get(swap.rfqId)) return;
1742
+ void this.deps.contracts.setContractWatchState(hex6.encode(swap.lockupPkScript), "retained").catch((error) => this.emitFailed(swap, error));
1743
+ }
1744
+ arm() {
1745
+ if (!this.running) return;
1746
+ if (this.timer) clearTimeout(this.timer);
1747
+ this.timer = setTimeout(() => {
1748
+ this.timer = null;
1749
+ void this.poll().then(() => this.arm());
1750
+ }, this.config.pollIntervalMs);
1751
+ }
1752
+ async pollSwap(swap) {
1753
+ if (this.inProgress.has(swap.rfqId)) return;
1754
+ if (!this.monitored.has(swap.rfqId)) return;
1755
+ this.inProgress.add(swap.rfqId);
1756
+ try {
1757
+ await this.runPass(swap);
1758
+ } finally {
1759
+ const persisted = this.dirty.has(swap.rfqId) ? await this.save(swap) : true;
1760
+ if (persisted) {
1761
+ this.dirty.delete(swap.rfqId);
1762
+ this.settleWaiters(swap);
1763
+ if (isRfqSwapTerminal(swap.state)) this.finalize(swap);
1764
+ }
1765
+ this.inProgress.delete(swap.rfqId);
1766
+ }
1767
+ }
1768
+ async runPass(swap) {
1769
+ await this.ensureRegistered(swap);
1770
+ let fate;
1771
+ try {
1772
+ fate = await readLockupFate(this.deps.indexer, {
1773
+ swapPkScript: swap.lockupPkScript,
1774
+ paymentHash: swap.paymentHash
1775
+ });
1776
+ } catch {
1777
+ fate = { fate: "unknown" };
1778
+ }
1779
+ if (fate.fate === "claimed" || fate.fate === "returned") {
1780
+ this.setState(swap, fate.fate === "claimed" ? "settled" : "refunded");
1781
+ return;
1782
+ }
1783
+ if (swap.kind === "lightning_receive") return this.driveReceiveClaim(swap);
1784
+ if (swap.kind === "onchain_send" && swap.state !== "claimed") {
1785
+ if (await this.driveOnchain(swap) === "handled") return;
1786
+ }
1787
+ await this.driveArkadeRefund(swap);
1788
+ }
1789
+ /**
1790
+ * The receive leg's whole state machine: claim the solver-funded lockup
1791
+ * while the window is open, and recognise the shapes in which it can be
1792
+ * lost.
1793
+ *
1794
+ * **The window closes at `refundLocktime`, on wall clock, with no margin.**
1795
+ * Both halves of that are deliberate. It closes there because publishing
1796
+ * `P` into the solver's live refund window risks losing the race and
1797
+ * handing over the preimage anyway — the hazard `ONCHAIN_CLAIM_MARGIN_SECONDS`
1798
+ * guards on the L1 side. It takes no margin because the two situations are
1799
+ * not alike: that one budgets for confirmation depth, while this claim is an
1800
+ * offchain spend that lands in seconds. Wall clock is already the
1801
+ * conservative reading — the solver's leaf is a CLTV, which matures against
1802
+ * median-time-past, and MTP trails wall clock — so the real window extends
1803
+ * PAST this deadline rather than ending before it. Every second of margin
1804
+ * subtracted here is a second of live claim window given away for nothing.
1805
+ *
1806
+ * **The trader has no move after it.** Nothing here can take the lockup
1807
+ * back, so once the window shuts the swap is the solver's to resolve and
1808
+ * this manager's job is to watch it happen and then stop.
1809
+ */
1810
+ async driveReceiveClaim(swap) {
1811
+ const now = this.config.now();
1812
+ if (now < swap.refundLocktime) {
1813
+ let vtxos;
1814
+ try {
1815
+ vtxos = await findLockupVtxos(this.deps.indexer, swap.lockupPkScript);
1816
+ } catch (error) {
1817
+ this.emitFailed(swap, error);
1818
+ return;
1819
+ }
1820
+ return this.claimIfFunded(swap, vtxos);
1821
+ }
1822
+ if (now < swap.refundLocktime + REFUND_MTP_LAG_SECONDS) {
1823
+ if (swap.claimArkTxid) return;
1824
+ return this.block(
1825
+ swap,
1826
+ "the claim window closed with the lockup unclaimed \u2014 only the solver can act now"
1827
+ );
1828
+ }
1829
+ const failure = this.lastClaimError.get(swap.rfqId);
1830
+ if (failure && !swap.claimArkTxid) {
1831
+ return this.fail(swap, new Error(failure));
1832
+ }
1833
+ this.setState(swap, "refunded");
1834
+ }
1835
+ /**
1836
+ * Claim what the solver funded, once it is enough.
1837
+ *
1838
+ * The value gate here decides WHEN to act. `pushClaim`'s decides whether
1839
+ * `P` is published, and runs with nothing between it and the signature —
1840
+ * the check that matters is the inner one, and this is not a reason to
1841
+ * relax it.
1842
+ */
1843
+ async claimIfFunded(swap, vtxos) {
1844
+ if (vtxos.length === 0) return this.unblock(swap);
1845
+ const partiallyClaimed = swap.claimArkTxid !== void 0;
1846
+ if (partiallyClaimed && !this.hasUnclaimedOutpoint(swap.rfqId, vtxos)) {
1847
+ return;
1848
+ }
1849
+ if (!partiallyClaimed) {
1850
+ if (!Number.isFinite(swap.expectedAmount)) {
1851
+ return this.block(
1852
+ swap,
1853
+ `expectedAmount is not a finite number (${String(swap.expectedAmount)}), so the funded value cannot be checked \u2014 refusing to publish the preimage`
1854
+ );
1855
+ }
1856
+ const locked = vtxos.reduce((sum, vtxo) => sum + vtxo.value, 0);
1857
+ if (!Number.isFinite(locked) || locked < swap.expectedAmount) {
1858
+ return this.block(
1859
+ swap,
1860
+ `lockup holds ${locked} sats, below the agreed ${swap.expectedAmount} \u2014 refusing to publish the preimage`
1861
+ );
1862
+ }
1863
+ }
1864
+ if (!this.callbacks) {
1865
+ return this.block(
1866
+ swap,
1867
+ "no callbacks are wired, so this wallet cannot claim the lockup"
1868
+ );
1869
+ }
1870
+ this.setState(swap, "claimable");
1871
+ if (!this.config.enableAutoActions) return;
1872
+ try {
1873
+ const { arkTxid } = await this.callbacks.claimLockup(swap, vtxos, { partiallyClaimed });
1874
+ this.lastClaimError.delete(swap.rfqId);
1875
+ this.rememberClaimed(swap.rfqId, vtxos);
1876
+ swap.claimArkTxid = arkTxid;
1877
+ this.touch(swap);
1878
+ this.setState(swap, "claimed");
1879
+ this.emitAction(swap, "claimLockup");
1880
+ } catch (error) {
1881
+ this.lastClaimError.set(swap.rfqId, errorMessage(error));
1882
+ this.emitFailed(swap, error);
1883
+ }
1884
+ }
1885
+ /** `handled` ends the pass; `continue` falls through to the refund gate. */
1886
+ async driveOnchain(swap) {
1887
+ if (!this.deps.chain) {
1888
+ this.fail(
1889
+ swap,
1890
+ new Error(
1891
+ "onchain-send swap monitored without a ChainSource \u2014 the L1 fill cannot be seen or claimed"
1892
+ )
1893
+ );
1894
+ return "handled";
1895
+ }
1896
+ let phase;
1897
+ try {
1898
+ phase = await classifyOnchainHtlc(this.deps.chain, {
1899
+ htlc: swap.htlc,
1900
+ minConfirmations: swap.minConfirmations,
1901
+ funding: swap.funding
1902
+ });
1903
+ } catch {
1904
+ return "continue";
1905
+ }
1906
+ if ("utxo" in phase && !swap.funding) {
1907
+ swap.funding = { txid: phase.utxo.txid, vout: phase.utxo.vout };
1908
+ this.touch(swap);
1909
+ }
1910
+ const action = nextOnchainAction({
1911
+ phase,
1912
+ htlcLocktime: swap.htlc.refundLocktime,
1913
+ now: this.config.now()
1914
+ });
1915
+ if (action === "claim" && phase.phase === "claimable") {
1916
+ if (swap.claimTxid) return "continue";
1917
+ this.setOnchainState(swap, "claimable");
1918
+ if (!this.config.enableAutoActions || !this.callbacks) return "handled";
1919
+ try {
1920
+ const { txid } = await this.callbacks.claimOnchain(swap, phase.utxo);
1921
+ swap.claimTxid = txid;
1922
+ this.setOnchainState(swap, "claimed");
1923
+ this.emitAction(swap, "claimOnchain");
1924
+ } catch (error) {
1925
+ this.emitFailed(swap, error);
1926
+ }
1927
+ return "handled";
1928
+ }
1929
+ if (action === "claimed" && phase.phase === "claimed") {
1930
+ if (!swap.claimTxid) {
1931
+ swap.claimTxid = phase.txid;
1932
+ this.touch(swap);
1933
+ }
1934
+ this.setOnchainState(swap, "claimed");
1935
+ }
1936
+ return "continue";
1937
+ }
1938
+ async driveArkadeRefund(swap) {
1939
+ const now = this.config.now();
1940
+ const refusal = await this.probeRefusal(swap);
1941
+ if (refusal) {
1942
+ const claiming = swap.state === "claimable" || swap.state === "claimed";
1943
+ if (now < swap.refundLocktime && claiming) return;
1944
+ return this.block(swap, refusal);
1945
+ }
1946
+ if (this.refundRefused.has(swap.rfqId)) {
1947
+ if (!this.callbacks?.canRefundArkade) return;
1948
+ this.refundRefused.delete(swap.rfqId);
1949
+ }
1950
+ if (now < swap.refundLocktime) return this.unblock(swap);
1951
+ if (!this.config.enableAutoActions || !this.callbacks) {
1952
+ return this.block(
1953
+ swap,
1954
+ this.callbacks ? "automatic actions are disabled, so this wallet will not push the refund" : "no callbacks are wired, so this wallet cannot push the refund"
1955
+ );
1956
+ }
1957
+ this.unblock(swap);
1958
+ try {
1959
+ const pushed = await this.callbacks.refundArkade(swap);
1960
+ if (pushed) {
1961
+ swap.refundArkTxid = pushed.arkTxid;
1962
+ this.touch(swap);
1963
+ }
1964
+ this.setState(swap, "refunded");
1965
+ this.emitAction(swap, "refundArkade");
1966
+ } catch (error) {
1967
+ if (error instanceof RefundNotLocallyPossibleError) {
1968
+ this.refundRefused.add(swap.rfqId);
1969
+ return this.block(swap, error.message);
1970
+ }
1971
+ this.emitFailed(swap, error);
1972
+ if (now >= swap.refundLocktime + REFUND_MTP_LAG_SECONDS) {
1973
+ swap.failure = errorMessage(error);
1974
+ this.setState(swap, "failed");
1975
+ }
1976
+ }
1977
+ }
1978
+ /** Whether any of these outputs has never been handed to the claim
1979
+ * callback — the only reason to claim a lockup a second time. */
1980
+ hasUnclaimedOutpoint(rfqId, vtxos) {
1981
+ const claimed = this.claimedOutpoints.get(rfqId);
1982
+ if (!claimed) return true;
1983
+ return vtxos.some((vtxo) => !claimed.has(outpointKey(vtxo)));
1984
+ }
1985
+ rememberClaimed(rfqId, vtxos) {
1986
+ const claimed = this.claimedOutpoints.get(rfqId) ?? /* @__PURE__ */ new Set();
1987
+ for (const vtxo of vtxos) claimed.add(outpointKey(vtxo));
1988
+ this.claimedOutpoints.set(rfqId, claimed);
1989
+ }
1990
+ /**
1991
+ * L1 progress, which past the refund window must not overwrite a refusal.
1992
+ * The two halves are independent — a claimed fill says nothing about
1993
+ * whether this wallet can take the Arkade lockup back — and `claimed` is
1994
+ * re-asserted from chain on every pass, so without this a blocked swap
1995
+ * would flip between the two states forever. The claim itself always runs;
1996
+ * only the label defers, and only once the refund is the live half.
1997
+ */
1998
+ setOnchainState(swap, state) {
1999
+ if (swap.state === "needs_counterparty" && this.config.now() >= swap.refundLocktime) return;
2000
+ this.setState(swap, state);
2001
+ }
2002
+ /** The probe's refusal reason, or `undefined` when a local refund is
2003
+ * possible as far as anyone here can tell. A probe that throws is treated
2004
+ * as a refusal: a capability check that cannot answer is not a yes. */
2005
+ async probeRefusal(swap) {
2006
+ const probe = this.callbacks?.canRefundArkade;
2007
+ if (!probe) return void 0;
2008
+ try {
2009
+ const answer = await probe(swap);
2010
+ return answer.ok ? void 0 : answer.reason;
2011
+ } catch (error) {
2012
+ return errorMessage(error);
2013
+ }
2014
+ }
2015
+ /** Report that no local refund will happen, without ending the swap. */
2016
+ block(swap, reason) {
2017
+ if (swap.blockedReason !== reason) {
2018
+ swap.blockedReason = reason;
2019
+ this.touch(swap);
2020
+ }
2021
+ this.setState(swap, "needs_counterparty");
2022
+ }
2023
+ /** The way back out, taken as soon as the swap becomes actionable again.
2024
+ * Back to what the record can prove, not to `pending` unconditionally: a
2025
+ * swap that already made its claim has a txid for it, and reporting that
2026
+ * swap as `pending` would un-say something true. */
2027
+ unblock(swap) {
2028
+ if (swap.state !== "needs_counterparty") return;
2029
+ this.setState(swap, traderClaimTxid(swap) ? "claimed" : "pending");
2030
+ }
2031
+ touch(swap) {
2032
+ swap.updatedAt = this.config.now();
2033
+ this.dirty.add(swap.rfqId);
2034
+ }
2035
+ setState(swap, state) {
2036
+ if (swap.state === state) return;
2037
+ const previous = swap.state;
2038
+ if (previous === "needs_counterparty") delete swap.blockedReason;
2039
+ swap.state = state;
2040
+ this.touch(swap);
2041
+ notify(this.swapUpdateListeners, (listener) => listener(swap, previous));
2042
+ }
2043
+ /** Terminal failure. The `onSwapFailed` emission is left to
2044
+ * {@link finalize}, so this does not double-report. */
2045
+ fail(swap, error) {
2046
+ swap.failure = error.message;
2047
+ this.setState(swap, "failed");
2048
+ }
2049
+ emitFailed(swap, error) {
2050
+ const wrapped = error instanceof Error ? error : new Error(errorMessage(error));
2051
+ notify(this.swapFailedListeners, (listener) => listener(swap, wrapped));
2052
+ }
2053
+ emitAction(swap, action) {
2054
+ notify(this.actionExecutedListeners, (listener) => listener(swap, action));
2055
+ }
2056
+ /** Whether the record is now persisted — false only when `saveSwap` threw. */
2057
+ async save(swap) {
2058
+ if (!this.callbacks) return true;
2059
+ try {
2060
+ await this.callbacks.saveSwap(swap);
2061
+ return true;
2062
+ } catch (error) {
2063
+ this.emitFailed(swap, error);
2064
+ return false;
2065
+ }
2066
+ }
2067
+ /**
2068
+ * Drop a terminal swap from monitoring and report it exactly once.
2069
+ *
2070
+ * `onSwapCompleted` and `onSwapFailed` are mutually exclusive here, unlike
2071
+ * Boltz's manager, which fires completion for every swap that leaves
2072
+ * monitoring including the failed ones — a listener named "completed" that
2073
+ * also fires on failure is a trap worth not inheriting.
2074
+ */
2075
+ finalize(swap) {
2076
+ if (!this.monitored.has(swap.rfqId)) return;
2077
+ this.untrack(swap.rfqId);
2078
+ this.finished.set(swap.rfqId, swap);
2079
+ this.retireContract(swap);
2080
+ if (swap.state === "failed") {
2081
+ notify(
2082
+ this.swapFailedListeners,
2083
+ (listener) => listener(swap, new Error(swap.failure ?? `swap ${swap.rfqId} failed`))
2084
+ );
2085
+ return;
2086
+ }
2087
+ notify(this.swapCompletedListeners, (listener) => listener(swap));
2088
+ }
2089
+ settleWaiters(swap) {
2090
+ const waiting = this.waiters.get(swap.rfqId);
2091
+ if (!waiting) return;
2092
+ if (swap.state === "failed") {
2093
+ const error = new Error(swap.failure ?? `swap ${swap.rfqId} failed`);
2094
+ for (const waiter of waiting) waiter.reject(error);
2095
+ } else if (isPayoutDecided(swap)) {
2096
+ const outcome = outcomeOf(swap);
2097
+ for (const waiter of waiting) waiter.resolve(outcome);
2098
+ } else {
2099
+ return;
2100
+ }
2101
+ this.waiters.delete(swap.rfqId);
2102
+ }
2103
+ };
2104
+ var traderClaimTxid = (swap) => {
2105
+ switch (swap.kind) {
2106
+ case "onchain_send":
2107
+ return swap.claimTxid;
2108
+ case "lightning_receive":
2109
+ return swap.claimArkTxid;
2110
+ default:
2111
+ return void 0;
2112
+ }
2113
+ };
2114
+ var isPayoutDecided = (swap) => swap.state === "settled" || swap.state === "refunded" || swap.kind === "onchain_send" && swap.claimTxid !== void 0;
2115
+ var outcomeOf = (swap) => {
2116
+ const lostReceive = swap.kind === "lightning_receive" && swap.state === "refunded";
2117
+ return {
2118
+ state: swap.state,
2119
+ txid: lostReceive ? swap.refundArkTxid : traderClaimTxid(swap) ?? swap.refundArkTxid
2120
+ };
2121
+ };
2122
+ var errorMessage = (error) => error instanceof Error ? error.message : String(error);
2123
+ var outpointKey = (vtxo) => `${vtxo.txid}:${vtxo.vout}`;
2124
+ export {
2125
+ ARKADE_ASSET,
2126
+ ARKADE_BTC,
2127
+ AddressMismatch,
2128
+ BTC_ASSET_ID,
2129
+ InMemoryAssetSwapRepository,
2130
+ IndexedDbAssetSwapRepository,
2131
+ LIGHTNING_BTC,
2132
+ LIGHTNING_RECEIVE_PAIR,
2133
+ LIGHTNING_SEND_PAIR,
2134
+ LockupAmountMismatchError,
2135
+ LockupNeedsRecoveryError,
2136
+ LockupRegistrationFailed,
2137
+ MAX_MIN_CONFIRMATIONS,
2138
+ MIN_CLAIM_WINDOW_SECONDS,
2139
+ MIN_HEADROOM_SECONDS,
2140
+ OFFER_PACKET_TYPE,
2141
+ ONCHAIN_BTC,
2142
+ ONCHAIN_CLAIM_MARGIN_SECONDS,
2143
+ ONCHAIN_DUST_SATS,
2144
+ ONCHAIN_ORDER_MARGIN_SECONDS,
2145
+ ONCHAIN_RECEIVE_PAIR,
2146
+ ONCHAIN_SECONDS_PER_BLOCK,
2147
+ ONCHAIN_SEND_PAIR,
2148
+ QUOTE_OPTIONS,
2149
+ REFUND_MTP_LAG_SECONDS,
2150
+ RFQ_PREIMAGE_TAG,
2151
+ RFQ_RESOLVED_STATES,
2152
+ RFQ_SWAP_TERMINAL_STATES,
2153
+ RFQ_TERMINAL_STATES,
2154
+ RefundNotLocallyPossibleError,
2155
+ RfqSwapManager,
2156
+ SWAP_LOCKUP_CONTRACT_KIND,
2157
+ SWAP_LOCKUP_CONTRACT_LABEL,
2158
+ SWAP_LOCKUP_CONTRACT_TYPE,
2159
+ SwapRefusal,
2160
+ addAssetSwap,
2161
+ adoptSwapDescriptor,
2162
+ arkadeSwapRequest,
2163
+ assertFundable,
2164
+ assertReceivable,
2165
+ awaitLockupFunding,
2166
+ awaitOnchainFill,
2167
+ awaitRfqResolution,
2168
+ buildHtlcClaim,
2169
+ buildHtlcRefund,
2170
+ buildPreimageMessage,
2171
+ cancelOffer,
2172
+ claimOnchainFill,
2173
+ claimReceiveLockup,
2174
+ classifyDepositSpend,
2175
+ classifyOnchainHtlc,
2176
+ classifySpend,
2177
+ createOffer,
2178
+ decodeOffer,
2179
+ deriveLightningReceive,
2180
+ deriveOnchainReceive,
2181
+ deriveOnchainSend,
2182
+ derivePreimage,
2183
+ deriveSwapSecrets,
2184
+ discoverMarkets,
2185
+ encodeOffer,
2186
+ extractPreimage,
2187
+ findLockupVtxos,
2188
+ findMarket,
2189
+ getAssetSwaps,
2190
+ getAssetSwapsOrThrow,
2191
+ httpTransport,
2192
+ isDeterministicSigner,
2193
+ isRfqSwapTerminal,
2194
+ isRfqTerminal,
2195
+ lightningReceiveRequest,
2196
+ lightningSendRequest,
2197
+ lightningSendVtxoScript,
2198
+ makeCachedFeedFetch,
2199
+ newPreimage,
2200
+ newRfqId,
2201
+ nextOnchainAction,
2202
+ offerTermsFromQuote,
2203
+ offerVtxoScript,
2204
+ onchainHtlcScript,
2205
+ onchainReceiveRequest,
2206
+ onchainSendRequest,
2207
+ paymentHashOf,
2208
+ preimageForRfqSecrets,
2209
+ pushClaim,
2210
+ pushRefundWithoutReceiver,
2211
+ randomSwapSecrets,
2212
+ readLockupFate,
2213
+ receiveVtxoScript,
2214
+ refundIfUnresolved,
2215
+ registerLockupContract,
2216
+ relayTransport,
2217
+ requestLightningReceive,
2218
+ requestLightningSend,
2219
+ requestOnchainReceive,
2220
+ requestOnchainSend,
2221
+ restoreAssetSwaps,
2222
+ retireSettledOfferContracts,
2223
+ rfqPair,
2224
+ rfqSecretsOfRecord,
2225
+ rfqSecretsToRecord,
2226
+ sealClaimPacket,
2227
+ senderIdentityForRfqSecrets,
2228
+ senderIdentityForSwapRecord,
2229
+ senderPubkeyForRfqSecrets,
2230
+ spendTxidsOf,
2231
+ spendUpdate,
2232
+ swapPrograms,
2233
+ unilateralClaimDelay,
2234
+ unilateralRefundDelay,
2235
+ unilateralRefundWithoutReceiverDelay,
2236
+ updateAssetSwap,
2237
+ updateAssetSwapBestEffort,
2238
+ validatePlan,
2239
+ verifyLockupAddress,
2240
+ verifyReceiveInvoice,
2241
+ watchOfferSwaps
2242
+ };