@arkade-os/swap 0.0.7 → 0.0.9

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
@@ -5,6 +5,8 @@ import {
5
5
  LIGHTNING_BTC,
6
6
  LIGHTNING_RECEIVE_PAIR,
7
7
  LIGHTNING_SEND_PAIR,
8
+ LOCKTIME_THRESHOLD,
9
+ LockupContractMissing,
8
10
  LockupRegistrationFailed,
9
11
  MAX_MIN_CONFIRMATIONS,
10
12
  MIN_CLAIM_WINDOW_SECONDS,
@@ -39,6 +41,7 @@ import {
39
41
  lightningReceiveRequest,
40
42
  lightningSendRequest,
41
43
  lightningSendVtxoScript,
44
+ lockupContractParams,
42
45
  newPreimage,
43
46
  newRfqId,
44
47
  offerTermsFromQuote,
@@ -60,11 +63,11 @@ import {
60
63
  unilateralRefundWithoutReceiverDelay,
61
64
  verifyLockupAddress,
62
65
  verifyReceiveInvoice
63
- } from "./chunk-ZDTRQZE2.js";
66
+ } from "./chunk-2FEMUOIH.js";
64
67
  import {
65
68
  InMemoryAssetSwapRepository,
66
69
  marketsCacheKey
67
- } from "./chunk-WGRU2DBF.js";
70
+ } from "./chunk-6ZUS47GA.js";
68
71
 
69
72
  // src/offer.ts
70
73
  import { hex as hex2 } from "@scure/base";
@@ -696,60 +699,50 @@ var validatePlan = (plan, giveBalance, dust) => {
696
699
  };
697
700
 
698
701
  // src/indexedDbRepository.ts
699
- import { closeDatabase, openDatabase } from "@arkade-os/sdk";
702
+ import {
703
+ awaitTransaction,
704
+ createManagedConnection,
705
+ promisifyRequest
706
+ } from "@arkade-os/sdk";
700
707
  var DEFAULT_DB_NAME = "arkade-intents";
701
- var DB_VERSION = 1;
708
+ var DB_VERSION = 2;
702
709
  var STORE_SWAPS = "swaps";
710
+ var STORE_RFQ_SWAPS = "rfqSwaps";
703
711
  var STORE_SCANNED = "scannedTxids";
704
712
  var STORE_MARKETS = "markets";
705
713
  var STORES = [
706
714
  [STORE_SWAPS, { keyPath: "id" }],
715
+ // v2. Separate from `swaps` rather than sharing it: the two record types
716
+ // have different keys and no consumer wants them interleaved.
717
+ [STORE_RFQ_SWAPS, { keyPath: "rfqId" }],
707
718
  [STORE_SCANNED],
708
719
  [STORE_MARKETS]
709
720
  ];
710
- function initDatabase(db) {
721
+ function initDatabase(db, oldVersion, transaction) {
722
+ void oldVersion;
723
+ void transaction;
711
724
  for (const [name, options] of STORES) {
712
725
  if (!db.objectStoreNames.contains(name)) db.createObjectStore(name, options);
713
726
  }
714
727
  }
715
- var request = (req) => new Promise((resolve, reject) => {
716
- req.onsuccess = () => resolve(req.result);
717
- req.onerror = () => reject(req.error);
718
- });
719
- var txDone = (tx) => new Promise((resolve, reject) => {
720
- tx.oncomplete = () => resolve();
721
- tx.onerror = () => reject(tx.error);
722
- tx.onabort = () => reject(tx.error);
723
- });
724
728
  var IndexedDbAssetSwapRepository = class {
729
+ version = 4;
730
+ connection;
725
731
  constructor(dbName = DEFAULT_DB_NAME) {
726
- this.dbName = dbName;
727
- }
728
- dbName;
729
- version = 2;
730
- // the promise, not the resolved database: openDatabase bumps a refcount on
731
- // every call including cache hits, while dispose closes once, so two
732
- // concurrent first calls would strand the refcount above zero and leak the
733
- // connection for the process lifetime. Cleared on failure so a failed open
734
- // can be retried rather than cached forever.
735
- dbPromise = null;
732
+ this.connection = createManagedConnection(dbName, DB_VERSION, initDatabase);
733
+ }
736
734
  ensureDb() {
737
- return this.dbPromise ??= openDatabase(this.dbName, DB_VERSION, initDatabase).catch(
738
- (err) => {
739
- this.dbPromise = null;
740
- throw err;
741
- }
742
- );
735
+ return this.connection.get();
743
736
  }
744
737
  async readStore(name) {
745
738
  return (await this.ensureDb()).transaction([name], "readonly").objectStore(name);
746
739
  }
747
740
  /** Every write in one place, so none of them can forget to await the
748
741
  * commit. Requests need no individual await: a failed one aborts the
749
- * transaction, which `txDone` reports. */
742
+ * transaction, which `awaitTransaction` reports. */
750
743
  async write(name, apply) {
751
744
  const tx = (await this.ensureDb()).transaction([name], "readwrite");
752
- const done = txDone(tx);
745
+ const done = awaitTransaction(tx);
753
746
  apply(tx.objectStore(name));
754
747
  await done;
755
748
  }
@@ -759,10 +752,26 @@ var IndexedDbAssetSwapRepository = class {
759
752
  });
760
753
  }
761
754
  async getAllSwaps() {
762
- return request((await this.readStore(STORE_SWAPS)).getAll());
755
+ return promisifyRequest((await this.readStore(STORE_SWAPS)).getAll());
756
+ }
757
+ async saveRfqSwap(record) {
758
+ await this.write(STORE_RFQ_SWAPS, (store) => {
759
+ store.put(record);
760
+ });
761
+ }
762
+ async getRfqSwap(rfqId) {
763
+ return promisifyRequest((await this.readStore(STORE_RFQ_SWAPS)).get(rfqId));
764
+ }
765
+ async getAllRfqSwaps() {
766
+ return promisifyRequest((await this.readStore(STORE_RFQ_SWAPS)).getAll());
767
+ }
768
+ async removeRfqSwap(rfqId) {
769
+ await this.write(STORE_RFQ_SWAPS, (store) => {
770
+ store.delete(rfqId);
771
+ });
763
772
  }
764
773
  async getScannedTxids() {
765
- const keys = await request((await this.readStore(STORE_SCANNED)).getAllKeys());
774
+ const keys = await promisifyRequest((await this.readStore(STORE_SCANNED)).getAllKeys());
766
775
  return new Set(keys);
767
776
  }
768
777
  async markTxidsScanned(txids) {
@@ -772,7 +781,7 @@ var IndexedDbAssetSwapRepository = class {
772
781
  }
773
782
  async getCachedMarkets(network, registry) {
774
783
  const store = await this.readStore(STORE_MARKETS);
775
- return request(store.get(marketsCacheKey(network, registry)));
784
+ return promisifyRequest(store.get(marketsCacheKey(network, registry)));
776
785
  }
777
786
  async saveCachedMarkets(network, registry, entry) {
778
787
  await this.write(STORE_MARKETS, (store) => {
@@ -785,19 +794,353 @@ var IndexedDbAssetSwapRepository = class {
785
794
  async clear() {
786
795
  const stores = STORES.map(([name]) => name);
787
796
  const tx = (await this.ensureDb()).transaction(stores, "readwrite");
788
- const done = txDone(tx);
797
+ const done = awaitTransaction(tx);
789
798
  for (const name of stores) tx.objectStore(name).clear();
790
799
  await done;
791
800
  }
792
801
  async [Symbol.asyncDispose]() {
793
- if (!this.dbPromise) return;
794
- await closeDatabase(this.dbName);
795
- this.dbPromise = null;
802
+ await this.connection[Symbol.asyncDispose]();
796
803
  }
797
804
  };
798
805
 
806
+ // src/rfqCorridors.ts
807
+ import { hex as hex3 } from "@scure/base";
808
+
809
+ // src/rfqCorridor.ts
810
+ var RfqCorridorRegistry = class {
811
+ handlers = /* @__PURE__ */ new Map();
812
+ register(handler) {
813
+ if (this.handlers.has(handler.kind)) {
814
+ throw new Error(
815
+ `RFQ corridor handler for kind '${handler.kind}' is already registered`
816
+ );
817
+ }
818
+ this.handlers.set(handler.kind, handler);
819
+ }
820
+ /** Takes a bare `string`, deliberately, where {@link RfqCorridorHandler.kind}
821
+ * is the manager's union: a lookup key comes off a record a backend handed
822
+ * back, and that it typechecks as a `kind` is a claim about the type, not
823
+ * about what was stored. Narrowing this would read as a check already made. */
824
+ get(kind) {
825
+ return this.handlers.get(kind);
826
+ }
827
+ /**
828
+ * The handler for a kind, or a loud failure.
829
+ *
830
+ * A record whose corridor is not registered cannot be restored, and
831
+ * guessing would monitor a swap with no idea how to drive it. Names the
832
+ * registered kinds so a missing `register()` call is obvious.
833
+ */
834
+ getOrThrow(kind) {
835
+ const handler = this.get(kind);
836
+ if (!handler) {
837
+ throw new Error(
838
+ `no RFQ corridor handler registered for kind '${kind}'; registered: ${this.registeredKinds().join(", ") || "none"}`
839
+ );
840
+ }
841
+ return handler;
842
+ }
843
+ has(kind) {
844
+ return this.handlers.has(kind);
845
+ }
846
+ registeredKinds() {
847
+ return [...this.handlers.keys()];
848
+ }
849
+ /** Test seam, mirroring the contract registry's. */
850
+ unregister(kind) {
851
+ return this.handlers.delete(kind);
852
+ }
853
+ };
854
+ var rfqCorridorHandlers = new RfqCorridorRegistry();
855
+
856
+ // src/rfqProfileParts.ts
857
+ var rfqSecretsProfile = (secrets, paymentHash) => {
858
+ const { signingDescriptor, ...preimage } = swapSecretsToRecord(secrets);
859
+ return {
860
+ signer: { signingDescriptor },
861
+ ...paymentHash ? { hashlock: { paymentHash, ...preimage } } : {}
862
+ };
863
+ };
864
+ var parseHex32 = (value, field) => {
865
+ if (typeof value !== "string" || !/^[0-9a-fA-F]{64}$/.test(value)) {
866
+ throw new Error(`${field} must be 32 bytes of hex, got ${JSON.stringify(value)}`);
867
+ }
868
+ return value.toLowerCase();
869
+ };
870
+ var parseSigner = (value) => {
871
+ const signingDescriptor = value?.signingDescriptor;
872
+ if (typeof signingDescriptor !== "string" || signingDescriptor.length === 0) {
873
+ throw new Error(
874
+ `rfq profile.signer carries no signingDescriptor (got ${JSON.stringify(signingDescriptor)})`
875
+ );
876
+ }
877
+ return { signingDescriptor };
878
+ };
879
+ var parseHashlock = (value) => {
880
+ const raw = value ?? {};
881
+ const hashlock = {
882
+ paymentHash: parseHex32(raw.paymentHash, "rfq profile.hashlock.paymentHash")
883
+ };
884
+ if (raw.preimageHex !== void 0) {
885
+ hashlock.preimageHex = parseHex32(raw.preimageHex, "rfq profile.hashlock.preimageHex");
886
+ }
887
+ if (raw.preimageSaltHex !== void 0) {
888
+ hashlock.preimageSaltHex = parseHex32(
889
+ raw.preimageSaltHex,
890
+ "rfq profile.hashlock.preimageSaltHex"
891
+ );
892
+ }
893
+ return hashlock;
894
+ };
895
+ var hydrateHashlock = (profile) => {
896
+ try {
897
+ return { paymentHash: parseHashlock(profile.hashlock).paymentHash };
898
+ } catch (cause) {
899
+ throw new Error(
900
+ `rfq record carries no usable hashlock; it cannot verify a preimage: ${String(cause)}`,
901
+ { cause }
902
+ );
903
+ }
904
+ };
905
+ var rfqSignerOf = (record) => {
906
+ const signer = record.profile.signer;
907
+ if (signer === void 0) return void 0;
908
+ return parseSigner(signer);
909
+ };
910
+ var rfqClaimSecretOf = (record) => {
911
+ const handler = rfqCorridorHandlers.getOrThrow(record.kind);
912
+ if (!handler.claimSecret) return void 0;
913
+ try {
914
+ const claim = handler.claimSecret(record.profile);
915
+ return { ...parseSigner(claim), ...parseHashlock(claim) };
916
+ } catch (cause) {
917
+ throw new PreimageNotRecoverableError(
918
+ "malformed-record",
919
+ `this ${record.kind} record's claim secret is unreadable: ${String(cause)}`,
920
+ { cause }
921
+ );
922
+ }
923
+ };
924
+
925
+ // src/rfqCorridors.ts
926
+ var LightningSendCorridor = {
927
+ kind: "lightning_send",
928
+ project: () => ({}),
929
+ hydrate: (profile) => hydrateHashlock(profile)
930
+ // No `claimSecret`, deliberately: `provisionRefundKey` mints no preimage at
931
+ // all, and deriving one off the refund descriptor would fail the payment
932
+ // hash check — a `hash-mismatch` on a swap that was never broken.
933
+ };
934
+ var LightningReceiveCorridor = {
935
+ kind: "lightning_receive",
936
+ // What the manager holds: the amount gate, and its own claim once it lands.
937
+ // `payoutAddress` comes from the request result and never changes, so the
938
+ // caller writes it once and this leaves it alone.
939
+ project: (swap) => {
940
+ const receive = swap;
941
+ return {
942
+ expectedAmount: receive.expectedAmount,
943
+ ...receive.claimArkTxid ? { claimArkTxid: receive.claimArkTxid } : {}
944
+ };
945
+ },
946
+ hydrate(profile) {
947
+ if (typeof profile.expectedAmount !== "number" || !Number.isFinite(profile.expectedAmount)) {
948
+ throw new Error("lightning_receive record carries no expectedAmount; it cannot claim");
949
+ }
950
+ return {
951
+ ...hydrateHashlock(profile),
952
+ expectedAmount: profile.expectedAmount,
953
+ ...profile.claimArkTxid ? { claimArkTxid: profile.claimArkTxid } : {}
954
+ };
955
+ },
956
+ // We are the claimant here, so the preimage material on the hashlock is
957
+ // ours to use.
958
+ claimSecret: (profile) => ({ ...profile.signer, ...profile.hashlock }),
959
+ activityTxids: (profile) => profile.claimArkTxid ? [profile.claimArkTxid] : []
960
+ };
961
+ function onchainSendProfile(result) {
962
+ return {
963
+ claimKey: hex3.encode(result.htlcParams.claimKey),
964
+ refundKey: hex3.encode(result.htlcParams.refundKey),
965
+ htlcLocktime: result.htlcParams.refundLocktime,
966
+ network: result.l1Network,
967
+ htlcAddress: result.htlc.address,
968
+ minConfirmations: result.minConfirmations
969
+ };
970
+ }
971
+ var OnchainSendCorridor = {
972
+ kind: "onchain_send",
973
+ // The L1 keys and the network are only in the request result — nothing on
974
+ // `OnchainSendSwap` carries them — so the caller writes them once. What the
975
+ // manager learns as it drives the swap is the fill and our own claim.
976
+ project: (swap) => {
977
+ const send = swap;
978
+ return {
979
+ ...send.funding ? { funding: send.funding } : {},
980
+ ...send.claimTxid ? { claimTxid: send.claimTxid } : {}
981
+ };
982
+ },
983
+ hydrate(profile) {
984
+ const { paymentHash } = hydrateHashlock(profile);
985
+ if (!profile.claimKey || !profile.refundKey) {
986
+ throw new Error(
987
+ "onchain_send record carries no L1 keys; its HTLC cannot be rebuilt and its refund window would pass unwatched"
988
+ );
989
+ }
990
+ if (!Number.isInteger(profile.minConfirmations) || profile.minConfirmations < 1) {
991
+ throw new Error(
992
+ `onchain_send record carries no usable minConfirmations (${String(profile.minConfirmations)}); the confirmation gate cannot be checked \u2014 refusing to restore a swap that would claim an unconfirmed fill`
993
+ );
994
+ }
995
+ const htlc = onchainHtlcScript(
996
+ {
997
+ // From the profile, not the covenant: the lockup commits to
998
+ // `hash160(P)`, and the HTLC needs `sha256(P)`. One P
999
+ // unlocks both legs, but only one of the two hashes of it
1000
+ // is recoverable here.
1001
+ paymentHash,
1002
+ claimKey: hex3.decode(profile.claimKey),
1003
+ refundKey: hex3.decode(profile.refundKey),
1004
+ refundLocktime: profile.htlcLocktime
1005
+ },
1006
+ profile.network
1007
+ );
1008
+ if (htlc.address !== profile.htlcAddress) {
1009
+ throw new Error(
1010
+ `onchain_send record's L1 inputs derive ${htlc.address}, but the fill was expected at ${String(profile.htlcAddress)} \u2014 these are not this swap's`
1011
+ );
1012
+ }
1013
+ return {
1014
+ paymentHash,
1015
+ htlc,
1016
+ minConfirmations: profile.minConfirmations,
1017
+ ...profile.funding ? { funding: profile.funding } : {},
1018
+ ...profile.claimTxid ? { claimTxid: profile.claimTxid } : {}
1019
+ };
1020
+ },
1021
+ // The trader claims the L1 HTLC with P, so this leg's preimage material is
1022
+ // ours.
1023
+ claimSecret: (profile) => ({ ...profile.signer, ...profile.hashlock }),
1024
+ // Our own L1 claim only. `funding` is the SOLVER's fill into the HTLC —
1025
+ // not a transaction of ours, so grouping it would claim a row this wallet
1026
+ // never made.
1027
+ activityTxids: (profile) => profile.claimTxid ? [profile.claimTxid] : []
1028
+ };
1029
+ rfqCorridorHandlers.register(LightningSendCorridor);
1030
+ rfqCorridorHandlers.register(LightningReceiveCorridor);
1031
+ rfqCorridorHandlers.register(OnchainSendCorridor);
1032
+
1033
+ // src/rfqRecord.ts
1034
+ import { ArkAddress as ArkAddress2, VHTLCV2ContractHandler } from "@arkade-os/sdk";
1035
+ import { hex as hex4 } from "@scure/base";
1036
+
1037
+ // src/rfqSwapState.ts
1038
+ var RFQ_SWAP_TERMINAL_STATES = ["settled", "refunded", "failed"];
1039
+ var isRfqSwapTerminal = (state) => RFQ_SWAP_TERMINAL_STATES.includes(state);
1040
+
1041
+ // src/rfqRecord.ts
1042
+ var RFQ_SWAP_RETENTION_SECONDS = 30 * 24 * 60 * 60;
1043
+ var managerState = (swap) => ({
1044
+ rfqId: swap.rfqId,
1045
+ state: swap.state,
1046
+ createdAt: swap.createdAt,
1047
+ updatedAt: swap.updatedAt,
1048
+ ...swap.refundArkTxid ? { refundArkTxid: swap.refundArkTxid } : {},
1049
+ ...swap.lockupSpendArkTxids?.length ? { lockupSpendArkTxids: [...swap.lockupSpendArkTxids] } : {},
1050
+ ...swap.failure ? { failure: swap.failure } : {},
1051
+ ...swap.blockedReason ? { blockedReason: swap.blockedReason } : {}
1052
+ });
1053
+ function assertSameSwap(origin, swap) {
1054
+ if (origin.kind !== swap.kind) {
1055
+ throw new Error(
1056
+ `rfq swap record is a ${origin.kind} origin paired with a ${swap.kind} swap`
1057
+ );
1058
+ }
1059
+ const funded = hex4.encode(ArkAddress2.decode(origin.lockupAddress).pkScript);
1060
+ const watched = hex4.encode(swap.lockupPkScript);
1061
+ if (funded !== watched) {
1062
+ throw new Error(
1063
+ `rfq swap record's lockup address holds ${funded}, but the swap watches ${watched} \u2014 these are not the same swap`
1064
+ );
1065
+ }
1066
+ }
1067
+ function createRfqSwapRecord(origin, swap) {
1068
+ assertSameSwap(origin, swap);
1069
+ const handler = rfqCorridorHandlers.getOrThrow(origin.kind);
1070
+ return {
1071
+ ...origin,
1072
+ ...managerState(swap),
1073
+ // The caller wrote what only the request result knows; the handler adds
1074
+ // what the live swap already carries.
1075
+ profile: { ...origin.profile, ...handler.project(swap) }
1076
+ };
1077
+ }
1078
+ function updateRfqSwapRecord(record, swap) {
1079
+ assertSameSwap(record, swap);
1080
+ const {
1081
+ refundArkTxid: _refundArkTxid,
1082
+ lockupSpendArkTxids: _lockupSpendArkTxids,
1083
+ failure: _failure,
1084
+ blockedReason: _blockedReason,
1085
+ ...origin
1086
+ } = record;
1087
+ const handler = rfqCorridorHandlers.getOrThrow(record.kind);
1088
+ return {
1089
+ ...origin,
1090
+ ...managerState(swap),
1091
+ profile: { ...record.profile, ...handler.project(swap) }
1092
+ };
1093
+ }
1094
+ function rfqSwapOriginOf(record) {
1095
+ return {
1096
+ kind: record.kind,
1097
+ lockupAddress: record.lockupAddress,
1098
+ profile: { ...record.profile },
1099
+ ...record.amount !== void 0 ? { amount: record.amount } : {},
1100
+ ...record.fundingArkTxid ? { fundingArkTxid: record.fundingArkTxid } : {}
1101
+ };
1102
+ }
1103
+ function lockupScript(params, lockupAddress) {
1104
+ const script = VHTLCV2ContractHandler.createScript(params);
1105
+ const funded = ArkAddress2.decode(lockupAddress).pkScript;
1106
+ if (hex4.encode(funded) !== hex4.encode(script.pkScript)) {
1107
+ throw new Error(
1108
+ `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`
1109
+ );
1110
+ }
1111
+ return script;
1112
+ }
1113
+ function rebuildRfqSwap(record, params) {
1114
+ const script = lockupScript(params, record.lockupAddress);
1115
+ const common = {
1116
+ rfqId: record.rfqId,
1117
+ state: record.state,
1118
+ lockupPkScript: script.pkScript,
1119
+ lockup: { script, address: record.lockupAddress },
1120
+ // From the covenant, which binds it: the record's own copy would be a
1121
+ // second source for the deadline the refund is gated on.
1122
+ refundLocktime: Number(script.options.refundLocktime),
1123
+ createdAt: record.createdAt,
1124
+ updatedAt: record.updatedAt,
1125
+ ...record.refundArkTxid ? { refundArkTxid: record.refundArkTxid } : {},
1126
+ ...record.lockupSpendArkTxids?.length ? { lockupSpendArkTxids: [...record.lockupSpendArkTxids] } : {},
1127
+ ...record.failure ? { failure: record.failure } : {},
1128
+ ...record.blockedReason ? { blockedReason: record.blockedReason } : {}
1129
+ };
1130
+ const handler = rfqCorridorHandlers.getOrThrow(record.kind);
1131
+ return {
1132
+ ...common,
1133
+ kind: record.kind,
1134
+ ...handler.hydrate(record.profile, { lockup: script })
1135
+ };
1136
+ }
1137
+ function shouldRetainRfqSwap(record, now) {
1138
+ if (!isRfqSwapTerminal(record.state)) return true;
1139
+ return now - record.updatedAt < RFQ_SWAP_RETENTION_SECONDS;
1140
+ }
1141
+
799
1142
  // src/restore.ts
800
- import { base64, hex as hex3 } from "@scure/base";
1143
+ import { base64, hex as hex5 } from "@scure/base";
801
1144
  import {
802
1145
  Extension,
803
1146
  Transaction,
@@ -833,7 +1176,7 @@ function classifySpend(offer, serverPubkey, spendTx, deposit) {
833
1176
  let leaves;
834
1177
  try {
835
1178
  const script = offerVtxoScript(offer, serverPubkey);
836
- if (hex3.encode(script.pkScript) !== hex3.encode(offer.swapPkScript)) return "indeterminate";
1179
+ if (hex5.encode(script.pkScript) !== hex5.encode(offer.swapPkScript)) return "indeterminate";
837
1180
  leaves = {
838
1181
  cancel: script.functionByName("cancel")?.leafScript,
839
1182
  fulfill: script.functionByName("fulfill")?.leafScript
@@ -844,11 +1187,11 @@ function classifySpend(offer, serverPubkey, spendTx, deposit) {
844
1187
  for (let i = 0; i < spendTx.inputsLength; i++) {
845
1188
  const input = spendTx.getInput(i);
846
1189
  if (!input.txid || input.index !== deposit.vout) continue;
847
- if (hex3.encode(input.txid) !== deposit.txid) continue;
1190
+ if (hex5.encode(input.txid) !== deposit.txid) continue;
848
1191
  for (const leaf of input.tapLeafScript ?? []) {
849
- const spent = hex3.encode(scriptFromTapLeafScript(leaf));
850
- if (leaves.cancel && spent === hex3.encode(leaves.cancel)) return "cancelled";
851
- if (leaves.fulfill && spent === hex3.encode(leaves.fulfill)) return "fulfilled";
1192
+ const spent = hex5.encode(scriptFromTapLeafScript(leaf));
1193
+ if (leaves.cancel && spent === hex5.encode(leaves.cancel)) return "cancelled";
1194
+ if (leaves.fulfill && spent === hex5.encode(leaves.fulfill)) return "fulfilled";
852
1195
  }
853
1196
  }
854
1197
  return "indeterminate";
@@ -883,13 +1226,13 @@ async function restoreAssetSwaps(indexer, txs, existingIds, opts) {
883
1226
  found.push({
884
1227
  fundingTx,
885
1228
  offer: decodeOffer(payload),
886
- offerHex: hex3.encode(payload)
1229
+ offerHex: hex5.encode(payload)
887
1230
  });
888
1231
  } catch {
889
1232
  }
890
1233
  }
891
1234
  if (found.length === 0) return { restored: [], scannedTxids: fetchedTxids };
892
- const scripts = [...new Set(found.map((f) => hex3.encode(f.offer.swapPkScript)))];
1235
+ const scripts = [...new Set(found.map((f) => hex5.encode(f.offer.swapPkScript)))];
893
1236
  const { vtxos } = await indexer.getVtxos({ scripts });
894
1237
  const vtxoByScriptAndTxid = new Map(vtxos.map((v) => [`${v.script}:${v.txid}`, v]));
895
1238
  const txByAnyId = /* @__PURE__ */ new Map();
@@ -901,7 +1244,7 @@ async function restoreAssetSwaps(indexer, txs, existingIds, opts) {
901
1244
  const spendTxids = /* @__PURE__ */ new Set();
902
1245
  for (const { fundingTx, offer } of found) {
903
1246
  const vtxo = vtxoByScriptAndTxid.get(
904
- `${hex3.encode(offer.swapPkScript)}:${fundingTx.redeemTxid}`
1247
+ `${hex5.encode(offer.swapPkScript)}:${fundingTx.redeemTxid}`
905
1248
  );
906
1249
  if (vtxo?.virtualStatus.state !== "spent") continue;
907
1250
  for (const txid of spendTxidsOf(vtxo)) spendTxids.add(txid);
@@ -910,7 +1253,7 @@ async function restoreAssetSwaps(indexer, txs, existingIds, opts) {
910
1253
  const restored = [];
911
1254
  const unresolved = /* @__PURE__ */ new Set();
912
1255
  for (const { fundingTx, offer, offerHex } of found) {
913
- const swapPkScript = hex3.encode(offer.swapPkScript);
1256
+ const swapPkScript = hex5.encode(offer.swapPkScript);
914
1257
  const vtxo = vtxoByScriptAndTxid.get(`${swapPkScript}:${fundingTx.redeemTxid}`);
915
1258
  if (!vtxo) {
916
1259
  unresolved.add(fundingTx.redeemTxid);
@@ -967,9 +1310,9 @@ async function restoreAssetSwaps(indexer, txs, existingIds, opts) {
967
1310
  }
968
1311
 
969
1312
  // src/watch.ts
970
- import { base64 as base642, hex as hex4 } from "@scure/base";
1313
+ import { base64 as base642, hex as hex6 } from "@scure/base";
971
1314
  import {
972
- ArkAddress as ArkAddress2,
1315
+ ArkAddress as ArkAddress3,
973
1316
  RestIndexerProvider as RestIndexerProvider3,
974
1317
  Transaction as Transaction2
975
1318
  } from "@arkade-os/sdk";
@@ -992,7 +1335,7 @@ async function watchOfferSwaps({
992
1335
  onUpdate
993
1336
  }) {
994
1337
  const manager = await wallet.getContractManager();
995
- const serverPubkey = ArkAddress2.decode(await wallet.getAddress()).serverPubKey;
1338
+ const serverPubkey = ArkAddress3.decode(await wallet.getAddress()).serverPubKey;
996
1339
  const indexer = new RestIndexerProvider3(arkServerUrl);
997
1340
  let queue = Promise.resolve();
998
1341
  const enqueue = (task) => {
@@ -1006,7 +1349,7 @@ async function watchOfferSwaps({
1006
1349
  if (candidates.length === 0) return "indeterminate";
1007
1350
  const { txs } = await indexer.getVirtualTxs(candidates);
1008
1351
  return classifyDepositSpend(
1009
- decodeOffer(hex4.decode(swap.offerHex)),
1352
+ decodeOffer(hex6.decode(swap.offerHex)),
1010
1353
  serverPubkey,
1011
1354
  txs.map((psbt) => Transaction2.fromPSBT(base642.decode(psbt))),
1012
1355
  { txid: vtxo.txid, vout: vtxo.vout }
@@ -1050,7 +1393,7 @@ async function watchOfferSwaps({
1050
1393
  }
1051
1394
 
1052
1395
  // src/claim.ts
1053
- import { hex as hex6 } from "@scure/base";
1396
+ import { hex as hex8 } from "@scure/base";
1054
1397
  import { ripemd160 } from "@noble/hashes/legacy.js";
1055
1398
  import { sha256 as sha2563 } from "@noble/hashes/sha2.js";
1056
1399
  import {
@@ -1060,7 +1403,7 @@ import {
1060
1403
  } from "@arkade-os/sdk";
1061
1404
 
1062
1405
  // src/refund.ts
1063
- import { base64 as base643, hex as hex5 } from "@scure/base";
1406
+ import { base64 as base643, hex as hex7 } from "@scure/base";
1064
1407
  import { sha256 as sha2562 } from "@noble/hashes/sha2.js";
1065
1408
  import {
1066
1409
  CSVMultisigTapscript,
@@ -1117,7 +1460,7 @@ var LockupNeedsRecoveryError = class extends Error {
1117
1460
  }
1118
1461
  };
1119
1462
  async function findLockupVtxos(indexer, swapPkScript) {
1120
- const scripts = [hex5.encode(swapPkScript)];
1463
+ const scripts = [hex7.encode(swapPkScript)];
1121
1464
  const [spendable, recoverable] = await Promise.all([
1122
1465
  indexer.getVtxos({ scripts, spendableOnly: true }),
1123
1466
  indexer.getVtxos({ scripts, recoverableOnly: true })
@@ -1142,23 +1485,28 @@ async function findLockupVtxos(indexer, swapPkScript) {
1142
1485
  }
1143
1486
  return out;
1144
1487
  }
1145
- var hashesTo = (candidate, paymentHash) => hex5.encode(sha2562(candidate)) === paymentHash;
1488
+ var hashesTo = (candidate, paymentHash) => hex7.encode(sha2562(candidate)) === paymentHash;
1146
1489
  var candidateWitnessItems = (tx, inputIndex) => [
1147
1490
  ...getArkPsbtFields(tx, inputIndex, ConditionWitness).flat(),
1148
1491
  ...tx.getInput(inputIndex).finalScriptWitness ?? []
1149
1492
  ];
1150
1493
  async function readLockupFate(indexer, input) {
1151
- const { vtxos } = await indexer.getVtxos({ scripts: [hex5.encode(input.swapPkScript)] });
1494
+ const { vtxos } = await indexer.getVtxos({ scripts: [hex7.encode(input.swapPkScript)] });
1152
1495
  const all = vtxos ?? [];
1153
1496
  if (all.length === 0) return { fate: "unknown" };
1154
- const spentBy = /* @__PURE__ */ new Set();
1497
+ const spentBy = /* @__PURE__ */ new Map();
1155
1498
  let everySpendNamed = true;
1156
1499
  for (const vtxo of all) {
1157
1500
  if (!vtxo.isSpent && !vtxo.spentBy && !vtxo.settledBy) return { fate: "open" };
1158
- if (vtxo.spentBy) spentBy.add(vtxo.spentBy);
1501
+ if (vtxo.spentBy)
1502
+ spentBy.set(vtxo.spentBy, {
1503
+ checkpointTxid: vtxo.spentBy,
1504
+ arkTxid: vtxo.arkTxId
1505
+ });
1159
1506
  else everySpendNamed = false;
1160
1507
  }
1161
- const { txs } = await indexer.getVirtualTxs([...spentBy]);
1508
+ const spends = [...spentBy.values()];
1509
+ const { txs } = await indexer.getVirtualTxs([...spentBy.keys()]);
1162
1510
  const observed = /* @__PURE__ */ new Set();
1163
1511
  for (const raw of txs) {
1164
1512
  let tx;
@@ -1171,16 +1519,16 @@ async function readLockupFate(indexer, input) {
1171
1519
  for (let i = 0; i < tx.inputsLength; i++) {
1172
1520
  const spent = tx.getInput(i);
1173
1521
  if (!spent.txid) continue;
1174
- const txid = hex5.encode(spent.txid);
1522
+ const txid = hex7.encode(spent.txid);
1175
1523
  if (!all.some((vtxo) => vtxo.txid === txid && vtxo.vout === spent.index)) continue;
1176
1524
  for (const candidate of candidateWitnessItems(tx, i)) {
1177
1525
  if (hashesTo(candidate, input.paymentHash)) {
1178
- return { fate: "claimed", preimage: candidate };
1526
+ return { fate: "claimed", preimage: candidate, spends };
1179
1527
  }
1180
1528
  }
1181
1529
  }
1182
1530
  }
1183
- return everySpendNamed && observed.size === spentBy.size ? { fate: "returned" } : { fate: "unknown" };
1531
+ return everySpendNamed && observed.size === spentBy.size ? { fate: "returned", spends } : { fate: "unknown" };
1184
1532
  }
1185
1533
  async function pushRefundWithoutReceiver(ark, input) {
1186
1534
  if (input.vtxos.length === 0) throw new Error("nothing to refund: no funded outputs");
@@ -1200,7 +1548,7 @@ async function pushRefundWithoutReceiver(ark, input) {
1200
1548
  const info = await ark.getInfo();
1201
1549
  let serverUnrollScript;
1202
1550
  try {
1203
- serverUnrollScript = CSVMultisigTapscript.decode(hex5.decode(info.checkpointTapscript));
1551
+ serverUnrollScript = CSVMultisigTapscript.decode(hex7.decode(info.checkpointTapscript));
1204
1552
  } catch {
1205
1553
  throw new Error("invalid checkpointTapscript from the Arkade server");
1206
1554
  }
@@ -1311,13 +1659,13 @@ async function pushClaim(ark, input) {
1311
1659
  }
1312
1660
  }
1313
1661
  const committed = input.script.options.preimageHash;
1314
- if (hex6.encode(ripemd160(sha2563(input.preimage))) !== hex6.encode(committed)) {
1662
+ if (hex8.encode(ripemd160(sha2563(input.preimage))) !== hex8.encode(committed)) {
1315
1663
  throw new Error("preimage does not match the covenant's payment hash");
1316
1664
  }
1317
1665
  const info = await ark.getInfo();
1318
1666
  let serverUnrollScript;
1319
1667
  try {
1320
- serverUnrollScript = CSVMultisigTapscript2.decode(hex6.decode(info.checkpointTapscript));
1668
+ serverUnrollScript = CSVMultisigTapscript2.decode(hex8.decode(info.checkpointTapscript));
1321
1669
  } catch {
1322
1670
  throw new Error("invalid checkpointTapscript from the Arkade server");
1323
1671
  }
@@ -1412,10 +1760,31 @@ async function senderIdentityForSwapRecord(wallet, record) {
1412
1760
  }
1413
1761
  }
1414
1762
 
1763
+ // src/arkadeRefunder.ts
1764
+ function arkadeRefunder(deps) {
1765
+ return async (swap) => {
1766
+ const script = swap.lockup?.script;
1767
+ if (!script) {
1768
+ throw new Error(
1769
+ `swap ${swap.rfqId} carries no lockup covenant, so its refund cannot be built`
1770
+ );
1771
+ }
1772
+ const vtxos = await findLockupVtxos(deps.indexer, swap.lockupPkScript);
1773
+ if (vtxos.length === 0) return null;
1774
+ const record = await deps.repository.getRfqSwap(swap.rfqId);
1775
+ if (!record) {
1776
+ throw new RefundNotLocallyPossibleError(
1777
+ "no-secrets",
1778
+ `no stored record for ${swap.rfqId}; the descriptor that signs its refund lives there`
1779
+ );
1780
+ }
1781
+ const sender = await senderIdentityForSwapRecord(deps.wallet, rfqSignerOf(record) ?? {});
1782
+ return pushRefundWithoutReceiver(deps.ark, { script, sender, vtxos });
1783
+ };
1784
+ }
1785
+
1415
1786
  // src/swapManager.ts
1416
- import { hex as hex7 } from "@scure/base";
1417
- var RFQ_SWAP_TERMINAL_STATES = ["settled", "refunded", "failed"];
1418
- var isRfqSwapTerminal = (state) => RFQ_SWAP_TERMINAL_STATES.includes(state);
1787
+ import { hex as hex9 } from "@scure/base";
1419
1788
  function nextOnchainAction(input) {
1420
1789
  switch (input.phase.phase) {
1421
1790
  case "unfunded":
@@ -1431,6 +1800,17 @@ function nextOnchainAction(input) {
1431
1800
  return input.htlcLocktime - input.now >= ONCHAIN_CLAIM_MARGIN_SECONDS ? "claim" : "claim_window_closed";
1432
1801
  }
1433
1802
  }
1803
+ var RfqSwapOriginRequired = class extends Error {
1804
+ /** The swap that could not be admitted. */
1805
+ rfqId;
1806
+ constructor(rfqId) {
1807
+ super(
1808
+ `rfq swap ${rfqId} has no stored record and no origin was supplied; pass the request-time origin as addSwap's second argument so its first record can be written`
1809
+ );
1810
+ this.name = "RfqSwapOriginRequired";
1811
+ this.rfqId = rfqId;
1812
+ }
1813
+ };
1434
1814
  var notify = (listeners, call) => {
1435
1815
  for (const listener of listeners) {
1436
1816
  try {
@@ -1508,7 +1888,20 @@ var RfqSwapManager = class {
1508
1888
  * answers instead of throwing "not found". Cleared by {@link removeSwap}. */
1509
1889
  finished = /* @__PURE__ */ new Map();
1510
1890
  waiters = /* @__PURE__ */ new Map();
1511
- /** Records changed during the current pass, flushed through `saveSwap`. */
1891
+ /**
1892
+ * The request-time origin of each swap the manager may have to CREATE a
1893
+ * record for, by rfqId.
1894
+ *
1895
+ * Needed only for a swap the store has never seen: once a record exists,
1896
+ * `updateRfqSwapRecord` carries the origin half through and the map is
1897
+ * redundant. It is kept anyway for the swap's whole life, so a record the
1898
+ * store loses between passes is rewritten rather than lost — and dropped
1899
+ * by {@link removeSwap} and by retention, which are the two places a swap
1900
+ * stops being this manager's business.
1901
+ */
1902
+ origins = /* @__PURE__ */ new Map();
1903
+ /** Records changed during the current pass, flushed to the repository and
1904
+ * through `saveSwap`. */
1512
1905
  dirty = /* @__PURE__ */ new Set();
1513
1906
  /** Race guard: one action at a time per swap. */
1514
1907
  inProgress = /* @__PURE__ */ new Set();
@@ -1530,7 +1923,9 @@ var RfqSwapManager = class {
1530
1923
  this.actionExecutedListeners.add(config.events.onActionExecuted);
1531
1924
  }
1532
1925
  }
1533
- /** Wire the money-moving half. Without it the manager only watches. */
1926
+ /** Wire the money-moving half. Without it the manager only watches. The
1927
+ * two claims may be omitted for a consumer that drives no kind reaching
1928
+ * them — see {@link AvailableRfqSwapManagerCallbacks}. */
1534
1929
  setCallbacks(callbacks) {
1535
1930
  this.callbacks = callbacks;
1536
1931
  }
@@ -1550,6 +1945,116 @@ var RfqSwapManager = class {
1550
1945
  this.actionExecutedListeners.add(listener);
1551
1946
  return () => this.actionExecutedListeners.delete(listener);
1552
1947
  }
1948
+ /**
1949
+ * Rebuild every stored swap and take over monitoring them.
1950
+ *
1951
+ * The composition a consumer otherwise writes by hand, and the one place
1952
+ * all four pieces meet: retention decides what to keep
1953
+ * (`shouldRetainRfqSwap`), the lockup's contract row supplies the covenant
1954
+ * (`lockupContractParams`), `rebuildRfqSwap` turns a record back into a
1955
+ * live swap, and each rebuilt swap arrives with its own origin, so nothing
1956
+ * is asked of the caller.
1957
+ *
1958
+ * **Deliberately not part of {@link start}.** A consumer that wants to
1959
+ * look at its records — count them, show them, prune and stop — is not
1960
+ * forced to start driving money to do it. Call this first and `start()`
1961
+ * after; a manager already running polls the restored swaps at once.
1962
+ *
1963
+ * Retention runs BEFORE the rebuild, so a record past
1964
+ * `RFQ_SWAP_RETENTION_SECONDS` costs no contract lookup on its way to being
1965
+ * dropped.
1966
+ *
1967
+ * **A record that cannot be rebuilt is reported, never swallowed and never
1968
+ * fatal.** `rebuildRfqSwap` throws by design when the covenant params do
1969
+ * not derive the funded address, and `lockupContractParams` throws
1970
+ * `LockupContractMissing` when the wallet has no row for the lockup — both
1971
+ * say something true about that one record, and neither is a reason to
1972
+ * strand the others. They come back in {@link RfqRestoreResult.failed}.
1973
+ */
1974
+ async restoreFromRepository(options = {}) {
1975
+ const repository = this.requireRepository("restoreFromRepository");
1976
+ const params = options.params ?? this.paramsFromContracts();
1977
+ const records = await repository.getAllRfqSwaps();
1978
+ const pruned = await this.dropRetired(repository, records);
1979
+ const retired = new Set(pruned);
1980
+ const restored = [];
1981
+ const failed = [];
1982
+ for (const record of records) {
1983
+ if (retired.has(record.rfqId)) continue;
1984
+ let swap;
1985
+ try {
1986
+ swap = rebuildRfqSwap(record, await params(record));
1987
+ } catch (error) {
1988
+ failed.push({
1989
+ rfqId: record.rfqId,
1990
+ error: error instanceof Error ? error : new Error(errorMessage(error))
1991
+ });
1992
+ continue;
1993
+ }
1994
+ this.origins.set(record.rfqId, rfqSwapOriginOf(record));
1995
+ if (isRfqSwapTerminal(swap.state)) this.finished.set(swap.rfqId, swap);
1996
+ else this.track(swap);
1997
+ restored.push(swap);
1998
+ }
1999
+ if (this.running) {
2000
+ await Promise.allSettled(
2001
+ restored.filter((swap) => this.monitored.has(swap.rfqId)).map((swap) => this.pollSwap(swap))
2002
+ );
2003
+ }
2004
+ return { restored, failed, pruned };
2005
+ }
2006
+ /**
2007
+ * Drop stored records that are terminal and past
2008
+ * `RFQ_SWAP_RETENTION_SECONDS`, and return their ids.
2009
+ *
2010
+ * `needs_counterparty` is never dropped, however old: the money is still at
2011
+ * the lockup and the counterparty's move still ends the swap. That rule
2012
+ * lives in `shouldRetainRfqSwap`, which this defers to rather than
2013
+ * restating.
2014
+ *
2015
+ * Public because retention is a caller's cadence, not the manager's: a
2016
+ * long-lived process wants it on a timer of its own, a mobile app wants it
2017
+ * at boot. {@link restoreFromRepository} runs it first, so the boot path
2018
+ * needs no separate call.
2019
+ */
2020
+ async pruneRetiredSwaps() {
2021
+ const repository = this.deps.repository;
2022
+ if (!repository) return [];
2023
+ return this.dropRetired(repository, await repository.getAllRfqSwaps());
2024
+ }
2025
+ async dropRetired(repository, records) {
2026
+ const now = this.config.now();
2027
+ const dropped = [];
2028
+ for (const record of records) {
2029
+ if (shouldRetainRfqSwap(record, now)) continue;
2030
+ await repository.removeRfqSwap(record.rfqId);
2031
+ this.finished.delete(record.rfqId);
2032
+ if (!this.monitored.has(record.rfqId)) this.origins.delete(record.rfqId);
2033
+ dropped.push(record.rfqId);
2034
+ }
2035
+ return dropped;
2036
+ }
2037
+ requireRepository(method) {
2038
+ const repository = this.deps.repository;
2039
+ if (!repository) {
2040
+ throw new Error(
2041
+ `${method} needs a record store; pass one as RfqSwapManagerDeps.repository`
2042
+ );
2043
+ }
2044
+ return repository;
2045
+ }
2046
+ /** The default covenant source: the wallet's own contract row for each
2047
+ * lockup, which is where registration put it before the address could be
2048
+ * funded. */
2049
+ paramsFromContracts() {
2050
+ const contracts = this.deps.contracts;
2051
+ if (!contracts) {
2052
+ throw new Error(
2053
+ "restoreFromRepository needs the covenant parameters: wire RfqSwapManagerDeps.contracts so each lockup's contract row can be read, or pass options.params"
2054
+ );
2055
+ }
2056
+ return (record) => lockupContractParams(contracts, record.lockupAddress);
2057
+ }
1553
2058
  /**
1554
2059
  * Load records and begin monitoring. Runs one pass immediately — a caller
1555
2060
  * resuming after a restart may be well past a deadline already — then
@@ -1559,8 +2064,17 @@ var RfqSwapManager = class {
1559
2064
  * Calling it again while running loads the records and returns rather than
1560
2065
  * re-arming — dropping them silently would strand a funded swap on a
1561
2066
  * caller's harmless double-start.
2067
+ *
2068
+ * The signature is unchanged with a {@link RfqSwapManagerDeps.repository}
2069
+ * wired; the origins are resolved from the store instead, by the same rule
2070
+ * {@link addSwap} applies. A swap the store has never seen throws
2071
+ * {@link RfqSwapOriginRequired} — hand that one to `addSwap` with its
2072
+ * origin, or restore the whole set with {@link restoreFromRepository},
2073
+ * which needs no caller input at all. Every swap is checked before any is
2074
+ * tracked, so a bad one in the list does not leave a half-loaded manager.
1562
2075
  */
1563
2076
  async start(swaps = []) {
2077
+ for (const swap of swaps) await this.admit(swap);
1564
2078
  for (const swap of swaps) {
1565
2079
  if (isRfqSwapTerminal(swap.state)) this.finished.set(swap.rfqId, swap);
1566
2080
  else this.track(swap);
@@ -1592,9 +2106,22 @@ var RfqSwapManager = class {
1592
2106
  this.unsubscribeContracts?.();
1593
2107
  this.unsubscribeContracts = null;
1594
2108
  }
1595
- /** Begin monitoring a swap. Polled immediately when the manager is running,
1596
- * so a just-funded swap does not wait out a whole interval. */
1597
- async addSwap(swap) {
2109
+ /**
2110
+ * Begin monitoring a swap. Polled immediately when the manager is running,
2111
+ * so a just-funded swap does not wait out a whole interval.
2112
+ *
2113
+ * `origin` is the request-time half a live swap cannot carry — the corridor,
2114
+ * the funded address, the corridor's profile, the funding txid — and it is
2115
+ * what lets the manager write this swap's FIRST record. Supply it whenever
2116
+ * a {@link RfqSwapManagerDeps.repository} is wired and the swap is new. It
2117
+ * may be omitted for a swap the store already holds a record for, which is
2118
+ * then read to confirm it; omitting it for one the store has never seen
2119
+ * throws {@link RfqSwapOriginRequired} rather than admitting a swap whose
2120
+ * record could never be written. With no repository wired the parameter is
2121
+ * inert.
2122
+ */
2123
+ async addSwap(swap, origin) {
2124
+ await this.admit(swap, origin);
1598
2125
  if (isRfqSwapTerminal(swap.state)) {
1599
2126
  this.finished.set(swap.rfqId, swap);
1600
2127
  return;
@@ -1602,6 +2129,29 @@ var RfqSwapManager = class {
1602
2129
  this.track(swap);
1603
2130
  if (this.running) await this.pollSwap(swap);
1604
2131
  }
2132
+ /**
2133
+ * Settle where this swap's record will come from, before it is monitored.
2134
+ *
2135
+ * Three ways it can be answered, in order: the caller passed an origin, one
2136
+ * is already remembered from an earlier `addSwap`, or the store holds a
2137
+ * record — which is the origin, already written. Only the last costs a read,
2138
+ * and only when the first two are absent.
2139
+ */
2140
+ async admit(swap, origin) {
2141
+ if (origin) {
2142
+ assertSameSwap(origin, swap);
2143
+ this.origins.set(swap.rfqId, origin);
2144
+ if (this.deps.repository && !isRfqSwapTerminal(swap.state)) {
2145
+ this.dirty.add(swap.rfqId);
2146
+ }
2147
+ return;
2148
+ }
2149
+ const repository = this.deps.repository;
2150
+ if (!repository || this.origins.has(swap.rfqId)) return;
2151
+ const stored = await repository.getRfqSwap(swap.rfqId);
2152
+ if (!stored) throw new RfqSwapOriginRequired(swap.rfqId);
2153
+ this.origins.set(swap.rfqId, rfqSwapOriginOf(stored));
2154
+ }
1605
2155
  /** Forget a swap entirely, monitored or finished.
1606
2156
  *
1607
2157
  * Its contract row is left alone: registration is a wallet-level fact about
@@ -1619,6 +2169,7 @@ var RfqSwapManager = class {
1619
2169
  }
1620
2170
  this.waiters.delete(rfqId);
1621
2171
  this.dirty.delete(rfqId);
2172
+ this.origins.delete(rfqId);
1622
2173
  }
1623
2174
  /** Every swap still being monitored. */
1624
2175
  async getPendingSwaps() {
@@ -1681,7 +2232,7 @@ var RfqSwapManager = class {
1681
2232
  // ── internals ────────────────────────────────────────────────────────────
1682
2233
  track(swap) {
1683
2234
  this.monitored.set(swap.rfqId, swap);
1684
- this.byLockupScript.set(hex7.encode(swap.lockupPkScript), swap);
2235
+ this.byLockupScript.set(hex9.encode(swap.lockupPkScript), swap);
1685
2236
  }
1686
2237
  /** Drops the swap from BOTH indexes. The event index is the one that stops
1687
2238
  * a late event finding a swap that is gone; `pollSwap`'s own
@@ -1690,7 +2241,7 @@ var RfqSwapManager = class {
1690
2241
  * them from silently re-driving a cancelled swap. */
1691
2242
  untrack(rfqId) {
1692
2243
  const swap = this.monitored.get(rfqId);
1693
- if (swap) this.byLockupScript.delete(hex7.encode(swap.lockupPkScript));
2244
+ if (swap) this.byLockupScript.delete(hex9.encode(swap.lockupPkScript));
1694
2245
  this.monitored.delete(rfqId);
1695
2246
  this.refundRefused.delete(rfqId);
1696
2247
  this.lastClaimError.delete(rfqId);
@@ -1752,7 +2303,7 @@ var RfqSwapManager = class {
1752
2303
  if (!lockup) {
1753
2304
  try {
1754
2305
  const [existing] = await contracts.getContracts({
1755
- script: hex7.encode(swap.lockupPkScript)
2306
+ script: hex9.encode(swap.lockupPkScript)
1756
2307
  });
1757
2308
  if (existing) {
1758
2309
  this.registered.set(swap.rfqId, true);
@@ -1771,13 +2322,13 @@ var RfqSwapManager = class {
1771
2322
  );
1772
2323
  return;
1773
2324
  }
1774
- const script = hex7.encode(lockup.script.pkScript);
1775
- if (script !== hex7.encode(swap.lockupPkScript)) {
2325
+ const script = hex9.encode(lockup.script.pkScript);
2326
+ if (script !== hex9.encode(swap.lockupPkScript)) {
1776
2327
  this.registered.set(swap.rfqId, false);
1777
2328
  this.emitFailed(
1778
2329
  swap,
1779
2330
  new Error(
1780
- `swap ${swap.rfqId} lockup script ${script} does not match its lockupPkScript ${hex7.encode(swap.lockupPkScript)}`
2331
+ `swap ${swap.rfqId} lockup script ${script} does not match its lockupPkScript ${hex9.encode(swap.lockupPkScript)}`
1781
2332
  )
1782
2333
  );
1783
2334
  return;
@@ -1796,7 +2347,7 @@ var RfqSwapManager = class {
1796
2347
  * script for its whole life. Best-effort — the swap is over either way. */
1797
2348
  retireContract(swap) {
1798
2349
  if (!this.deps.contracts || !this.registered.get(swap.rfqId)) return;
1799
- void this.deps.contracts.setContractWatchState(hex7.encode(swap.lockupPkScript), "retained").catch((error) => this.emitFailed(swap, error));
2350
+ void this.deps.contracts.setContractWatchState(hex9.encode(swap.lockupPkScript), "retained").catch((error) => this.emitFailed(swap, error));
1800
2351
  }
1801
2352
  arm() {
1802
2353
  if (!this.running) return;
@@ -1834,6 +2385,7 @@ var RfqSwapManager = class {
1834
2385
  fate = { fate: "unknown" };
1835
2386
  }
1836
2387
  if (fate.fate === "claimed" || fate.fate === "returned") {
2388
+ this.stampLockupSpends(swap, fate.spends);
1837
2389
  this.setState(swap, fate.fate === "claimed" ? "settled" : "refunded");
1838
2390
  return;
1839
2391
  }
@@ -1918,10 +2470,10 @@ var RfqSwapManager = class {
1918
2470
  );
1919
2471
  }
1920
2472
  }
1921
- if (!this.callbacks) {
2473
+ if (!this.callbacks || !this.callbacks.claimLockup) {
1922
2474
  return this.block(
1923
2475
  swap,
1924
- "no callbacks are wired, so this wallet cannot claim the lockup"
2476
+ this.callbacks ? "no claimLockup callback is wired, so this wallet cannot claim the lockup" : "no callbacks are wired, so this wallet cannot claim the lockup"
1925
2477
  );
1926
2478
  }
1927
2479
  this.setState(swap, "claimable");
@@ -1971,8 +2523,15 @@ var RfqSwapManager = class {
1971
2523
  });
1972
2524
  if (action === "claim" && phase.phase === "claimable") {
1973
2525
  if (swap.claimTxid) return "continue";
2526
+ if (this.callbacks && !this.callbacks.claimOnchain) {
2527
+ this.block(
2528
+ swap,
2529
+ "no claimOnchain callback is wired, so this wallet cannot claim the L1 fill"
2530
+ );
2531
+ return "handled";
2532
+ }
1974
2533
  this.setOnchainState(swap, "claimable");
1975
- if (!this.config.enableAutoActions || !this.callbacks) return "handled";
2534
+ if (!this.config.enableAutoActions || !this.callbacks?.claimOnchain) return "handled";
1976
2535
  try {
1977
2536
  const { txid } = await this.callbacks.claimOnchain(swap, phase.utxo);
1978
2537
  swap.claimTxid = txid;
@@ -2085,6 +2644,24 @@ var RfqSwapManager = class {
2085
2644
  if (swap.state !== "needs_counterparty") return;
2086
2645
  this.setState(swap, traderClaimTxid(swap) ? "claimed" : "pending");
2087
2646
  }
2647
+ /**
2648
+ * Record which ark transactions ended the lockup.
2649
+ *
2650
+ * Only the ones the indexer actually named: `LockupSpend.arkTxid` is
2651
+ * optional, and a checkpoint txid is not what history correlates on — a
2652
+ * record carrying one would name a transaction the wallet's own activity
2653
+ * never shows. Fewer txids is the right failure here.
2654
+ *
2655
+ * Assigned rather than merged: the fate is one read of the whole lockup,
2656
+ * so it is the complete answer for this swap, and a swap only reaches a
2657
+ * verdict once.
2658
+ */
2659
+ stampLockupSpends(swap, spends) {
2660
+ const arkTxids = spends.map((spend) => spend.arkTxid).filter((txid) => txid !== void 0);
2661
+ if (arkTxids.length === 0) return;
2662
+ swap.lockupSpendArkTxids = arkTxids;
2663
+ this.touch(swap);
2664
+ }
2088
2665
  touch(swap) {
2089
2666
  swap.updatedAt = this.config.now();
2090
2667
  this.dirty.add(swap.rfqId);
@@ -2110,9 +2687,29 @@ var RfqSwapManager = class {
2110
2687
  emitAction(swap, action) {
2111
2688
  notify(this.actionExecutedListeners, (listener) => listener(swap, action));
2112
2689
  }
2113
- /** Whether the record is now persisted — false only when `saveSwap` threw. */
2690
+ /**
2691
+ * Flush a changed record to every sink that is wired, and say whether all
2692
+ * of them took it.
2693
+ *
2694
+ * Up to two writes, in this order: the canonical `RfqSwapRecord` to
2695
+ * {@link RfqSwapManagerDeps.repository}, then
2696
+ * {@link RfqSwapManagerCallbacks.saveSwap}. Both gate — a rejection from
2697
+ * either leaves the record dirty and monitored, so waiters stay unsettled
2698
+ * and a terminal swap is not finalized until the write it claims lands.
2699
+ * That is exactly today's rule for `saveSwap`, applied to whichever sinks
2700
+ * exist; wiring the repository does not weaken it.
2701
+ *
2702
+ * The canonical write goes FIRST and a failure there skips the second.
2703
+ * `saveSwap` is a projection of the record, and projecting a state the
2704
+ * record of record has just refused would leave the secondary sink ahead
2705
+ * of the primary — the one ordering that survives no restart.
2706
+ *
2707
+ * With neither wired the state is process-local, which is what a manager
2708
+ * with no callbacks has always done.
2709
+ */
2114
2710
  async save(swap) {
2115
- if (!this.callbacks) return true;
2711
+ if (!await this.saveRecord(swap)) return false;
2712
+ if (!this.callbacks?.saveSwap) return true;
2116
2713
  try {
2117
2714
  await this.callbacks.saveSwap(swap);
2118
2715
  return true;
@@ -2121,6 +2718,25 @@ var RfqSwapManager = class {
2121
2718
  return false;
2122
2719
  }
2123
2720
  }
2721
+ /** The canonical write. True when there is no repository to write to. */
2722
+ async saveRecord(swap) {
2723
+ const repository = this.deps.repository;
2724
+ if (!repository) return true;
2725
+ try {
2726
+ const stored = await repository.getRfqSwap(swap.rfqId);
2727
+ const record = stored ? updateRfqSwapRecord(stored, swap) : createRfqSwapRecord(this.originOrThrow(swap), swap);
2728
+ await repository.saveRfqSwap(record);
2729
+ return true;
2730
+ } catch (error) {
2731
+ this.emitFailed(swap, error);
2732
+ return false;
2733
+ }
2734
+ }
2735
+ originOrThrow(swap) {
2736
+ const origin = this.origins.get(swap.rfqId);
2737
+ if (!origin) throw new RfqSwapOriginRequired(swap.rfqId);
2738
+ return origin;
2739
+ }
2124
2740
  /**
2125
2741
  * Drop a terminal swap from monitoring and report it exactly once.
2126
2742
  *
@@ -2180,6 +2796,10 @@ var errorMessage = (error) => error instanceof Error ? error.message : String(er
2180
2796
  var outpointKey = (vtxo) => `${vtxo.txid}:${vtxo.vout}`;
2181
2797
 
2182
2798
  // src/activity.ts
2799
+ import {
2800
+ ArkAddress as ArkAddress4
2801
+ } from "@arkade-os/sdk";
2802
+ import { hex as hex10 } from "@scure/base";
2183
2803
  var LABELS = {
2184
2804
  lightning_send: "Lightning send",
2185
2805
  lightning_receive: "Lightning receive",
@@ -2232,6 +2852,44 @@ function swapActivityResolver(deps) {
2232
2852
  }
2233
2853
  };
2234
2854
  }
2855
+ async function rfqSwapActivityInputs(deps) {
2856
+ const records = await deps.repository.getAllRfqSwaps();
2857
+ return Promise.all(records.map((record) => activityInputOf(record, deps.indexer)));
2858
+ }
2859
+ async function activityInputOf(record, indexer) {
2860
+ const txids = /* @__PURE__ */ new Set();
2861
+ if (record.fundingArkTxid) txids.add(record.fundingArkTxid);
2862
+ if (record.refundArkTxid) txids.add(record.refundArkTxid);
2863
+ const handler = rfqCorridorHandlers.getOrThrow(record.kind);
2864
+ for (const txid of handler.activityTxids?.(record.profile) ?? []) txids.add(txid);
2865
+ for (const txid of record.lockupSpendArkTxids ?? []) txids.add(txid);
2866
+ const spendUnknown = isRfqSwapTerminal(record.state) && !record.refundArkTxid && !record.lockupSpendArkTxids?.length;
2867
+ if (indexer && (!record.fundingArkTxid || spendUnknown)) {
2868
+ for (const txid of await lockupTxids(indexer, record, !record.fundingArkTxid)) {
2869
+ txids.add(txid);
2870
+ }
2871
+ }
2872
+ return { rfqId: record.rfqId, kind: record.kind, state: record.state, txids: [...txids] };
2873
+ }
2874
+ async function lockupTxids(indexer, record, wantFunding) {
2875
+ let script;
2876
+ try {
2877
+ script = hex10.encode(ArkAddress4.decode(record.lockupAddress).pkScript);
2878
+ } catch {
2879
+ return [];
2880
+ }
2881
+ try {
2882
+ const { vtxos } = await indexer.getVtxos({ scripts: [script] });
2883
+ const out = [];
2884
+ for (const vtxo of vtxos ?? []) {
2885
+ if (wantFunding) out.push(vtxo.txid);
2886
+ if (vtxo.arkTxId) out.push(vtxo.arkTxId);
2887
+ }
2888
+ return out;
2889
+ } catch {
2890
+ return [];
2891
+ }
2892
+ }
2235
2893
  export {
2236
2894
  ARKADE_ASSET,
2237
2895
  ARKADE_BTC,
@@ -2242,7 +2900,9 @@ export {
2242
2900
  LIGHTNING_BTC,
2243
2901
  LIGHTNING_RECEIVE_PAIR,
2244
2902
  LIGHTNING_SEND_PAIR,
2903
+ LOCKTIME_THRESHOLD,
2245
2904
  LockupAmountMismatchError,
2905
+ LockupContractMissing,
2246
2906
  LockupNeedsRecoveryError,
2247
2907
  LockupRegistrationFailed,
2248
2908
  MAX_MIN_CONFIRMATIONS,
@@ -2260,10 +2920,12 @@ export {
2260
2920
  QUOTE_OPTIONS,
2261
2921
  REFUND_MTP_LAG_SECONDS,
2262
2922
  RFQ_RESOLVED_STATES,
2923
+ RFQ_SWAP_RETENTION_SECONDS,
2263
2924
  RFQ_SWAP_TERMINAL_STATES,
2264
2925
  RFQ_TERMINAL_STATES,
2265
2926
  RefundNotLocallyPossibleError,
2266
2927
  RfqSwapManager,
2928
+ RfqSwapOriginRequired,
2267
2929
  SOLO_REFUND_HEADROOM_SECONDS,
2268
2930
  SWAP_LOCKUP_CONTRACT_KIND,
2269
2931
  SWAP_LOCKUP_CONTRACT_LABEL,
@@ -2271,6 +2933,7 @@ export {
2271
2933
  SwapRefusal,
2272
2934
  addAssetSwap,
2273
2935
  arkadeAssetLeg,
2936
+ arkadeRefunder,
2274
2937
  arkadeSwapRequest,
2275
2938
  assertFundable,
2276
2939
  assertReceivable,
@@ -2286,6 +2949,7 @@ export {
2286
2949
  classifyOnchainHtlc,
2287
2950
  classifySpend,
2288
2951
  createOffer,
2952
+ createRfqSwapRecord,
2289
2953
  decodeOffer,
2290
2954
  deriveLightningReceive,
2291
2955
  deriveOnchainReceive,
@@ -2303,6 +2967,7 @@ export {
2303
2967
  lightningReceiveRequest,
2304
2968
  lightningSendRequest,
2305
2969
  lightningSendVtxoScript,
2970
+ lockupContractParams,
2306
2971
  makeCachedFeedFetch,
2307
2972
  newPreimage,
2308
2973
  newRfqId,
@@ -2311,12 +2976,14 @@ export {
2311
2976
  offerVtxoScript,
2312
2977
  onchainHtlcScript,
2313
2978
  onchainReceiveRequest,
2979
+ onchainSendProfile,
2314
2980
  onchainSendRequest,
2315
2981
  paymentHashOf,
2316
2982
  preimageForSwapRecord,
2317
2983
  pushClaim,
2318
2984
  pushRefundWithoutReceiver,
2319
2985
  readLockupFate,
2986
+ rebuildRfqSwap,
2320
2987
  receiveVtxoScript,
2321
2988
  refundIfUnresolved,
2322
2989
  registerLockupContract,
@@ -2327,9 +2994,15 @@ export {
2327
2994
  requestOnchainSend,
2328
2995
  restoreAssetSwaps,
2329
2996
  retireSettledOfferContracts,
2997
+ rfqClaimSecretOf,
2330
2998
  rfqPair,
2999
+ rfqSecretsProfile,
3000
+ rfqSignerOf,
3001
+ rfqSwapActivityInputs,
3002
+ rfqSwapOriginOf,
2331
3003
  sealClaimPacket,
2332
3004
  senderIdentityForSwapRecord,
3005
+ shouldRetainRfqSwap,
2333
3006
  spendTxidsOf,
2334
3007
  spendUpdate,
2335
3008
  swapActivityResolver,
@@ -2340,6 +3013,7 @@ export {
2340
3013
  unilateralRefundWithoutReceiverDelay,
2341
3014
  updateAssetSwap,
2342
3015
  updateAssetSwapBestEffort,
3016
+ updateRfqSwapRecord,
2343
3017
  validatePlan,
2344
3018
  verifyLockupAddress,
2345
3019
  verifyReceiveInvoice,