@satora/swap 0.1.0 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.cjs CHANGED
@@ -103,6 +103,8 @@ __export(src_exports, {
103
103
  DEFAULT_EVM_RPCS: () => DEFAULT_EVM_RPCS,
104
104
  EvmContractManager: () => EvmContractManager,
105
105
  SwapTracker: () => SwapTracker,
106
+ SwapWorker: () => SwapWorker,
107
+ WsStatusSource: () => WsStatusSource,
106
108
  buildArkadeVhtlcRef: () => buildArkadeVhtlcRef,
107
109
  createEvmRpcReader: () => createEvmRpcReader,
108
110
  defaultArkadeServerUrl: () => defaultArkadeServerUrl,
@@ -479,6 +481,9 @@ var ArkadeContractManager = class _ArkadeContractManager {
479
481
  chainTime: config.chainTime
480
482
  });
481
483
  }
484
+ canObserve(ref) {
485
+ return ref.ledger === "arkade";
486
+ }
482
487
  async register(ref) {
483
488
  if (ref.ledger !== "arkade")
484
489
  throw new Error(
@@ -509,6 +514,13 @@ var ArkadeContractManager = class _ArkadeContractManager {
509
514
  [...this.#refs.values()].map((ref) => this.#reconcileRef(ref))
510
515
  );
511
516
  }
517
+ async reconcile(ref) {
518
+ if (ref.ledger !== "arkade") return;
519
+ const tracked = this.#refs.get(ref.script);
520
+ if (!tracked) return;
521
+ if (this.#chainTime) this.#now = await this.#chainTime();
522
+ await this.#reconcileRef(tracked);
523
+ }
512
524
  dispose() {
513
525
  this.#listeners.clear();
514
526
  }
@@ -623,6 +635,9 @@ var BitcoinContractManager = class _BitcoinContractManager {
623
635
  chainTime: config.chainTime
624
636
  });
625
637
  }
638
+ canObserve(ref) {
639
+ return ref.ledger === "bitcoin";
640
+ }
626
641
  async register(ref) {
627
642
  if (ref.ledger !== "bitcoin")
628
643
  throw new Error(
@@ -653,6 +668,13 @@ var BitcoinContractManager = class _BitcoinContractManager {
653
668
  [...this.#refs.values()].map((ref) => this.#reconcileRef(ref))
654
669
  );
655
670
  }
671
+ async reconcile(ref) {
672
+ if (ref.ledger !== "bitcoin") return;
673
+ const tracked = this.#refs.get(ref.address);
674
+ if (!tracked) return;
675
+ if (this.#chainTime) this.#now = await this.#chainTime();
676
+ await this.#reconcileRef(tracked);
677
+ }
656
678
  dispose() {
657
679
  this.#listeners.clear();
658
680
  }
@@ -742,6 +764,9 @@ var EvmContractManager = class _EvmContractManager {
742
764
  static fromDeps(deps) {
743
765
  return new _EvmContractManager(deps);
744
766
  }
767
+ canObserve(ref) {
768
+ return ref.ledger === "evm" && this.#readers.has(ref.chainId);
769
+ }
745
770
  async register(ref) {
746
771
  if (ref.ledger !== "evm")
747
772
  throw new Error(`EvmContractManager can't track a '${ref.ledger}' HTLC`);
@@ -780,6 +805,15 @@ var EvmContractManager = class _EvmContractManager {
780
805
  const chainIds = new Set([...this.#refs.values()].map((r) => r.chainId));
781
806
  await Promise.all([...chainIds].map((c) => this.#reconcileChain(c)));
782
807
  }
808
+ async reconcile(ref) {
809
+ if (ref.ledger !== "evm") return;
810
+ const tracked = this.#refs.get(htlcKey(ref));
811
+ if (!tracked) return;
812
+ const reader = this.#readers.get(tracked.chainId);
813
+ if (!reader) return;
814
+ this.#now.set(tracked.chainId, await reader.getBlockTimeMs());
815
+ await this.#reconcileRef(reader, tracked);
816
+ }
783
817
  dispose() {
784
818
  for (const unsub of this.#watchUnsubs.values()) unsub();
785
819
  this.#watchUnsubs.clear();
@@ -963,6 +997,310 @@ function defaultEvmReaders(overrides) {
963
997
  return readers;
964
998
  }
965
999
 
1000
+ // src/hints/swap-worker.ts
1001
+ var AUTO_EXECUTABLE = /* @__PURE__ */ new Set([
1002
+ "claim"
1003
+ ]);
1004
+ var SwapWorker = class {
1005
+ #tracker;
1006
+ #hintSource;
1007
+ #execute;
1008
+ #onActionRequired;
1009
+ #maxAttempts;
1010
+ #baseDelayMs;
1011
+ #maxDelayMs;
1012
+ /** Swaps with an auto action in flight — guards against double-runs. */
1013
+ #executing = /* @__PURE__ */ new Set();
1014
+ /** Swaps with a scheduled auto-claim retry (backoff timer + attempt count). */
1015
+ #retries = /* @__PURE__ */ new Map();
1016
+ #unsubs = [];
1017
+ #started = false;
1018
+ constructor(options) {
1019
+ this.#tracker = options.tracker;
1020
+ this.#hintSource = options.hintSource;
1021
+ this.#execute = options.execute;
1022
+ this.#onActionRequired = options.onActionRequired;
1023
+ this.#maxAttempts = options.retry?.maxAttempts ?? 5;
1024
+ this.#baseDelayMs = options.retry?.baseDelayMs ?? 2e3;
1025
+ this.#maxDelayMs = options.retry?.maxDelayMs ?? 3e4;
1026
+ }
1027
+ /** Watch tracked swaps on the hint feed, wire hints → verify, react to actions. */
1028
+ start() {
1029
+ if (this.#started) return;
1030
+ this.#started = true;
1031
+ this.#hintSource.subscribe(this.#tracker.trackedSwapIds());
1032
+ this.#unsubs.push(
1033
+ this.#hintSource.onStatus(({ swapId }) => {
1034
+ void this.#tracker.applyHint(swapId).catch((error) => {
1035
+ console.warn(`SwapWorker: applyHint(${swapId}) failed:`, error);
1036
+ });
1037
+ })
1038
+ );
1039
+ this.#hintSource.start();
1040
+ this.#unsubs.push(
1041
+ this.#tracker.subscribeToActions(
1042
+ (swapId, actions) => this.#onActions(swapId, actions)
1043
+ )
1044
+ );
1045
+ }
1046
+ /** Stop reacting, cancel pending retries, and close the hint feed. */
1047
+ stop() {
1048
+ this.#started = false;
1049
+ for (const unsub of this.#unsubs) unsub();
1050
+ this.#unsubs = [];
1051
+ this.#hintSource.stop();
1052
+ for (const { timer } of this.#retries.values()) clearTimeout(timer);
1053
+ this.#retries.clear();
1054
+ this.#executing.clear();
1055
+ }
1056
+ #onActions(swapId, actions) {
1057
+ const recommended = actions.recommended;
1058
+ if (recommended === "none") {
1059
+ this.#hintSource.unsubscribe([swapId]);
1060
+ this.#cancelRetry(swapId);
1061
+ return;
1062
+ }
1063
+ this.#hintSource.subscribe([swapId]);
1064
+ if (recommended && AUTO_EXECUTABLE.has(recommended)) {
1065
+ this.#autoExecute(swapId, actions);
1066
+ return;
1067
+ }
1068
+ this.#cancelRetry(swapId);
1069
+ if (!recommended) return;
1070
+ const automation = actions.actions.find(
1071
+ (a) => a.id === recommended
1072
+ )?.automation;
1073
+ if (automation === "manual" || automation === "confirm")
1074
+ this.#onActionRequired?.(swapId, actions);
1075
+ }
1076
+ #autoExecute(swapId, actions) {
1077
+ if (this.#executing.has(swapId)) return;
1078
+ const actionId = actions.recommended;
1079
+ if (!actionId) return;
1080
+ this.#executing.add(swapId);
1081
+ void this.#execute(swapId, actionId).then(() => this.#cancelRetry(swapId)).catch((error) => {
1082
+ console.warn(`SwapWorker: ${actionId}(${swapId}) failed:`, error);
1083
+ this.#scheduleRetry(swapId, actions);
1084
+ }).finally(() => this.#executing.delete(swapId));
1085
+ }
1086
+ /** Schedule the next backoff retry, or give up and surface once attempts run out. */
1087
+ #scheduleRetry(swapId, actions) {
1088
+ const attempts = (this.#retries.get(swapId)?.attempts ?? 0) + 1;
1089
+ if (attempts > this.#maxAttempts) {
1090
+ console.warn(
1091
+ `SwapWorker: auto-claim for ${swapId} gave up after ${this.#maxAttempts} attempts`
1092
+ );
1093
+ this.#cancelRetry(swapId);
1094
+ this.#onActionRequired?.(swapId, actions);
1095
+ return;
1096
+ }
1097
+ this.#clearTimer(swapId);
1098
+ const delay = Math.min(
1099
+ this.#baseDelayMs * 2 ** (attempts - 1),
1100
+ this.#maxDelayMs
1101
+ );
1102
+ const timer = setTimeout(() => this.#retry(swapId, actions), delay);
1103
+ this.#retries.set(swapId, { attempts, timer });
1104
+ }
1105
+ /**
1106
+ * A retry fires: re-verify against chain (forced re-emit), which drives
1107
+ * {@link #onActions} to re-run the claim only if it is still recommended. If the
1108
+ * re-verify itself fails we can't safely retry, so back off and try the cycle
1109
+ * again.
1110
+ */
1111
+ #retry(swapId, actions) {
1112
+ void this.#tracker.applyHint(swapId, { force: true }).catch((error) => {
1113
+ console.warn(
1114
+ `SwapWorker: re-verify before retrying ${swapId} failed:`,
1115
+ error
1116
+ );
1117
+ this.#scheduleRetry(swapId, actions);
1118
+ });
1119
+ }
1120
+ #cancelRetry(swapId) {
1121
+ this.#clearTimer(swapId);
1122
+ this.#retries.delete(swapId);
1123
+ }
1124
+ #clearTimer(swapId) {
1125
+ const existing = this.#retries.get(swapId);
1126
+ if (existing) clearTimeout(existing.timer);
1127
+ }
1128
+ };
1129
+
1130
+ // src/hints/ws-status-source.ts
1131
+ var MAX_ARGS_PER_FRAME = 64;
1132
+ var MAX_SUBSCRIPTIONS = 256;
1133
+ var WsStatusSource = class {
1134
+ #url;
1135
+ #factory;
1136
+ #reconnectMinMs;
1137
+ #reconnectMaxMs;
1138
+ /**
1139
+ * Ids actually subscribed on the wire — held at or below the server's
1140
+ * per-connection cap, and resent in full on every (re)connect.
1141
+ */
1142
+ #active = /* @__PURE__ */ new Set();
1143
+ /**
1144
+ * Ids we want but have no slot for. Promoted FIFO as slots free (the worker
1145
+ * unsubscribes each swap when it reaches a terminal action, so they do free).
1146
+ * A pending swap is not broken, only slower: it gets no hints, so it advances
1147
+ * on the tracker's chain poll instead.
1148
+ */
1149
+ #pending = /* @__PURE__ */ new Set();
1150
+ #listeners = /* @__PURE__ */ new Set();
1151
+ #ws;
1152
+ #open = false;
1153
+ #stopped = false;
1154
+ #backoffMs;
1155
+ #reconnectTimer;
1156
+ constructor(options) {
1157
+ this.#url = toWsUrl(options.serverUrl);
1158
+ this.#factory = options.webSocketFactory ?? defaultWebSocketFactory;
1159
+ this.#reconnectMinMs = options.reconnectMinMs ?? 1e3;
1160
+ this.#reconnectMaxMs = options.reconnectMaxMs ?? 3e4;
1161
+ this.#backoffMs = this.#reconnectMinMs;
1162
+ }
1163
+ /** Open the connection and begin (re)subscribing. Idempotent. */
1164
+ start() {
1165
+ if (this.#ws || this.#reconnectTimer) return;
1166
+ this.#stopped = false;
1167
+ this.#connect();
1168
+ }
1169
+ /** Close the connection and stop reconnecting. */
1170
+ stop() {
1171
+ this.#stopped = true;
1172
+ if (this.#reconnectTimer) clearTimeout(this.#reconnectTimer);
1173
+ this.#reconnectTimer = void 0;
1174
+ this.#open = false;
1175
+ this.#ws?.close();
1176
+ this.#ws = void 0;
1177
+ }
1178
+ /** Emit for each pushed status update. Returns an unsubscribe fn. */
1179
+ onStatus(cb) {
1180
+ this.#listeners.add(cb);
1181
+ return () => this.#listeners.delete(cb);
1182
+ }
1183
+ /**
1184
+ * Track these swap ids — subscribe on the socket now if open, else on next open.
1185
+ *
1186
+ * Enforces the server's per-connection cap locally. The server silently stops
1187
+ * adding once it is full (it breaks out of the loop but still acks every id),
1188
+ * so sending more would leave us believing ids are subscribed that will never
1189
+ * receive an update. Overflow is queued instead and promoted as slots free.
1190
+ */
1191
+ subscribe(swapIds) {
1192
+ const fresh = swapIds.filter(
1193
+ (id) => !this.#active.has(id) && !this.#pending.has(id)
1194
+ );
1195
+ if (fresh.length === 0) return;
1196
+ const admitted = [];
1197
+ for (const id of fresh) {
1198
+ if (this.#active.size < MAX_SUBSCRIPTIONS) {
1199
+ this.#active.add(id);
1200
+ admitted.push(id);
1201
+ } else {
1202
+ this.#pending.add(id);
1203
+ }
1204
+ }
1205
+ if (this.#pending.size > 0)
1206
+ console.warn(
1207
+ `WsStatusSource: at the server cap of ${MAX_SUBSCRIPTIONS} subscriptions; ${this.#pending.size} swap(s) queued for a slot and will advance on the chain poll until then`
1208
+ );
1209
+ if (this.#open && admitted.length > 0)
1210
+ this.#sendFrame("subscribe", admitted);
1211
+ }
1212
+ /** Stop tracking these swap ids, promoting queued ones into the freed slots. */
1213
+ unsubscribe(swapIds) {
1214
+ const dropped = [];
1215
+ for (const id of swapIds) {
1216
+ if (this.#active.delete(id)) dropped.push(id);
1217
+ else this.#pending.delete(id);
1218
+ }
1219
+ if (this.#open && dropped.length > 0)
1220
+ this.#sendFrame("unsubscribe", dropped);
1221
+ this.#promotePending();
1222
+ }
1223
+ /** Move queued ids into any free slots, subscribing them if the socket is open. */
1224
+ #promotePending() {
1225
+ if (this.#pending.size === 0) return;
1226
+ const promoted = [];
1227
+ for (const id of this.#pending) {
1228
+ if (this.#active.size >= MAX_SUBSCRIPTIONS) break;
1229
+ this.#pending.delete(id);
1230
+ this.#active.add(id);
1231
+ promoted.push(id);
1232
+ }
1233
+ if (this.#open && promoted.length > 0)
1234
+ this.#sendFrame("subscribe", promoted);
1235
+ }
1236
+ #connect() {
1237
+ this.#reconnectTimer = void 0;
1238
+ const ws = this.#factory(this.#url);
1239
+ this.#ws = ws;
1240
+ ws.onopen = () => {
1241
+ this.#backoffMs = this.#reconnectMinMs;
1242
+ this.#promotePending();
1243
+ this.#open = true;
1244
+ if (this.#active.size > 0)
1245
+ this.#sendFrame("subscribe", [...this.#active]);
1246
+ };
1247
+ ws.onmessage = (event) => this.#handleMessage(event.data);
1248
+ ws.onclose = () => this.#onDisconnect();
1249
+ ws.onerror = () => this.#ws?.close();
1250
+ }
1251
+ #onDisconnect() {
1252
+ this.#open = false;
1253
+ this.#ws = void 0;
1254
+ if (this.#stopped || this.#reconnectTimer) return;
1255
+ const delay = this.#backoffMs;
1256
+ this.#backoffMs = Math.min(this.#backoffMs * 2, this.#reconnectMaxMs);
1257
+ this.#reconnectTimer = setTimeout(() => this.#connect(), delay);
1258
+ }
1259
+ #handleMessage(data) {
1260
+ if (typeof data !== "string") return;
1261
+ let frame;
1262
+ try {
1263
+ frame = JSON.parse(data);
1264
+ } catch {
1265
+ return;
1266
+ }
1267
+ if (!frame || typeof frame !== "object") return;
1268
+ const f = frame;
1269
+ if (f.data && typeof f.data === "object") {
1270
+ for (const [swapId, status] of Object.entries(
1271
+ f.data
1272
+ ))
1273
+ this.#emit({ swapId, status });
1274
+ return;
1275
+ }
1276
+ if (typeof f.message === "string")
1277
+ console.warn(`WsStatusSource: server error: ${f.message}`);
1278
+ }
1279
+ #emit(update) {
1280
+ for (const cb of this.#listeners) cb(update);
1281
+ }
1282
+ #sendFrame(op, ids) {
1283
+ for (let i = 0; i < ids.length; i += MAX_ARGS_PER_FRAME) {
1284
+ const args = ids.slice(i, i + MAX_ARGS_PER_FRAME);
1285
+ this.#ws?.send(JSON.stringify({ op, channel: "swap_status", args }));
1286
+ }
1287
+ }
1288
+ };
1289
+ function toWsUrl(serverUrl) {
1290
+ const url = new URL(serverUrl);
1291
+ const scheme = url.protocol === "https:" ? "wss" : "ws";
1292
+ const path = url.pathname.replace(/\/+$/, "");
1293
+ return `${scheme}://${url.host}${path}/ws`;
1294
+ }
1295
+ function defaultWebSocketFactory(url) {
1296
+ const WS = globalThis.WebSocket;
1297
+ if (!WS)
1298
+ throw new Error(
1299
+ "no global WebSocket available \u2014 pass options.webSocketFactory (Node < 22)"
1300
+ );
1301
+ return new WS(url);
1302
+ }
1303
+
966
1304
  // src/contracts/arkade-vhtlc.ts
967
1305
  var import_sdk3 = require("@arkade-os/sdk");
968
1306
  var import_legacy2 = require("@noble/hashes/legacy.js");
@@ -1235,9 +1573,26 @@ var SwapTracker = class {
1235
1573
  this.#managers = managers;
1236
1574
  this.#refreshIntervalMs = options?.refreshIntervalMs ?? 0;
1237
1575
  }
1576
+ /**
1577
+ * Whether every on-chain leg of `swap` is observable — i.e. the tracker can
1578
+ * derive its status from chain. `false` means a leg is on a ledger/chain this
1579
+ * client can't reach (no manager, or an EVM chain with no RPC), so the swap
1580
+ * can't be tracked. Pure predicate — callers decide the policy (skip vs reject).
1581
+ */
1582
+ canObserve(swap) {
1583
+ return legsOf(swap).every(
1584
+ (leg) => this.#managers.get(leg.ledger)?.canObserve(leg) ?? false
1585
+ );
1586
+ }
1238
1587
  /** Register each swap's on-chain leg(s), subscribe to manager events, and seed state. */
1239
1588
  async startTracking(swaps) {
1240
1589
  for (const swap of swaps) {
1590
+ if (!this.canObserve(swap)) {
1591
+ console.warn(
1592
+ `SwapTracker: skipping swap ${swap.swapId} \u2014 a leg is on a chain this client can't reach (configure it to track this swap)`
1593
+ );
1594
+ continue;
1595
+ }
1241
1596
  this.#swaps.set(swap.swapId, swap);
1242
1597
  for (const leg of legsOf(swap)) await this.#managerFor(leg).register(leg);
1243
1598
  }
@@ -1252,6 +1607,39 @@ var SwapTracker = class {
1252
1607
  this.#refreshIntervalMs
1253
1608
  );
1254
1609
  }
1610
+ /**
1611
+ * Track one more swap after {@link startTracking} — register its on-chain
1612
+ * leg(s), seed the managers they touch, and derive its first action. Idempotent:
1613
+ * a swap already tracked, or one already seen through to a terminal action, is
1614
+ * ignored. New swaps are picked up by the existing per-manager event listeners
1615
+ * and poll timer, so nothing else needs re-wiring. Lets a swap created after
1616
+ * start (e.g. via `createSwap`) be tracked without a restart.
1617
+ */
1618
+ async track(swap) {
1619
+ if (this.#swaps.has(swap.swapId) || this.#lastActions.has(swap.swapId))
1620
+ return;
1621
+ if (!this.canObserve(swap)) {
1622
+ console.warn(
1623
+ `SwapTracker: skipping swap ${swap.swapId} \u2014 a leg is on a chain this client can't reach (configure it to track this swap)`
1624
+ );
1625
+ return;
1626
+ }
1627
+ this.#swaps.set(swap.swapId, swap);
1628
+ const registered = [];
1629
+ try {
1630
+ for (const leg of legsOf(swap)) {
1631
+ await this.#managerFor(leg).register(leg);
1632
+ registered.push(leg);
1633
+ }
1634
+ } catch (error) {
1635
+ this.#swaps.delete(swap.swapId);
1636
+ for (const leg of registered) void this.#managerFor(leg).unregister(leg);
1637
+ throw error;
1638
+ }
1639
+ const managers = new Set(legsOf(swap).map((leg) => this.#managerFor(leg)));
1640
+ await Promise.all([...managers].map((manager) => manager.refresh()));
1641
+ this.#recompute(swap);
1642
+ }
1255
1643
  async #tick() {
1256
1644
  await this.#refreshManagers();
1257
1645
  this.#recomputeAll();
@@ -1272,6 +1660,25 @@ var SwapTracker = class {
1272
1660
  );
1273
1661
  await Promise.all([...managers].map((manager) => manager.refresh()));
1274
1662
  }
1663
+ /** The swap ids currently tracked — e.g. to subscribe them to a hint feed. */
1664
+ trackedSwapIds() {
1665
+ return [...this.#swaps.keys()];
1666
+ }
1667
+ /**
1668
+ * Re-verify one swap against the chain right now — the hook a faster server
1669
+ * status hint drives. Reconciles just that swap's legs (targeted, on the
1670
+ * managers), then recomputes its action. The hint is only a *trigger*: the
1671
+ * chain stays the source of truth, so a premature hint that the chain doesn't
1672
+ * yet reflect changes nothing. A no-op for an untracked swap.
1673
+ */
1674
+ async applyHint(swapId, opts) {
1675
+ const swap = this.#swaps.get(swapId);
1676
+ if (!swap) return;
1677
+ await Promise.all(
1678
+ legsOf(swap).map((leg) => this.#managerFor(leg).reconcile(leg))
1679
+ );
1680
+ this.#recompute(swap, opts?.force ?? false);
1681
+ }
1275
1682
  /** Notify `cb` of the current action for each tracked swap, then on every change. */
1276
1683
  subscribeToActions(cb) {
1277
1684
  this.#subscribers.add(cb);
@@ -1306,7 +1713,7 @@ var SwapTracker = class {
1306
1713
  #recomputeAll() {
1307
1714
  for (const swap of this.#swaps.values()) this.#recompute(swap);
1308
1715
  }
1309
- #recompute(swap) {
1716
+ #recompute(swap, force = false) {
1310
1717
  let clientHtlc;
1311
1718
  let serverHtlc;
1312
1719
  let clientChainNow = 0;
@@ -1337,7 +1744,7 @@ var SwapTracker = class {
1337
1744
  clientFunds: swap.clientHtlc !== void 0
1338
1745
  });
1339
1746
  const previous = this.#lastActions.get(swap.swapId);
1340
- if (previous && JSON.stringify(previous) === JSON.stringify(actions))
1747
+ if (!force && previous && JSON.stringify(previous) === JSON.stringify(actions))
1341
1748
  return;
1342
1749
  this.#lastActions.set(swap.swapId, actions);
1343
1750
  for (const cb of this.#subscribers) cb(swap.swapId, actions);
@@ -1364,6 +1771,8 @@ var Client = class {
1364
1771
  /** Per-ledger monitors, resolved once from {@link #tracking}. */
1365
1772
  #managers;
1366
1773
  #tracker;
1774
+ /** Hint-driven auto-claim worker; only built when `withAutoClaim` opted in. */
1775
+ #worker;
1367
1776
  #started = false;
1368
1777
  constructor(...args) {
1369
1778
  if (args[0] instanceof import_lendaswap_sdk_pure.Client) {
@@ -1578,31 +1987,41 @@ var Client = class {
1578
1987
  }
1579
1988
  /** Delegated to the legacy client (migration checkpoint). */
1580
1989
  createSwap(...args) {
1581
- return this.#legacy.createSwap(...args);
1990
+ return this.#trackAfterCreate(this.#legacy.createSwap(...args));
1582
1991
  }
1583
1992
  /** Delegated to the legacy client (migration checkpoint). */
1584
1993
  createArkadeToEvmSwapGeneric(...args) {
1585
- return this.#legacy.createArkadeToEvmSwapGeneric(...args);
1994
+ return this.#trackAfterCreate(
1995
+ this.#legacy.createArkadeToEvmSwapGeneric(...args)
1996
+ );
1586
1997
  }
1587
1998
  /** Delegated to the legacy client (migration checkpoint). */
1588
1999
  createLightningToEvmSwapGeneric(...args) {
1589
- return this.#legacy.createLightningToEvmSwapGeneric(...args);
2000
+ return this.#trackAfterCreate(
2001
+ this.#legacy.createLightningToEvmSwapGeneric(...args)
2002
+ );
1590
2003
  }
1591
2004
  /** Delegated to the legacy client (migration checkpoint). */
1592
2005
  createBitcoinToEvmSwap(...args) {
1593
- return this.#legacy.createBitcoinToEvmSwap(...args);
2006
+ return this.#trackAfterCreate(this.#legacy.createBitcoinToEvmSwap(...args));
1594
2007
  }
1595
2008
  /** Delegated to the legacy client (migration checkpoint). */
1596
2009
  createBitcoinToArkadeSwap(...args) {
1597
- return this.#legacy.createBitcoinToArkadeSwap(...args);
2010
+ return this.#trackAfterCreate(
2011
+ this.#legacy.createBitcoinToArkadeSwap(...args)
2012
+ );
1598
2013
  }
1599
2014
  /** Delegated to the legacy client (migration checkpoint). */
1600
2015
  createLightningToArkadeSwap(...args) {
1601
- return this.#legacy.createLightningToArkadeSwap(...args);
2016
+ return this.#trackAfterCreate(
2017
+ this.#legacy.createLightningToArkadeSwap(...args)
2018
+ );
1602
2019
  }
1603
2020
  /** Delegated to the legacy client (migration checkpoint). */
1604
2021
  createArkadeToLightningSwap(...args) {
1605
- return this.#legacy.createArkadeToLightningSwap(...args);
2022
+ return this.#trackAfterCreate(
2023
+ this.#legacy.createArkadeToLightningSwap(...args)
2024
+ );
1606
2025
  }
1607
2026
  /** Delegated to the legacy client (migration checkpoint). */
1608
2027
  getArkadeToLightningQuote(...args) {
@@ -1610,19 +2029,25 @@ var Client = class {
1610
2029
  }
1611
2030
  /** Delegated to the legacy client (migration checkpoint). */
1612
2031
  retryArkadeToLightningSwap(...args) {
1613
- return this.#legacy.retryArkadeToLightningSwap(...args);
2032
+ return this.#trackAfterRetry(
2033
+ this.#legacy.retryArkadeToLightningSwap(...args)
2034
+ );
1614
2035
  }
1615
2036
  /** Delegated to the legacy client (migration checkpoint). */
1616
2037
  createEvmToArkadeSwapGeneric(...args) {
1617
- return this.#legacy.createEvmToArkadeSwapGeneric(...args);
2038
+ return this.#trackAfterCreate(
2039
+ this.#legacy.createEvmToArkadeSwapGeneric(...args)
2040
+ );
1618
2041
  }
1619
2042
  /** Delegated to the legacy client (migration checkpoint). */
1620
2043
  createEvmToBitcoinSwap(...args) {
1621
- return this.#legacy.createEvmToBitcoinSwap(...args);
2044
+ return this.#trackAfterCreate(this.#legacy.createEvmToBitcoinSwap(...args));
1622
2045
  }
1623
2046
  /** Delegated to the legacy client (migration checkpoint). */
1624
2047
  createEvmToLightningSwapGeneric(...args) {
1625
- return this.#legacy.createEvmToLightningSwapGeneric(...args);
2048
+ return this.#trackAfterCreate(
2049
+ this.#legacy.createEvmToLightningSwapGeneric(...args)
2050
+ );
1626
2051
  }
1627
2052
  /** Delegated to the legacy client (migration checkpoint). */
1628
2053
  getCoordinatorFundingCallDataPermit2(...args) {
@@ -1657,6 +2082,14 @@ var Client = class {
1657
2082
  return this.#legacy.getEvmDepositorKey(...args);
1658
2083
  }
1659
2084
  /** Delegated to the legacy client (migration checkpoint). */
2085
+ getRefundedEvmSwapContinuation(...args) {
2086
+ return this.#legacy.getRefundedEvmSwapContinuation(...args);
2087
+ }
2088
+ /** Delegated to the legacy client (migration checkpoint). */
2089
+ continueRefundedEvmSwap(...args) {
2090
+ return this.#trackAfterRetry(this.#legacy.continueRefundedEvmSwap(...args));
2091
+ }
2092
+ /** Delegated to the legacy client (migration checkpoint). */
1660
2093
  recoverGaslessFunds(...args) {
1661
2094
  return this.#legacy.recoverGaslessFunds(...args);
1662
2095
  }
@@ -1686,16 +2119,107 @@ var Client = class {
1686
2119
  refreshIntervalMs: this.#tracking.refreshIntervalMs ?? 5e3
1687
2120
  });
1688
2121
  this.#tracker = tracker;
1689
- const swaps = await this.listAllSwaps();
1690
- const tracked = swaps.map(swapToTracked).filter((s) => s !== void 0);
1691
- await tracker.startTracking(tracked);
2122
+ await tracker.startTracking(await this.#loadTrackedSwaps());
2123
+ this.#startWorker(tracker);
1692
2124
  } catch (error) {
2125
+ this.#worker?.stop();
2126
+ this.#worker = void 0;
1693
2127
  this.#tracker?.stop();
1694
2128
  this.#tracker = void 0;
1695
2129
  this.#started = false;
1696
2130
  throw error;
1697
2131
  }
1698
2132
  }
2133
+ /** Every stored swap whose ledgers are observable, mapped to a {@link TrackedSwap}. */
2134
+ async #loadTrackedSwaps() {
2135
+ const swaps = await this.listAllSwaps();
2136
+ return swaps.map(swapToTracked).filter((s) => s !== void 0);
2137
+ }
2138
+ /**
2139
+ * Run a create, then fold the new swap into tracking.
2140
+ *
2141
+ * When tracking is active, first REJECT a swap this client can't reach: a leg on
2142
+ * an unconfigured chain can be neither observed nor claimed, so creating it would
2143
+ * strand funds. Throwing here (before the caller funds it) is deliberately
2144
+ * stricter than {@link startTracking}, which only *skips* such a pre-existing
2145
+ * swap. Otherwise fold the new swap(s) in — fire-and-forget, so a tracking hiccup
2146
+ * never fails or delays the create; a no-op when tracking isn't running (a later
2147
+ * {@link startTracking} picks it up from storage).
2148
+ */
2149
+ async #trackAfterCreate(op) {
2150
+ const result = await op;
2151
+ const tracker = this.#tracker;
2152
+ if (!tracker) return result;
2153
+ let swaps;
2154
+ try {
2155
+ swaps = await this.#loadTrackedSwaps();
2156
+ } catch (error) {
2157
+ console.warn("Client: loading swaps after create failed:", error);
2158
+ return result;
2159
+ }
2160
+ const created = swaps.find((s) => s.swapId === createdSwapId(result));
2161
+ if (created && !tracker.canObserve(created))
2162
+ throw new Error(
2163
+ `Refusing swap ${created.swapId}: a leg is on a chain this client can't reach, so it can be neither tracked nor claimed. Configure the chain (e.g. ClientBuilder.withEvmRpcUrls) before creating it.`
2164
+ );
2165
+ void this.#trackAll(tracker, swaps);
2166
+ return result;
2167
+ }
2168
+ /**
2169
+ * Run a retry, then fold its replacement swap into tracking.
2170
+ *
2171
+ * Unlike a create there is no unreachable-leg
2172
+ * rejection: by the time the retry returns, the old VHTLC has already been
2173
+ * collab-refunded into the new one, so refusing here can't protect any funds
2174
+ * — and {@link SwapTracker.track} skips a swap it can't observe anyway.
2175
+ */
2176
+ async #trackAfterRetry(op) {
2177
+ const result = await op;
2178
+ const tracker = this.#tracker;
2179
+ if (!tracker) return result;
2180
+ try {
2181
+ void this.#trackAll(tracker, await this.#loadTrackedSwaps());
2182
+ } catch (error) {
2183
+ console.warn("Client: loading swaps after retry failed:", error);
2184
+ }
2185
+ return result;
2186
+ }
2187
+ /** Track each given swap the tracker isn't already watching (isolated per swap). */
2188
+ async #trackAll(tracker, swaps) {
2189
+ for (const swap of swaps) {
2190
+ try {
2191
+ await tracker.track(swap);
2192
+ } catch (error) {
2193
+ console.warn(`Client: tracking swap ${swap.swapId} failed:`, error);
2194
+ }
2195
+ }
2196
+ }
2197
+ /**
2198
+ * If auto-claim was opted in, wire the server status WebSocket into a
2199
+ * {@link SwapWorker}: hints trigger a chain re-verify, and a swap the chain
2200
+ * confirms claimable is auto-claimed; `fund`/refund actions are surfaced via the
2201
+ * configured `onActionRequired`. A no-op unless `withAutoClaim` was set and a
2202
+ * base URL is configured (the WebSocket needs a server to reach).
2203
+ */
2204
+ #startWorker(tracker) {
2205
+ const autoClaim = this.#tracking.autoClaim;
2206
+ if (!autoClaim) return;
2207
+ if (!this.baseUrl) return;
2208
+ const hintSource = new WsStatusSource({ serverUrl: this.baseUrl });
2209
+ const worker = new SwapWorker({
2210
+ tracker,
2211
+ hintSource,
2212
+ execute: async (swapId, actionId) => {
2213
+ if (actionId !== "claim")
2214
+ throw new Error(`refusing to auto-run action '${actionId}'`);
2215
+ const result = await this.claim(swapId);
2216
+ if (!result.success) throw new Error(result.message ?? "claim failed");
2217
+ },
2218
+ onActionRequired: autoClaim.onActionRequired
2219
+ });
2220
+ this.#worker = worker;
2221
+ worker.start();
2222
+ }
1699
2223
  /**
1700
2224
  * Subscribe to next-action updates: `cb` fires with the current action for each
1701
2225
  * tracked swap immediately, then on every change. Call after
@@ -1708,6 +2232,8 @@ var Client = class {
1708
2232
  }
1709
2233
  /** Stop tracking and drop subscribers. The managers themselves are not disposed. */
1710
2234
  stopTracking() {
2235
+ this.#worker?.stop();
2236
+ this.#worker = void 0;
1711
2237
  this.#tracker?.stop();
1712
2238
  this.#tracker = void 0;
1713
2239
  this.#started = false;
@@ -1757,6 +2283,7 @@ var ClientBuilder = class {
1757
2283
  #esploraUrl;
1758
2284
  #evmRpcUrls;
1759
2285
  #managers;
2286
+ #autoClaim;
1760
2287
  /**
1761
2288
  * Override the EVM JSON-RPC endpoint per chainId. Optional — tracking uses
1762
2289
  * tested public defaults otherwise; an override is tried first, with the
@@ -1766,6 +2293,18 @@ var ClientBuilder = class {
1766
2293
  this.#evmRpcUrls = urls;
1767
2294
  return this;
1768
2295
  }
2296
+ /**
2297
+ * Opt in to hint-driven auto-claim. Tracking then also subscribes to the
2298
+ * server status WebSocket (a faster trigger than the chain poll) and, when the
2299
+ * chain confirms a swap is claimable, claims it automatically. Actions that
2300
+ * need the user — a manual `fund`, or a refund to confirm — are surfaced via
2301
+ * `onActionRequired` instead of being run. Off by default: this auto-spends on
2302
+ * the user's behalf, so it must be explicit.
2303
+ */
2304
+ withAutoClaim(options) {
2305
+ this.#autoClaim = { onActionRequired: options?.onActionRequired };
2306
+ return this;
2307
+ }
1769
2308
  /** Turn observe-mode tracking off. */
1770
2309
  withoutTracking() {
1771
2310
  this.#trackingEnabled = false;
@@ -1848,10 +2387,15 @@ var ClientBuilder = class {
1848
2387
  managers: this.#managers,
1849
2388
  arkadeServerUrl: this.#arkadeServerUrl,
1850
2389
  esploraUrl: this.#esploraUrl,
1851
- evmRpcUrls: this.#evmRpcUrls
2390
+ evmRpcUrls: this.#evmRpcUrls,
2391
+ autoClaim: this.#autoClaim
1852
2392
  });
1853
2393
  }
1854
2394
  };
2395
+ function createdSwapId(result) {
2396
+ const id = result?.response?.id;
2397
+ return typeof id === "string" ? id : void 0;
2398
+ }
1855
2399
 
1856
2400
  // src/index.ts
1857
2401
  init_bitcoin_reader_esplora();
@@ -1865,6 +2409,8 @@ init_bitcoin_reader_esplora();
1865
2409
  DEFAULT_EVM_RPCS,
1866
2410
  EvmContractManager,
1867
2411
  SwapTracker,
2412
+ SwapWorker,
2413
+ WsStatusSource,
1868
2414
  buildArkadeVhtlcRef,
1869
2415
  createEvmRpcReader,
1870
2416
  defaultArkadeServerUrl,