@arkade-os/swap 0.0.13 → 0.0.15

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,182 +1793,11 @@ 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 {
1419
1799
  ArkAddress as ArkAddress3,
1800
+ isContractVtxoEvent,
1420
1801
  RestIndexerProvider as RestIndexerProvider3,
1421
1802
  Transaction as Transaction2
1422
1803
  } from "@arkade-os/sdk";
@@ -1462,34 +1843,54 @@ async function watchOfferSwaps({
1462
1843
  return "indeterminate";
1463
1844
  }
1464
1845
  };
1465
- const handleSpend = async (event) => {
1466
- if (event.contract.metadata?.kind !== OFFER_CONTRACT_KIND) return;
1467
- 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) {
1468
1849
  const spentTxid = vtxo.arkTxId || vtxo.spentBy;
1469
1850
  if (!spentTxid) continue;
1470
- const swap = (await getAssetSwaps(repository)).find(
1471
- (s) => s.fundingTxid === vtxo.txid && s.swapPkScript === event.contractScript
1851
+ const swap = swaps.find(
1852
+ (s) => s.fundingTxid === vtxo.txid && s.swapPkScript === contractScript
1472
1853
  );
1473
1854
  if (!swap) continue;
1474
1855
  const kind = await classify(swap, vtxo, spentTxid);
1475
- const changes = spendUpdate(swap, { txid: spentTxid, kind, at: event.timestamp });
1856
+ const changes = spendUpdate(swap, { txid: spentTxid, kind, at });
1476
1857
  if (!changes) continue;
1477
- const { persisted, swaps } = await updateAssetSwapBestEffort(
1858
+ const { persisted, swaps: written } = await updateAssetSwapBestEffort(
1478
1859
  repository,
1479
1860
  swap.id,
1480
1861
  changes
1481
1862
  );
1482
1863
  if (!persisted) continue;
1864
+ swaps = written;
1483
1865
  onUpdate?.({ ...swap, ...changes });
1484
1866
  if (changes.status && RETIRABLE.includes(changes.status)) {
1485
- await retireOfferContract(manager, swaps, event.contractScript);
1867
+ await retireOfferContract(manager, swaps, contractScript);
1486
1868
  }
1487
1869
  }
1488
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
+ };
1489
1889
  const unsubscribe = manager.onContractEvent((event) => {
1490
- if (event.type !== "vtxo_spent") return;
1890
+ if (!isContractVtxoEvent(event) || event.type !== "vtxo_spent") return;
1491
1891
  enqueue(() => handleSpend(event));
1492
1892
  });
1893
+ enqueue(resolveOpenSwaps);
1493
1894
  return {
1494
1895
  stop: unsubscribe,
1495
1896
  idle: () => queue
@@ -1643,6 +2044,7 @@ function solverOnchainRail(deps) {
1643
2044
  amount,
1644
2045
  fee: swap.fundAmount - amount,
1645
2046
  total: swap.fundAmount,
2047
+ validUntil: swap.quote.valid_until,
1646
2048
  meta: {
1647
2049
  rfqId: swap.rfqId,
1648
2050
  validUntil: swap.quote.valid_until,
@@ -1753,6 +2155,9 @@ function solverLightningRail(deps) {
1753
2155
  amount: facts.amountSats,
1754
2156
  fee: swap.fundAmount - facts.amountSats,
1755
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),
1756
2161
  meta: {
1757
2162
  rfqId: swap.rfqId,
1758
2163
  validUntil: swap.quote.valid_until,
@@ -1810,6 +2215,7 @@ var parseAssetId = (assetId) => {
1810
2215
  return void 0;
1811
2216
  }
1812
2217
  };
2218
+ var marketLabel = (market) => `${market.base_asset.ticker || market.base_asset.id}/${market.quote_asset.ticker || market.quote_asset.id}`;
1813
2219
  function crossAssetRail(deps) {
1814
2220
  const planFor = async (asset2) => {
1815
2221
  const markets = await deps.discover();
@@ -1872,7 +2278,7 @@ function crossAssetRail(deps) {
1872
2278
  spent: { assetId: BTC_ASSET_ID, amount: plan.deposit.atomic }
1873
2279
  },
1874
2280
  meta: {
1875
- market: plan.market.pair,
2281
+ market: marketLabel(plan.market),
1876
2282
  priceDisplay: plan.priceDisplay,
1877
2283
  give: plan.give
1878
2284
  },
@@ -2460,6 +2866,9 @@ function arkadeRefunder(deps) {
2460
2866
 
2461
2867
  // src/swapManager.ts
2462
2868
  import { hex as hex12 } from "@scure/base";
2869
+ import {
2870
+ isContractVtxoEvent as isContractVtxoEvent2
2871
+ } from "@arkade-os/sdk";
2463
2872
  function nextOnchainAction(input) {
2464
2873
  switch (input.phase.phase) {
2465
2874
  case "unfunded":
@@ -2949,6 +3358,7 @@ var RfqSwapManager = class {
2949
3358
  });
2950
3359
  return;
2951
3360
  }
3361
+ if (!isContractVtxoEvent2(event)) return;
2952
3362
  const swap = this.byLockupScript.get(event.contractScript);
2953
3363
  if (!swap) return;
2954
3364
  void this.pollSwap(swap).catch(() => {
@@ -3592,6 +4002,7 @@ async function lockupTxids(indexer, record, wantFunding) {
3592
4002
  export {
3593
4003
  ARKADE_ASSET,
3594
4004
  ARKADE_BTC,
4005
+ ASSET_CARRIER_SATS,
3595
4006
  AddressMismatch,
3596
4007
  BTC_ASSET_ID,
3597
4008
  CROSS_ASSET_RAIL,
@@ -3606,6 +4017,7 @@ export {
3606
4017
  LockupContractMissing,
3607
4018
  LockupNeedsRecoveryError,
3608
4019
  LockupRegistrationFailed,
4020
+ MARKET_CORRIDORS,
3609
4021
  MAX_MIN_CONFIRMATIONS,
3610
4022
  MIN_CLAIM_WINDOW_SECONDS,
3611
4023
  MIN_HEADROOM_SECONDS,
@@ -3620,6 +4032,7 @@ export {
3620
4032
  PreimageNotRecoverableError,
3621
4033
  QUOTE_OPTIONS,
3622
4034
  REFUND_MTP_LAG_SECONDS,
4035
+ RFQ_REFUSAL_ERROR_CODES,
3623
4036
  RFQ_RESOLVED_STATES,
3624
4037
  RFQ_SWAP_RETENTION_SECONDS,
3625
4038
  RFQ_SWAP_TERMINAL_STATES,
@@ -3663,11 +4076,14 @@ export {
3663
4076
  discoverMarkets,
3664
4077
  encodeOffer,
3665
4078
  extractPreimage,
4079
+ fillOffer,
3666
4080
  findLockupVtxos,
3667
4081
  findMarket,
3668
4082
  getAssetSwaps,
3669
4083
  getAssetSwapsOrThrow,
3670
4084
  httpTransport,
4085
+ isRfqMarket,
4086
+ isRfqRefusalErrorCode,
3671
4087
  isRfqSwapTerminal,
3672
4088
  isRfqTerminal,
3673
4089
  l1ScriptForAddress,
@@ -3676,6 +4092,9 @@ export {
3676
4092
  lightningSendVtxoScript,
3677
4093
  lockupContractParams,
3678
4094
  makeCachedFeedFetch,
4095
+ marketAssetId,
4096
+ marketCorridor,
4097
+ marketPairLabel,
3679
4098
  newPreimage,
3680
4099
  newRfqId,
3681
4100
  nextOnchainAction,
@@ -3693,13 +4112,16 @@ export {
3693
4112
  rebuildRfqSwap,
3694
4113
  receiveVtxoScript,
3695
4114
  refundIfUnresolved,
4115
+ registerAssetSwapRestore,
3696
4116
  registerLockupContract,
3697
4117
  relayTransport,
3698
4118
  requestLightningReceive,
3699
4119
  requestLightningSend,
3700
4120
  requestOnchainReceive,
3701
4121
  requestOnchainSend,
4122
+ restoreAssetSwapRepository,
3702
4123
  restoreAssetSwaps,
4124
+ restoreOfferCoverage,
3703
4125
  retireSettledOfferContracts,
3704
4126
  revealClaimPacket,
3705
4127
  revealFieldsFromScript,