@arkade-os/swap 0.0.14 → 0.0.16

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 CHANGED
@@ -19,6 +19,7 @@ import {
19
19
  ONCHAIN_RECEIVE_PAIR,
20
20
  ONCHAIN_SECONDS_PER_BLOCK,
21
21
  ONCHAIN_SEND_PAIR,
22
+ RFQ_REFUSAL_ERROR_CODES,
22
23
  RFQ_TERMINAL_STATES,
23
24
  SOLO_REFUND_HEADROOM_SECONDS,
24
25
  SWAP_LOCKUP_CONTRACT_KIND,
@@ -39,6 +40,7 @@ import {
39
40
  deriveOnchainSend,
40
41
  extractPreimage,
41
42
  httpTransport,
43
+ isRfqRefusalErrorCode,
42
44
  l1ScriptForAddress,
43
45
  lightningReceiveRequest,
44
46
  lightningSendRequest,
@@ -65,7 +67,7 @@ import {
65
67
  unilateralRefundWithoutReceiverDelay,
66
68
  verifyLockupAddress,
67
69
  verifyReceiveInvoice
68
- } from "./chunk-IFZOBTTG.js";
70
+ } from "./chunk-7N7XO4PY.js";
69
71
  import {
70
72
  InMemoryAssetSwapRepository,
71
73
  marketsCacheKey
@@ -75,8 +77,10 @@ import {
75
77
  import { hex as hex2 } from "@scure/base";
76
78
  import { concatBytes } from "@scure/btc-signer/utils.js";
77
79
  import {
80
+ ASSET_CARRIER_SATS as SDK_ASSET_CARRIER_SATS,
78
81
  ArkAddress,
79
82
  RestArkProvider,
83
+ RestEmulatorProvider,
80
84
  RestIndexerProvider,
81
85
  arkade,
82
86
  asset,
@@ -188,10 +192,10 @@ function addressOutstanding(swaps, script) {
188
192
  }
189
193
  return true;
190
194
  }
191
- async function promoteOfferContract(manager, script) {
195
+ async function promoteOfferContract(manager, script, issued = Date.now()) {
192
196
  await serialize(script, async () => {
193
197
  await manager.setContractWatchState(script, "watched");
194
- issuedAt.set(script, Date.now());
198
+ issuedAt.set(script, issued);
195
199
  });
196
200
  }
197
201
  async function retireOfferContract(manager, swaps, script) {
@@ -397,7 +401,7 @@ function u64(name, value) {
397
401
  if (value < BigInt(0) || value >> BigInt(64) > BigInt(0)) {
398
402
  throw new Error(`${name} does not fit the offer wire format (u64)`);
399
403
  }
400
- const out = new Uint8Array(FIELDS.wantAmount.width);
404
+ const out = new Uint8Array(8);
401
405
  new DataView(out.buffer).setBigUint64(0, value, false);
402
406
  return out;
403
407
  }
@@ -532,10 +536,9 @@ function decodeExitDelay(value) {
532
536
  }
533
537
  var OFFER_CONTRACT_LABEL = "Arkade swap offer";
534
538
  var OFFER_CONTRACT_KIND = "asset-swap-offer";
535
- async function registerOfferContract(wallet, arkServerUrl, network, binding, serverPubkey, expectedPkScript) {
536
- const { program, args, keys } = swapProgramBinding(binding, serverPubkey);
537
- const contractManager = await wallet.getContractManager();
538
- const client = await arkade.Arkade.connect({
539
+ async function registerOfferContract(wallet, arkServerUrl, network, binding, serverPubkey, expectedPkScript, opts = {}) {
540
+ const contractManager = opts.contractManager ?? await wallet.getContractManager();
541
+ const client = opts.client ?? await arkade.Arkade.connect({
539
542
  arkade: new RestArkProvider(arkServerUrl),
540
543
  indexer: new RestIndexerProvider(arkServerUrl),
541
544
  identity: wallet.identity,
@@ -545,15 +548,66 @@ async function registerOfferContract(wallet, arkServerUrl, network, binding, ser
545
548
  network: getNetwork(network),
546
549
  contractManager
547
550
  });
551
+ await contractManager.createContract(
552
+ offerContractParams(client, binding, serverPubkey, expectedPkScript)
553
+ );
554
+ await promoteOfferContract(contractManager, hex2.encode(expectedPkScript), opts.issued);
555
+ }
556
+ function offerContractParams(client, binding, serverPubkey, expectedPkScript) {
557
+ const { program, args, keys } = swapProgramBinding(binding, serverPubkey);
548
558
  const contract = new arkade.ArkadeContract(client, program, args, keys);
549
559
  if (hex2.encode(contract.pkScript) !== hex2.encode(expectedPkScript)) {
550
560
  throw new Error("derived covenant does not match the offer's swapPkScript");
551
561
  }
552
- await contract.register({
562
+ return {
563
+ ...contract.toContractParams(),
553
564
  label: OFFER_CONTRACT_LABEL,
554
565
  metadata: { genericallySpendable: false, kind: OFFER_CONTRACT_KIND }
566
+ };
567
+ }
568
+ async function restoreOfferCoverage(wallet, arkServerUrl, swaps) {
569
+ const live = swaps.filter((s) => !RETIRABLE.includes(s.status));
570
+ if (live.length === 0) return;
571
+ const arkProvider = new RestArkProvider(arkServerUrl);
572
+ const info = await arkProvider.getInfo();
573
+ const serverPubKey = hex2.decode(toXOnlySignerHex(info.signerPubkey));
574
+ const contractManager = await wallet.getContractManager();
575
+ const client = await arkade.Arkade.connect({
576
+ arkade: arkProvider,
577
+ indexer: new RestIndexerProvider(arkServerUrl),
578
+ identity: wallet.identity,
579
+ network: getNetwork(info.network),
580
+ contractManager
555
581
  });
556
- await promoteOfferContract(contractManager, hex2.encode(expectedPkScript));
582
+ const done = /* @__PURE__ */ new Set();
583
+ const covenants = [];
584
+ for (const swap of live) {
585
+ if (done.has(swap.swapPkScript)) continue;
586
+ done.add(swap.swapPkScript);
587
+ try {
588
+ const { swapPkScript: _, ...binding } = decodeOffer(hex2.decode(swap.offerHex));
589
+ covenants.push({
590
+ script: swap.swapPkScript,
591
+ issued: swap.createdAt,
592
+ params: offerContractParams(
593
+ client,
594
+ binding,
595
+ serverPubKey,
596
+ hex2.decode(swap.swapPkScript)
597
+ )
598
+ });
599
+ } catch (err) {
600
+ console.warn(`[swap] could not restore coverage for ${swap.swapPkScript}`, err);
601
+ }
602
+ }
603
+ if (covenants.length === 0) return;
604
+ const paramsList = covenants.map((c) => c.params);
605
+ if (contractManager.createContracts) {
606
+ await contractManager.createContracts(paramsList);
607
+ } else {
608
+ for (const params of paramsList) await contractManager.createContract(params);
609
+ }
610
+ for (const c of covenants) await promoteOfferContract(contractManager, c.script, c.issued);
557
611
  }
558
612
  function serverExitDelay(delay) {
559
613
  if (typeof delay !== "bigint" || delay <= BigInt(0)) {
@@ -666,6 +720,127 @@ async function cancelOffer(wallet, arkServerUrl, offerHex, opts) {
666
720
  }
667
721
  return txid;
668
722
  }
723
+ var ASSET_CARRIER_SATS = BigInt(SDK_ASSET_CARRIER_SATS);
724
+ async function fillOffer(wallet, arkServerUrl, offerHex, opts) {
725
+ const {
726
+ fund,
727
+ payoutScript,
728
+ fundingTxid,
729
+ swapAddress,
730
+ assetCarrierSats = ASSET_CARRIER_SATS,
731
+ emulator,
732
+ emulatorPubkey
733
+ } = opts;
734
+ const offer = decodeOffer(hex2.decode(offerHex));
735
+ const wantedAssetId = offer.wantAsset?.toString();
736
+ if (fund.length === 0) {
737
+ throw new Error("fillOffer needs coins to pay wantAmount with \u2014 `fund` is empty");
738
+ }
739
+ if (wantedAssetId !== void 0) {
740
+ const supplied = fund.reduce(
741
+ (sum, coin) => sum + amountOfAsset(coin.assets, wantedAssetId),
742
+ BigInt(0)
743
+ );
744
+ if (supplied < offer.wantAmount) {
745
+ throw new Error(
746
+ `fillOffer needs ${offer.wantAmount} of ${wantedAssetId} to pay the maker, but \`fund\` declares ${supplied} \u2014 pass coins carrying it, and declare their assets`
747
+ );
748
+ }
749
+ }
750
+ const contractManager = await wallet.getContractManager();
751
+ const client = await arkade.Arkade.connect({
752
+ arkade: new RestArkProvider(arkServerUrl),
753
+ indexer: new RestIndexerProvider(arkServerUrl),
754
+ identity: wallet.identity,
755
+ contractManager,
756
+ // `fulfill` is a covenant path, so the emulator executes the arkade
757
+ // script and finalizes with arkd. Without it the spend is built and then
758
+ // refused at submission — there is no default to fall back on.
759
+ emulator: typeof emulator === "string" ? new RestEmulatorProvider(emulator) : emulator,
760
+ // Only reached for the client's own emulatorKey, which this fill never
761
+ // derives against — the offer's own key is bound below. It still has to
762
+ // resolve, and on a network with no pinned key it throws without this.
763
+ ...emulatorPubkey ? { emulatorPubkey } : {}
764
+ });
765
+ const serverKey = swapAddress ? ArkAddress.decode(swapAddress).serverPubKey : client.serverKey;
766
+ const { program, args, keys } = swapProgramBinding(offer, serverKey);
767
+ const rebuilt = new arkade.ArkadeProgramScript(program, args, keys);
768
+ if (hex2.encode(rebuilt.pkScript) !== hex2.encode(offer.swapPkScript)) {
769
+ throw new Error(
770
+ "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"
771
+ );
772
+ }
773
+ const contract = new arkade.ArkadeContract(client, program, args, keys);
774
+ const [vtxos, takerAddress] = await Promise.all([contract.getUtxos(), wallet.getAddress()]);
775
+ if (!fundingTxid && vtxos.length > 1) {
776
+ throw new Error(
777
+ "multiple spendable deposits at the swap address \u2014 pass fundingTxid to select one"
778
+ );
779
+ }
780
+ const vtxo = fundingTxid ? vtxos.find((v) => v.txid === fundingTxid) : vtxos[0];
781
+ if (!vtxo) throw new Error("no spendable VTXO at the swap address");
782
+ if (offer.offerAsset) {
783
+ const offered = offer.offerAsset.toString();
784
+ const deposited = amountOfAsset(vtxo.assets, offered);
785
+ if (deposited <= BigInt(0)) {
786
+ throw new Error(
787
+ `the deposit at the swap address carries no ${offered}, which this offer sells \u2014 filling it would pay wantAmount for nothing. The indexer may be behind, or the offer may not be backed by what it advertises`
788
+ );
789
+ }
790
+ }
791
+ const payout = payoutScript ?? ArkAddress.decode(takerAddress).pkScript;
792
+ const makerSats = wantedAssetId === void 0 ? offer.wantAmount : assetCarrierSats;
793
+ const fill = contract.functions.fulfill().from({ txid: vtxo.txid, vout: vtxo.vout, value: vtxo.value }).fund(fund).to(offer.makerPkScript, makerSats).change(payout);
794
+ const held = /* @__PURE__ */ new Map();
795
+ const hold = (vin, assets) => {
796
+ for (const a of assets ?? []) {
797
+ const amount = BigInt(a.amount);
798
+ if (amount <= BigInt(0)) continue;
799
+ held.set(a.assetId, [...held.get(a.assetId) ?? [], { vin, amount }]);
800
+ }
801
+ };
802
+ hold(0, vtxo.assets);
803
+ fund.forEach((coin, i) => hold(i + 1, coin.assets));
804
+ const outputsSum = makerSats;
805
+ const inputsSum = fund.reduce((s, c) => s + BigInt(c.value), BigInt(vtxo.value));
806
+ const hasPayoutOutput = inputsSum > outputsSum;
807
+ const emitWanted = () => {
808
+ if (wantedAssetId === void 0) return;
809
+ const supplying = held.get(wantedAssetId) ?? [];
810
+ const supplied = supplying.reduce((s, i) => s + i.amount, BigInt(0));
811
+ const outputs = [{ vout: 0, amount: offer.wantAmount }];
812
+ const surplus = supplied - offer.wantAmount;
813
+ if (surplus > BigInt(0)) {
814
+ if (!hasPayoutOutput) {
815
+ throw new Error(
816
+ `fillOffer has ${surplus} of ${wantedAssetId} to return but no payout output \u2014 fund with more sats than the maker's output takes`
817
+ );
818
+ }
819
+ outputs.push({ vout: 1, amount: surplus });
820
+ }
821
+ fill.withAsset({ assetId: wantedAssetId, inputs: supplying, outputs });
822
+ held.delete(wantedAssetId);
823
+ };
824
+ emitWanted();
825
+ for (const [assetId, inputs] of held) {
826
+ const amount = inputs.reduce((s, i) => s + i.amount, BigInt(0));
827
+ if (!hasPayoutOutput) {
828
+ throw new Error(
829
+ `fillOffer has ${amount} of ${assetId} to return but no payout output \u2014 fund with more sats than the maker's output takes`
830
+ );
831
+ }
832
+ fill.withAsset({ assetId, inputs, outputs: [{ vout: 1, amount }] });
833
+ }
834
+ const { txid } = await fill.send();
835
+ return txid;
836
+ }
837
+ var amountOfAsset = (assets, assetId) => {
838
+ let total = BigInt(0);
839
+ for (const a of assets ?? []) {
840
+ if (a.assetId === assetId) total += BigInt(a.amount);
841
+ }
842
+ return total;
843
+ };
669
844
 
670
845
  // src/markets.ts
671
846
  import {
@@ -675,6 +850,45 @@ import {
675
850
  sideLimits
676
851
  } from "@arkade-os/solver-discovery";
677
852
  import { isSubdust } from "@arkade-os/sdk";
853
+
854
+ // src/marketShape.ts
855
+ var MARKET_CORRIDORS = ["arkade", "bolt11", "bitcoin", "eip155"];
856
+ var LEGACY_CORRIDORS = {
857
+ arkade: "arkade",
858
+ lightning: "bolt11",
859
+ onchain: "bitcoin",
860
+ eip155: "eip155"
861
+ };
862
+ var assetOf = (market, side) => side === "base" ? market.base_asset : market.quote_asset;
863
+ var marketAssetId = (market, side) => {
864
+ const asset2 = assetOf(market, side);
865
+ if (typeof asset2?.caip19_id === "string") return asset2.caip19_id;
866
+ return typeof asset2?.id === "string" ? asset2.id : void 0;
867
+ };
868
+ var chainNamespaceOf = (id) => {
869
+ if (id === void 0) return void 0;
870
+ const slash = id.indexOf("/");
871
+ const colon = id.indexOf(":");
872
+ return slash === -1 || colon === -1 || colon > slash ? void 0 : id.slice(0, colon);
873
+ };
874
+ var marketCorridor = (market, side) => {
875
+ const namespace = chainNamespaceOf(marketAssetId(market, side));
876
+ if (namespace !== void 0) {
877
+ return MARKET_CORRIDORS.includes(namespace) ? namespace : "arkade";
878
+ }
879
+ const legacy = side === "base" ? market.base_corridor : market.quote_corridor;
880
+ return typeof legacy === "string" ? LEGACY_CORRIDORS[legacy] ?? "arkade" : "arkade";
881
+ };
882
+ var isRfqMarket = (market) => marketCorridor(market, "base") !== "arkade" || marketCorridor(market, "quote") !== "arkade";
883
+ var sideLabel = (market, side) => {
884
+ const ticker = assetOf(market, side)?.ticker;
885
+ const label = typeof ticker === "string" && ticker.length > 0 ? ticker : "?";
886
+ const corridor = marketCorridor(market, side);
887
+ return corridor === "arkade" ? label : `${corridor}:${label}`;
888
+ };
889
+ var marketPairLabel = (market) => `${sideLabel(market, "base")}/${sideLabel(market, "quote")}`;
890
+
891
+ // src/markets.ts
678
892
  var QUOTE_OPTIONS = { safetyBps: 0 };
679
893
  var makeCachedFeedFetch = (ttlMs = 3e4, fetchImpl = fetch) => {
680
894
  const cache = /* @__PURE__ */ new Map();
@@ -723,14 +937,17 @@ var makeCachedFeedFetch = (ttlMs = 3e4, fetchImpl = fetch) => {
723
937
  var MARKETS_CACHE_TTL_MS = 60 * 60 * 1e3;
724
938
  var isMarketShaped = (m) => {
725
939
  const market = m;
726
- return typeof market?.pair === "string" && typeof market.base_asset?.id === "string" && typeof market.quote_asset?.id === "string" && typeof market.quote_asset.decimals === "number";
940
+ if (!market) return false;
941
+ return typeof market.base_asset?.id === "string" && typeof market.quote_asset?.id === "string" && typeof market.quote_asset.decimals === "number";
727
942
  };
728
943
  var readMarketsCache = async (repository, network, registry) => {
729
944
  try {
730
945
  const entry = await repository.getCachedMarkets(network, registry);
731
946
  if (!Array.isArray(entry?.markets) || typeof entry?.fetchedAt !== "number")
732
947
  return void 0;
733
- return entry.markets.every(isMarketShaped) ? entry : void 0;
948
+ const markets = entry.markets.filter(isMarketShaped);
949
+ if (entry.markets.length > 0 && markets.length === 0) return void 0;
950
+ return { ...entry, markets };
734
951
  } catch {
735
952
  return void 0;
736
953
  }
@@ -771,10 +988,33 @@ var discoverMarkets = async (options) => {
771
988
  };
772
989
  var findMarket = (markets, fromId, toId) => {
773
990
  if (fromId === toId) return void 0;
774
- const givingBase = bestMarket(markets, { baseId: fromId, quoteId: toId, wantSide: "quote" });
991
+ const resolveMarketId = (id) => {
992
+ for (const market of markets) {
993
+ for (const side of ["base", "quote"]) {
994
+ const asset2 = side === "base" ? market.base_asset : market.quote_asset;
995
+ if (asset2.id === id) return id;
996
+ const canonical = marketAssetId(market, side) ?? "";
997
+ const matchesBtc = id === BTC_ASSET_ID && /^arkade:[^/]+\/slip44:(?:0|1)$/.test(canonical);
998
+ const matchesAsset = /^[0-9a-f]{68}$/.test(id) && canonical.endsWith(`/asset:${id}`);
999
+ if ((matchesBtc || matchesAsset) && typeof asset2.id === "string") return asset2.id;
1000
+ }
1001
+ }
1002
+ return id;
1003
+ };
1004
+ const resolvedFrom = resolveMarketId(fromId);
1005
+ const resolvedTo = resolveMarketId(toId);
1006
+ const givingBase = bestMarket(markets, {
1007
+ baseId: resolvedFrom,
1008
+ quoteId: resolvedTo,
1009
+ wantSide: "quote"
1010
+ });
775
1011
  if (givingBase) return { market: givingBase, give: "base" };
776
1012
  return {
777
- market: bestMarket(markets, { baseId: toId, quoteId: fromId, wantSide: "base" }),
1013
+ market: bestMarket(markets, {
1014
+ baseId: resolvedTo,
1015
+ quoteId: resolvedFrom,
1016
+ wantSide: "base"
1017
+ }),
778
1018
  give: "quote"
779
1019
  };
780
1020
  };
@@ -901,8 +1141,320 @@ var IndexedDbAssetSwapRepository = class {
901
1141
  }
902
1142
  };
903
1143
 
1144
+ // src/restore.ts
1145
+ import { base64, hex as hex3 } from "@scure/base";
1146
+ import {
1147
+ Extension,
1148
+ Transaction,
1149
+ scriptFromTapLeafScript
1150
+ } from "@arkade-os/sdk";
1151
+ var TXS_PER_REQUEST = 50;
1152
+ async function fetchParsedTxs(indexer, txids) {
1153
+ const parsedByTxid = /* @__PURE__ */ new Map();
1154
+ if (txids.length === 0) return parsedByTxid;
1155
+ const chunks = [];
1156
+ for (let i = 0; i < txids.length; i += TXS_PER_REQUEST) {
1157
+ chunks.push(txids.slice(i, i + TXS_PER_REQUEST));
1158
+ }
1159
+ const chunkResults = await Promise.allSettled(
1160
+ chunks.map(async (ids) => (await indexer.getVirtualTxs(ids)).txs)
1161
+ );
1162
+ for (const result of chunkResults) {
1163
+ if (result.status !== "fulfilled") continue;
1164
+ for (const psbt of result.value) {
1165
+ try {
1166
+ const parsed = Transaction.fromPSBT(base64.decode(psbt));
1167
+ parsedByTxid.set(parsed.id, parsed);
1168
+ } catch {
1169
+ }
1170
+ }
1171
+ }
1172
+ return parsedByTxid;
1173
+ }
1174
+ var unscannedSwapCandidates = (txs, existingIds, scanned) => txs.filter(
1175
+ (tx) => tx.type === "sent" && tx.redeemTxid && !existingIds.has(tx.redeemTxid) && !scanned.has(tx.redeemTxid)
1176
+ );
1177
+ function classifySpend(offer, serverPubkey, spendTx, deposit) {
1178
+ let leaves;
1179
+ try {
1180
+ const script = offerVtxoScript(offer, serverPubkey);
1181
+ if (hex3.encode(script.pkScript) !== hex3.encode(offer.swapPkScript)) return "indeterminate";
1182
+ leaves = {
1183
+ // both routes that hand the deposit back; `exit` is absent on an
1184
+ // offer that carries no exit closure, and drops out here
1185
+ returned: ["cancel", "exit"].map((name) => script.functionByName(name)?.leafScript).filter((leaf) => leaf !== void 0),
1186
+ fulfill: script.functionByName("fulfill")?.leafScript
1187
+ };
1188
+ } catch {
1189
+ return "indeterminate";
1190
+ }
1191
+ for (let i = 0; i < spendTx.inputsLength; i++) {
1192
+ const input = spendTx.getInput(i);
1193
+ if (!input.txid || input.index !== deposit.vout) continue;
1194
+ if (hex3.encode(input.txid) !== deposit.txid) continue;
1195
+ for (const leaf of input.tapLeafScript ?? []) {
1196
+ const spent = hex3.encode(scriptFromTapLeafScript(leaf));
1197
+ if (leaves.returned.some((back) => spent === hex3.encode(back))) return "cancelled";
1198
+ if (leaves.fulfill && spent === hex3.encode(leaves.fulfill)) return "fulfilled";
1199
+ }
1200
+ }
1201
+ return "indeterminate";
1202
+ }
1203
+ var spendTxidsOf = (vtxo) => [vtxo.spentBy, vtxo.arkTxId].filter((id) => Boolean(id));
1204
+ function classifyDepositSpend(offer, serverPubkey, spendTxs, deposit) {
1205
+ for (const tx of spendTxs) {
1206
+ const kind = classifySpend(offer, serverPubkey, tx, deposit);
1207
+ if (kind !== "indeterminate") return kind;
1208
+ }
1209
+ return "indeterminate";
1210
+ }
1211
+ async function restoreAssetSwaps(indexer, txs, existingIds, opts) {
1212
+ const { serverPubkey, scanned = /* @__PURE__ */ new Set(), reopen = [] } = opts;
1213
+ const reopened = [];
1214
+ for (const swap of reopen) {
1215
+ try {
1216
+ reopened.push({
1217
+ fundingTx: { type: "sent", redeemTxid: swap.fundingTxid },
1218
+ offer: decodeOffer(hex3.decode(swap.offerHex)),
1219
+ offerHex: swap.offerHex,
1220
+ existing: swap
1221
+ });
1222
+ } catch {
1223
+ }
1224
+ }
1225
+ const reopenedTxids = new Set(reopened.map((f) => f.fundingTx.redeemTxid));
1226
+ const candidates = unscannedSwapCandidates(txs, existingIds, scanned).filter(
1227
+ (tx) => !reopenedTxids.has(tx.redeemTxid)
1228
+ );
1229
+ if (candidates.length === 0 && reopened.length === 0) {
1230
+ return { restored: [], scannedTxids: [] };
1231
+ }
1232
+ const byTxid = new Map(candidates.map((tx) => [tx.redeemTxid, tx]));
1233
+ const parsedByTxid = await fetchParsedTxs(
1234
+ indexer,
1235
+ candidates.map((tx) => tx.redeemTxid)
1236
+ );
1237
+ const fetchedTxids = [];
1238
+ const found = [...reopened];
1239
+ for (const [txid, parsed] of parsedByTxid) {
1240
+ const fundingTx = byTxid.get(txid);
1241
+ if (!fundingTx) continue;
1242
+ fetchedTxids.push(txid);
1243
+ try {
1244
+ const packet = Extension.fromTx(parsed).getPacketByType(OFFER_PACKET_TYPE);
1245
+ if (!packet) continue;
1246
+ const payload = packet.serialize();
1247
+ found.push({
1248
+ fundingTx,
1249
+ offer: decodeOffer(payload),
1250
+ offerHex: hex3.encode(payload)
1251
+ });
1252
+ } catch {
1253
+ }
1254
+ }
1255
+ if (found.length === 0) return { restored: [], scannedTxids: fetchedTxids };
1256
+ const scripts = [...new Set(found.map((f) => hex3.encode(f.offer.swapPkScript)))];
1257
+ const { vtxos } = await indexer.getVtxos({ scripts });
1258
+ const vtxoByScriptAndTxid = new Map(vtxos.map((v) => [`${v.script}:${v.txid}`, v]));
1259
+ const txByAnyId = /* @__PURE__ */ new Map();
1260
+ for (const tx of txs) {
1261
+ for (const id of [tx.boardingTxid, tx.redeemTxid, tx.roundTxid]) {
1262
+ if (id) txByAnyId.set(id, tx);
1263
+ }
1264
+ }
1265
+ const spendTxids = /* @__PURE__ */ new Set();
1266
+ for (const { fundingTx, offer } of found) {
1267
+ const vtxo = vtxoByScriptAndTxid.get(
1268
+ `${hex3.encode(offer.swapPkScript)}:${fundingTx.redeemTxid}`
1269
+ );
1270
+ if (vtxo?.virtualStatus.state !== "spent") continue;
1271
+ for (const txid of spendTxidsOf(vtxo)) spendTxids.add(txid);
1272
+ }
1273
+ const spendTxByTxid = await fetchParsedTxs(indexer, [...spendTxids]);
1274
+ const restored = [];
1275
+ const unresolved = /* @__PURE__ */ new Set();
1276
+ for (const { fundingTx, offer, offerHex, existing } of found) {
1277
+ const swapPkScript = hex3.encode(offer.swapPkScript);
1278
+ const vtxo = vtxoByScriptAndTxid.get(`${swapPkScript}:${fundingTx.redeemTxid}`);
1279
+ if (!vtxo) {
1280
+ unresolved.add(fundingTx.redeemTxid);
1281
+ continue;
1282
+ }
1283
+ const depositRider = vtxo.assets?.length === 1 && vtxo.assets[0].amount > BigInt(0) ? vtxo.assets[0] : void 0;
1284
+ const fromAsset = offer.offerAsset?.toString() ?? depositRider?.assetId ?? BTC_ASSET_ID;
1285
+ const toAsset = offer.wantAsset?.toString() ?? BTC_ASSET_ID;
1286
+ const depositAmount = fromAsset === BTC_ASSET_ID ? BigInt(vtxo.value) : vtxo.assets?.find((a) => a.assetId === fromAsset)?.amount;
1287
+ if (depositAmount === void 0) {
1288
+ unresolved.add(fundingTx.redeemTxid);
1289
+ continue;
1290
+ }
1291
+ const fromAmount = depositAmount.toString();
1292
+ const state = vtxo.virtualStatus.state;
1293
+ const spentTxid = state === "spent" ? vtxo.arkTxId || vtxo.spentBy : void 0;
1294
+ let status = "pending";
1295
+ if (state === "swept") status = "recoverable";
1296
+ else if (state === "spent") {
1297
+ const spendTxs = spendTxidsOf(vtxo).map((id) => spendTxByTxid.get(id)).filter((tx) => tx !== void 0);
1298
+ const kind = classifyDepositSpend(offer, serverPubkey, spendTxs, {
1299
+ txid: vtxo.txid,
1300
+ vout: vtxo.vout
1301
+ });
1302
+ if (kind === "indeterminate") {
1303
+ unresolved.add(fundingTx.redeemTxid);
1304
+ continue;
1305
+ }
1306
+ status = kind;
1307
+ }
1308
+ const completion = status === "fulfilled" && spentTxid && txByAnyId.get(spentTxid)?.createdAt ? { completedAt: txByAnyId.get(spentTxid).createdAt * 1e3 } : {};
1309
+ if (existing) {
1310
+ if (status === "pending") continue;
1311
+ restored.push({ ...existing, status, spentTxid, ...completion });
1312
+ continue;
1313
+ }
1314
+ restored.push({
1315
+ id: fundingTx.redeemTxid,
1316
+ fromAsset,
1317
+ toAsset,
1318
+ fromAmount,
1319
+ toAmount: offer.wantAmount.toString(),
1320
+ // ponytail(arkade-os/ts-sdk#680): empty address makes cancel fall back
1321
+ // to the current server key; store the funded address if server-key
1322
+ // rotations become real (cancelOffer now at least diagnoses the
1323
+ // mismatch instead of reporting a missing VTXO)
1324
+ swapAddress: "",
1325
+ swapPkScript,
1326
+ offerHex,
1327
+ fundingTxid: fundingTx.redeemTxid,
1328
+ spentTxid,
1329
+ status,
1330
+ createdAt: fundingTx.createdAt ? fundingTx.createdAt * 1e3 : vtxo.createdAt.getTime(),
1331
+ ...completion
1332
+ });
1333
+ }
1334
+ return { restored, scannedTxids: fetchedTxids.filter((id) => !unresolved.has(id)) };
1335
+ }
1336
+
1337
+ // src/restoreRepository.ts
1338
+ var isOpen = (swap) => swap.status === "pending" || swap.status === "cancelling";
1339
+ var isOfferSwap = (swap) => typeof swap.offerHex === "string";
1340
+ var restoreCoverage = async (wallet, arkServerUrl, swaps) => {
1341
+ const offers = swaps.filter(isOfferSwap);
1342
+ if (offers.length > 0) await restoreOfferCoverage(wallet, arkServerUrl, offers);
1343
+ };
1344
+ var aborted = (swaps) => ({
1345
+ swaps,
1346
+ changes: [],
1347
+ scannedTxids: [],
1348
+ aborted: true
1349
+ });
1350
+ async function restoreAssetSwapRepository(opts) {
1351
+ const { wallet, arkServerUrl, indexer, repository, txs, serverPubkey, prepareNew, signal } = opts;
1352
+ const [existing, scanned] = await Promise.all([
1353
+ getAssetSwapsOrThrow(repository),
1354
+ repository.getScannedTxids()
1355
+ ]);
1356
+ if (signal?.aborted) return aborted(existing);
1357
+ let scan;
1358
+ try {
1359
+ scan = await restoreAssetSwaps(indexer, txs, new Set(existing.map((swap) => swap.id)), {
1360
+ serverPubkey,
1361
+ scanned,
1362
+ reopen: existing.filter((swap) => isOpen(swap) && isOfferSwap(swap))
1363
+ });
1364
+ } catch (scanError) {
1365
+ if (!signal?.aborted) {
1366
+ try {
1367
+ await restoreCoverage(wallet, arkServerUrl, existing);
1368
+ } catch (coverageError2) {
1369
+ throw new AggregateError(
1370
+ [scanError, coverageError2],
1371
+ "asset-swap scan and covenant coverage restore both failed"
1372
+ );
1373
+ }
1374
+ }
1375
+ throw scanError;
1376
+ }
1377
+ if (signal?.aborted) return aborted(existing);
1378
+ const before = new Map(existing.map((swap) => [swap.id, swap]));
1379
+ const changes = [];
1380
+ const cancelledAfterCommit = async (scannedTxids = []) => ({
1381
+ swaps: await getAssetSwapsOrThrow(repository),
1382
+ changes,
1383
+ scannedTxids,
1384
+ aborted: true
1385
+ });
1386
+ for (const restored of scan.restored) {
1387
+ if (signal?.aborted) return cancelledAfterCommit();
1388
+ const previous = before.get(restored.id);
1389
+ const current = previous !== void 0 || !prepareNew ? restored : await prepareNew(restored);
1390
+ if (current.id !== restored.id) {
1391
+ throw new Error("prepareNew must not change an asset swap id");
1392
+ }
1393
+ if (signal?.aborted) return cancelledAfterCommit();
1394
+ await repository.saveSwap(current);
1395
+ changes.push(previous ? { previous, current } : { current });
1396
+ if (signal?.aborted) return cancelledAfterCommit();
1397
+ }
1398
+ if (signal?.aborted) return cancelledAfterCommit();
1399
+ if (scan.scannedTxids.length > 0) {
1400
+ await repository.markTxidsScanned(scan.scannedTxids);
1401
+ }
1402
+ const swaps = await getAssetSwapsOrThrow(repository);
1403
+ if (signal?.aborted) return cancelledAfterCommit(scan.scannedTxids);
1404
+ let coverageError;
1405
+ try {
1406
+ await restoreCoverage(wallet, arkServerUrl, swaps);
1407
+ } catch (error) {
1408
+ coverageError = error;
1409
+ }
1410
+ return {
1411
+ swaps,
1412
+ changes,
1413
+ scannedTxids: scan.scannedTxids,
1414
+ aborted: false,
1415
+ ...coverageError === void 0 ? {} : { coverageError }
1416
+ };
1417
+ }
1418
+
1419
+ // src/registerRestore.ts
1420
+ import { registerWalletRestoreHook } from "@arkade-os/sdk";
1421
+ var toRestoreTx = (tx) => ({
1422
+ type: tx.type.toLowerCase(),
1423
+ redeemTxid: tx.key.arkTxid,
1424
+ boardingTxid: tx.key.boardingTxid,
1425
+ roundTxid: tx.key.commitmentTxid,
1426
+ createdAt: Math.floor(tx.createdAt / 1e3)
1427
+ });
1428
+ function registerAssetSwapRestore(wallet, options) {
1429
+ return registerWalletRestoreHook(wallet, {
1430
+ id: "arkade-os:asset-swap",
1431
+ restore: async (restoredWallet) => {
1432
+ const dependencies = restoredWallet;
1433
+ const indexer = options.indexer ?? dependencies.indexerProvider;
1434
+ const serverPubkey = options.serverPubkey ?? dependencies.arkServerPublicKey;
1435
+ if (!indexer) {
1436
+ throw new Error("asset-swap restore requires an indexer");
1437
+ }
1438
+ if (!serverPubkey) {
1439
+ throw new Error("asset-swap restore requires an Ark server public key");
1440
+ }
1441
+ const history = await restoredWallet.getTransactionHistory();
1442
+ const result = await restoreAssetSwapRepository({
1443
+ wallet: restoredWallet,
1444
+ arkServerUrl: options.arkServerUrl,
1445
+ indexer,
1446
+ repository: options.repository,
1447
+ txs: history.map(toRestoreTx),
1448
+ serverPubkey,
1449
+ prepareNew: options.prepareNew
1450
+ });
1451
+ await options.onResult?.(result);
1452
+ }
1453
+ });
1454
+ }
1455
+
904
1456
  // src/rfqCorridors.ts
905
- import { hex as hex3 } from "@scure/base";
1457
+ import { hex as hex4 } from "@scure/base";
906
1458
 
907
1459
  // src/rfqCorridor.ts
908
1460
  var RfqCorridorRegistry = class {
@@ -1058,13 +1610,13 @@ var LightningReceiveCorridor = {
1058
1610
  };
1059
1611
  function onchainSendProfile(result) {
1060
1612
  return {
1061
- claimKey: hex3.encode(result.htlcParams.claimKey),
1062
- refundKey: hex3.encode(result.htlcParams.refundKey),
1613
+ claimKey: hex4.encode(result.htlcParams.claimKey),
1614
+ refundKey: hex4.encode(result.htlcParams.refundKey),
1063
1615
  htlcLocktime: result.htlcParams.refundLocktime,
1064
1616
  network: result.l1Network,
1065
1617
  htlcAddress: result.htlc.address,
1066
1618
  minConfirmations: result.minConfirmations,
1067
- payoutPkScript: hex3.encode(result.payoutPkScript)
1619
+ payoutPkScript: hex4.encode(result.payoutPkScript)
1068
1620
  };
1069
1621
  }
1070
1622
  var OnchainSendCorridor = {
@@ -1098,8 +1650,8 @@ var OnchainSendCorridor = {
1098
1650
  // unlocks both legs, but only one of the two hashes of it
1099
1651
  // is recoverable here.
1100
1652
  paymentHash,
1101
- claimKey: hex3.decode(profile.claimKey),
1102
- refundKey: hex3.decode(profile.refundKey),
1653
+ claimKey: hex4.decode(profile.claimKey),
1654
+ refundKey: hex4.decode(profile.refundKey),
1103
1655
  refundLocktime: profile.htlcLocktime
1104
1656
  },
1105
1657
  profile.network
@@ -1115,7 +1667,7 @@ var OnchainSendCorridor = {
1115
1667
  minConfirmations: profile.minConfirmations,
1116
1668
  // Optional here, required at the write: throwing on an older
1117
1669
  // record would strand the refund it is still owed.
1118
- ...profile.payoutPkScript ? { payoutPkScript: hex3.decode(profile.payoutPkScript) } : {},
1670
+ ...profile.payoutPkScript ? { payoutPkScript: hex4.decode(profile.payoutPkScript) } : {},
1119
1671
  ...profile.funding ? { funding: profile.funding } : {},
1120
1672
  ...profile.claimTxid ? { claimTxid: profile.claimTxid } : {}
1121
1673
  };
@@ -1134,7 +1686,7 @@ rfqCorridorHandlers.register(OnchainSendCorridor);
1134
1686
 
1135
1687
  // src/rfqRecord.ts
1136
1688
  import { ArkAddress as ArkAddress2, VHTLCV2ContractHandler } from "@arkade-os/sdk";
1137
- import { hex as hex4 } from "@scure/base";
1689
+ import { hex as hex5 } from "@scure/base";
1138
1690
 
1139
1691
  // src/rfqSwapState.ts
1140
1692
  var RFQ_SWAP_TERMINAL_STATES = ["settled", "refunded", "failed"];
@@ -1158,8 +1710,8 @@ function assertSameSwap(origin, swap) {
1158
1710
  `rfq swap record is a ${origin.kind} origin paired with a ${swap.kind} swap`
1159
1711
  );
1160
1712
  }
1161
- const funded = hex4.encode(ArkAddress2.decode(origin.lockupAddress).pkScript);
1162
- const watched = hex4.encode(swap.lockupPkScript);
1713
+ const funded = hex5.encode(ArkAddress2.decode(origin.lockupAddress).pkScript);
1714
+ const watched = hex5.encode(swap.lockupPkScript);
1163
1715
  if (funded !== watched) {
1164
1716
  throw new Error(
1165
1717
  `rfq swap record's lockup address holds ${funded}, but the swap watches ${watched} \u2014 these are not the same swap`
@@ -1205,9 +1757,9 @@ function rfqSwapOriginOf(record) {
1205
1757
  function lockupScript(params, lockupAddress) {
1206
1758
  const script = VHTLCV2ContractHandler.createScript(params);
1207
1759
  const funded = ArkAddress2.decode(lockupAddress).pkScript;
1208
- if (hex4.encode(funded) !== hex4.encode(script.pkScript)) {
1760
+ if (hex5.encode(funded) !== hex5.encode(script.pkScript)) {
1209
1761
  throw new Error(
1210
- `rfq swap covenant params derive ${hex4.encode(script.pkScript)}, but the record's lockup address holds ${hex4.encode(funded)} \u2014 these params are not this swap's`
1762
+ `rfq swap covenant params derive ${hex5.encode(script.pkScript)}, but the record's lockup address holds ${hex5.encode(funded)} \u2014 these params are not this swap's`
1211
1763
  );
1212
1764
  }
1213
1765
  return script;
@@ -1241,178 +1793,6 @@ function shouldRetainRfqSwap(record, now) {
1241
1793
  return now - record.updatedAt < RFQ_SWAP_RETENTION_SECONDS;
1242
1794
  }
1243
1795
 
1244
- // src/restore.ts
1245
- import { base64, hex as hex5 } from "@scure/base";
1246
- import {
1247
- Extension,
1248
- Transaction,
1249
- scriptFromTapLeafScript
1250
- } from "@arkade-os/sdk";
1251
- var TXS_PER_REQUEST = 50;
1252
- async function fetchParsedTxs(indexer, txids) {
1253
- const parsedByTxid = /* @__PURE__ */ new Map();
1254
- if (txids.length === 0) return parsedByTxid;
1255
- const chunks = [];
1256
- for (let i = 0; i < txids.length; i += TXS_PER_REQUEST) {
1257
- chunks.push(txids.slice(i, i + TXS_PER_REQUEST));
1258
- }
1259
- const chunkResults = await Promise.allSettled(
1260
- chunks.map(async (ids) => (await indexer.getVirtualTxs(ids)).txs)
1261
- );
1262
- for (const result of chunkResults) {
1263
- if (result.status !== "fulfilled") continue;
1264
- for (const psbt of result.value) {
1265
- try {
1266
- const parsed = Transaction.fromPSBT(base64.decode(psbt));
1267
- parsedByTxid.set(parsed.id, parsed);
1268
- } catch {
1269
- }
1270
- }
1271
- }
1272
- return parsedByTxid;
1273
- }
1274
- var unscannedSwapCandidates = (txs, existingIds, scanned) => txs.filter(
1275
- (tx) => tx.type === "sent" && tx.redeemTxid && !existingIds.has(tx.redeemTxid) && !scanned.has(tx.redeemTxid)
1276
- );
1277
- function classifySpend(offer, serverPubkey, spendTx, deposit) {
1278
- let leaves;
1279
- try {
1280
- const script = offerVtxoScript(offer, serverPubkey);
1281
- if (hex5.encode(script.pkScript) !== hex5.encode(offer.swapPkScript)) return "indeterminate";
1282
- leaves = {
1283
- // both routes that hand the deposit back; `exit` is absent on an
1284
- // offer that carries no exit closure, and drops out here
1285
- returned: ["cancel", "exit"].map((name) => script.functionByName(name)?.leafScript).filter((leaf) => leaf !== void 0),
1286
- fulfill: script.functionByName("fulfill")?.leafScript
1287
- };
1288
- } catch {
1289
- return "indeterminate";
1290
- }
1291
- for (let i = 0; i < spendTx.inputsLength; i++) {
1292
- const input = spendTx.getInput(i);
1293
- if (!input.txid || input.index !== deposit.vout) continue;
1294
- if (hex5.encode(input.txid) !== deposit.txid) continue;
1295
- for (const leaf of input.tapLeafScript ?? []) {
1296
- const spent = hex5.encode(scriptFromTapLeafScript(leaf));
1297
- if (leaves.returned.some((back) => spent === hex5.encode(back))) return "cancelled";
1298
- if (leaves.fulfill && spent === hex5.encode(leaves.fulfill)) return "fulfilled";
1299
- }
1300
- }
1301
- return "indeterminate";
1302
- }
1303
- var spendTxidsOf = (vtxo) => [vtxo.spentBy, vtxo.arkTxId].filter((id) => Boolean(id));
1304
- function classifyDepositSpend(offer, serverPubkey, spendTxs, deposit) {
1305
- for (const tx of spendTxs) {
1306
- const kind = classifySpend(offer, serverPubkey, tx, deposit);
1307
- if (kind !== "indeterminate") return kind;
1308
- }
1309
- return "indeterminate";
1310
- }
1311
- async function restoreAssetSwaps(indexer, txs, existingIds, opts) {
1312
- const { serverPubkey, scanned = /* @__PURE__ */ new Set() } = opts;
1313
- const candidates = unscannedSwapCandidates(txs, existingIds, scanned);
1314
- if (candidates.length === 0) return { restored: [], scannedTxids: [] };
1315
- const byTxid = new Map(candidates.map((tx) => [tx.redeemTxid, tx]));
1316
- const parsedByTxid = await fetchParsedTxs(
1317
- indexer,
1318
- candidates.map((tx) => tx.redeemTxid)
1319
- );
1320
- const fetchedTxids = [];
1321
- const found = [];
1322
- for (const [txid, parsed] of parsedByTxid) {
1323
- const fundingTx = byTxid.get(txid);
1324
- if (!fundingTx) continue;
1325
- fetchedTxids.push(txid);
1326
- try {
1327
- const packet = Extension.fromTx(parsed).getPacketByType(OFFER_PACKET_TYPE);
1328
- if (!packet) continue;
1329
- const payload = packet.serialize();
1330
- found.push({
1331
- fundingTx,
1332
- offer: decodeOffer(payload),
1333
- offerHex: hex5.encode(payload)
1334
- });
1335
- } catch {
1336
- }
1337
- }
1338
- if (found.length === 0) return { restored: [], scannedTxids: fetchedTxids };
1339
- const scripts = [...new Set(found.map((f) => hex5.encode(f.offer.swapPkScript)))];
1340
- const { vtxos } = await indexer.getVtxos({ scripts });
1341
- const vtxoByScriptAndTxid = new Map(vtxos.map((v) => [`${v.script}:${v.txid}`, v]));
1342
- const txByAnyId = /* @__PURE__ */ new Map();
1343
- for (const tx of txs) {
1344
- for (const id of [tx.boardingTxid, tx.redeemTxid, tx.roundTxid]) {
1345
- if (id) txByAnyId.set(id, tx);
1346
- }
1347
- }
1348
- const spendTxids = /* @__PURE__ */ new Set();
1349
- for (const { fundingTx, offer } of found) {
1350
- const vtxo = vtxoByScriptAndTxid.get(
1351
- `${hex5.encode(offer.swapPkScript)}:${fundingTx.redeemTxid}`
1352
- );
1353
- if (vtxo?.virtualStatus.state !== "spent") continue;
1354
- for (const txid of spendTxidsOf(vtxo)) spendTxids.add(txid);
1355
- }
1356
- const spendTxByTxid = await fetchParsedTxs(indexer, [...spendTxids]);
1357
- const restored = [];
1358
- const unresolved = /* @__PURE__ */ new Set();
1359
- for (const { fundingTx, offer, offerHex } of found) {
1360
- const swapPkScript = hex5.encode(offer.swapPkScript);
1361
- const vtxo = vtxoByScriptAndTxid.get(`${swapPkScript}:${fundingTx.redeemTxid}`);
1362
- if (!vtxo) {
1363
- unresolved.add(fundingTx.redeemTxid);
1364
- continue;
1365
- }
1366
- const depositRider = vtxo.assets?.length === 1 && vtxo.assets[0].amount > BigInt(0) ? vtxo.assets[0] : void 0;
1367
- const fromAsset = offer.offerAsset?.toString() ?? depositRider?.assetId ?? BTC_ASSET_ID;
1368
- const toAsset = offer.wantAsset?.toString() ?? BTC_ASSET_ID;
1369
- const depositAmount = fromAsset === BTC_ASSET_ID ? BigInt(vtxo.value) : vtxo.assets?.find((a) => a.assetId === fromAsset)?.amount;
1370
- if (depositAmount === void 0) {
1371
- unresolved.add(fundingTx.redeemTxid);
1372
- continue;
1373
- }
1374
- const fromAmount = depositAmount.toString();
1375
- const state = vtxo.virtualStatus.state;
1376
- const spentTxid = state === "spent" ? vtxo.arkTxId || vtxo.spentBy : void 0;
1377
- let status = "pending";
1378
- if (state === "swept") status = "recoverable";
1379
- else if (state === "spent") {
1380
- const spendTxs = spendTxidsOf(vtxo).map((id) => spendTxByTxid.get(id)).filter((tx) => tx !== void 0);
1381
- const kind = classifyDepositSpend(offer, serverPubkey, spendTxs, {
1382
- txid: vtxo.txid,
1383
- vout: vtxo.vout
1384
- });
1385
- if (kind === "indeterminate") {
1386
- unresolved.add(fundingTx.redeemTxid);
1387
- continue;
1388
- }
1389
- status = kind;
1390
- }
1391
- restored.push({
1392
- id: fundingTx.redeemTxid,
1393
- fromAsset,
1394
- toAsset,
1395
- fromAmount,
1396
- toAmount: offer.wantAmount.toString(),
1397
- // ponytail(arkade-os/ts-sdk#680): empty address makes cancel fall back
1398
- // to the current server key; store the funded address if server-key
1399
- // rotations become real (cancelOffer now at least diagnoses the
1400
- // mismatch instead of reporting a missing VTXO)
1401
- swapAddress: "",
1402
- swapPkScript,
1403
- offerHex,
1404
- fundingTxid: fundingTx.redeemTxid,
1405
- spentTxid,
1406
- status,
1407
- createdAt: fundingTx.createdAt ? fundingTx.createdAt * 1e3 : vtxo.createdAt.getTime(),
1408
- // the completion time is the caller's record of the spend, if it
1409
- // has one — the psbt that classified it carries no timestamp
1410
- ...status === "fulfilled" && spentTxid && txByAnyId.get(spentTxid)?.createdAt ? { completedAt: txByAnyId.get(spentTxid).createdAt * 1e3 } : {}
1411
- });
1412
- }
1413
- return { restored, scannedTxids: fetchedTxids.filter((id) => !unresolved.has(id)) };
1414
- }
1415
-
1416
1796
  // src/watch.ts
1417
1797
  import { base64 as base642, hex as hex6 } from "@scure/base";
1418
1798
  import {
@@ -1463,34 +1843,54 @@ async function watchOfferSwaps({
1463
1843
  return "indeterminate";
1464
1844
  }
1465
1845
  };
1466
- const handleSpend = async (event) => {
1467
- if (event.contract.metadata?.kind !== OFFER_CONTRACT_KIND) return;
1468
- for (const vtxo of event.vtxos) {
1846
+ const resolveSpends = async (contractScript, vtxos, { at, known } = {}) => {
1847
+ let swaps = known ?? await getAssetSwaps(repository);
1848
+ for (const vtxo of vtxos) {
1469
1849
  const spentTxid = vtxo.arkTxId || vtxo.spentBy;
1470
1850
  if (!spentTxid) continue;
1471
- const swap = (await getAssetSwaps(repository)).find(
1472
- (s) => s.fundingTxid === vtxo.txid && s.swapPkScript === event.contractScript
1851
+ const swap = swaps.find(
1852
+ (s) => s.fundingTxid === vtxo.txid && s.swapPkScript === contractScript
1473
1853
  );
1474
1854
  if (!swap) continue;
1475
1855
  const kind = await classify(swap, vtxo, spentTxid);
1476
- const changes = spendUpdate(swap, { txid: spentTxid, kind, at: event.timestamp });
1856
+ const changes = spendUpdate(swap, { txid: spentTxid, kind, at });
1477
1857
  if (!changes) continue;
1478
- const { persisted, swaps } = await updateAssetSwapBestEffort(
1858
+ const { persisted, swaps: written } = await updateAssetSwapBestEffort(
1479
1859
  repository,
1480
1860
  swap.id,
1481
1861
  changes
1482
1862
  );
1483
1863
  if (!persisted) continue;
1864
+ swaps = written;
1484
1865
  onUpdate?.({ ...swap, ...changes });
1485
1866
  if (changes.status && RETIRABLE.includes(changes.status)) {
1486
- await retireOfferContract(manager, swaps, event.contractScript);
1867
+ await retireOfferContract(manager, swaps, contractScript);
1487
1868
  }
1488
1869
  }
1489
1870
  };
1871
+ const handleSpend = async (event) => {
1872
+ if (event.contract.metadata?.kind !== OFFER_CONTRACT_KIND) return;
1873
+ await resolveSpends(event.contractScript, event.vtxos, { at: event.timestamp });
1874
+ };
1875
+ const resolveOpenSwaps = async () => {
1876
+ const swaps = await getAssetSwaps(repository);
1877
+ const open = swaps.filter((s) => !TERMINAL.includes(s.status));
1878
+ if (open.length === 0) return;
1879
+ const script = [...new Set(open.map((s) => s.swapPkScript))];
1880
+ for (const { contract, vtxos } of await manager.getContractsWithVtxos({ script })) {
1881
+ if (contract.metadata?.kind !== OFFER_CONTRACT_KIND) continue;
1882
+ await resolveSpends(
1883
+ contract.script,
1884
+ vtxos.filter((v) => v.isSpent),
1885
+ { known: swaps }
1886
+ );
1887
+ }
1888
+ };
1490
1889
  const unsubscribe = manager.onContractEvent((event) => {
1491
1890
  if (!isContractVtxoEvent(event) || event.type !== "vtxo_spent") return;
1492
1891
  enqueue(() => handleSpend(event));
1493
1892
  });
1893
+ enqueue(resolveOpenSwaps);
1494
1894
  return {
1495
1895
  stop: unsubscribe,
1496
1896
  idle: () => queue
@@ -1644,6 +2044,7 @@ function solverOnchainRail(deps) {
1644
2044
  amount,
1645
2045
  fee: swap.fundAmount - amount,
1646
2046
  total: swap.fundAmount,
2047
+ validUntil: swap.quote.valid_until,
1647
2048
  meta: {
1648
2049
  rfqId: swap.rfqId,
1649
2050
  validUntil: swap.quote.valid_until,
@@ -1754,6 +2155,9 @@ function solverLightningRail(deps) {
1754
2155
  amount: facts.amountSats,
1755
2156
  fee: swap.fundAmount - facts.amountSats,
1756
2157
  total: swap.fundAmount,
2158
+ // The invoice can lapse before the quote does; the earlier bound
2159
+ // is the one a holder must observe.
2160
+ validUntil: Math.min(swap.quote.valid_until, facts.expiresAt),
1757
2161
  meta: {
1758
2162
  rfqId: swap.rfqId,
1759
2163
  validUntil: swap.quote.valid_until,
@@ -1811,6 +2215,7 @@ var parseAssetId = (assetId) => {
1811
2215
  return void 0;
1812
2216
  }
1813
2217
  };
2218
+ var marketLabel = (market) => `${market.base_asset.ticker || market.base_asset.id}/${market.quote_asset.ticker || market.quote_asset.id}`;
1814
2219
  function crossAssetRail(deps) {
1815
2220
  const planFor = async (asset2) => {
1816
2221
  const markets = await deps.discover();
@@ -1873,7 +2278,7 @@ function crossAssetRail(deps) {
1873
2278
  spent: { assetId: BTC_ASSET_ID, amount: plan.deposit.atomic }
1874
2279
  },
1875
2280
  meta: {
1876
- market: plan.market.pair,
2281
+ market: marketLabel(plan.market),
1877
2282
  priceDisplay: plan.priceDisplay,
1878
2283
  give: plan.give
1879
2284
  },
@@ -3597,6 +4002,7 @@ async function lockupTxids(indexer, record, wantFunding) {
3597
4002
  export {
3598
4003
  ARKADE_ASSET,
3599
4004
  ARKADE_BTC,
4005
+ ASSET_CARRIER_SATS,
3600
4006
  AddressMismatch,
3601
4007
  BTC_ASSET_ID,
3602
4008
  CROSS_ASSET_RAIL,
@@ -3611,6 +4017,7 @@ export {
3611
4017
  LockupContractMissing,
3612
4018
  LockupNeedsRecoveryError,
3613
4019
  LockupRegistrationFailed,
4020
+ MARKET_CORRIDORS,
3614
4021
  MAX_MIN_CONFIRMATIONS,
3615
4022
  MIN_CLAIM_WINDOW_SECONDS,
3616
4023
  MIN_HEADROOM_SECONDS,
@@ -3625,6 +4032,7 @@ export {
3625
4032
  PreimageNotRecoverableError,
3626
4033
  QUOTE_OPTIONS,
3627
4034
  REFUND_MTP_LAG_SECONDS,
4035
+ RFQ_REFUSAL_ERROR_CODES,
3628
4036
  RFQ_RESOLVED_STATES,
3629
4037
  RFQ_SWAP_RETENTION_SECONDS,
3630
4038
  RFQ_SWAP_TERMINAL_STATES,
@@ -3668,11 +4076,14 @@ export {
3668
4076
  discoverMarkets,
3669
4077
  encodeOffer,
3670
4078
  extractPreimage,
4079
+ fillOffer,
3671
4080
  findLockupVtxos,
3672
4081
  findMarket,
3673
4082
  getAssetSwaps,
3674
4083
  getAssetSwapsOrThrow,
3675
4084
  httpTransport,
4085
+ isRfqMarket,
4086
+ isRfqRefusalErrorCode,
3676
4087
  isRfqSwapTerminal,
3677
4088
  isRfqTerminal,
3678
4089
  l1ScriptForAddress,
@@ -3681,6 +4092,9 @@ export {
3681
4092
  lightningSendVtxoScript,
3682
4093
  lockupContractParams,
3683
4094
  makeCachedFeedFetch,
4095
+ marketAssetId,
4096
+ marketCorridor,
4097
+ marketPairLabel,
3684
4098
  newPreimage,
3685
4099
  newRfqId,
3686
4100
  nextOnchainAction,
@@ -3698,13 +4112,16 @@ export {
3698
4112
  rebuildRfqSwap,
3699
4113
  receiveVtxoScript,
3700
4114
  refundIfUnresolved,
4115
+ registerAssetSwapRestore,
3701
4116
  registerLockupContract,
3702
4117
  relayTransport,
3703
4118
  requestLightningReceive,
3704
4119
  requestLightningSend,
3705
4120
  requestOnchainReceive,
3706
4121
  requestOnchainSend,
4122
+ restoreAssetSwapRepository,
3707
4123
  restoreAssetSwaps,
4124
+ restoreOfferCoverage,
3708
4125
  retireSettledOfferContracts,
3709
4126
  revealClaimPacket,
3710
4127
  revealFieldsFromScript,