@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.js CHANGED
@@ -374,6 +374,9 @@ var ArkadeContractManager = class _ArkadeContractManager {
374
374
  chainTime: config.chainTime
375
375
  });
376
376
  }
377
+ canObserve(ref) {
378
+ return ref.ledger === "arkade";
379
+ }
377
380
  async register(ref) {
378
381
  if (ref.ledger !== "arkade")
379
382
  throw new Error(
@@ -404,6 +407,13 @@ var ArkadeContractManager = class _ArkadeContractManager {
404
407
  [...this.#refs.values()].map((ref) => this.#reconcileRef(ref))
405
408
  );
406
409
  }
410
+ async reconcile(ref) {
411
+ if (ref.ledger !== "arkade") return;
412
+ const tracked = this.#refs.get(ref.script);
413
+ if (!tracked) return;
414
+ if (this.#chainTime) this.#now = await this.#chainTime();
415
+ await this.#reconcileRef(tracked);
416
+ }
407
417
  dispose() {
408
418
  this.#listeners.clear();
409
419
  }
@@ -518,6 +528,9 @@ var BitcoinContractManager = class _BitcoinContractManager {
518
528
  chainTime: config.chainTime
519
529
  });
520
530
  }
531
+ canObserve(ref) {
532
+ return ref.ledger === "bitcoin";
533
+ }
521
534
  async register(ref) {
522
535
  if (ref.ledger !== "bitcoin")
523
536
  throw new Error(
@@ -548,6 +561,13 @@ var BitcoinContractManager = class _BitcoinContractManager {
548
561
  [...this.#refs.values()].map((ref) => this.#reconcileRef(ref))
549
562
  );
550
563
  }
564
+ async reconcile(ref) {
565
+ if (ref.ledger !== "bitcoin") return;
566
+ const tracked = this.#refs.get(ref.address);
567
+ if (!tracked) return;
568
+ if (this.#chainTime) this.#now = await this.#chainTime();
569
+ await this.#reconcileRef(tracked);
570
+ }
551
571
  dispose() {
552
572
  this.#listeners.clear();
553
573
  }
@@ -634,6 +654,9 @@ var EvmContractManager = class _EvmContractManager {
634
654
  static fromDeps(deps) {
635
655
  return new _EvmContractManager(deps);
636
656
  }
657
+ canObserve(ref) {
658
+ return ref.ledger === "evm" && this.#readers.has(ref.chainId);
659
+ }
637
660
  async register(ref) {
638
661
  if (ref.ledger !== "evm")
639
662
  throw new Error(`EvmContractManager can't track a '${ref.ledger}' HTLC`);
@@ -672,6 +695,15 @@ var EvmContractManager = class _EvmContractManager {
672
695
  const chainIds = new Set([...this.#refs.values()].map((r) => r.chainId));
673
696
  await Promise.all([...chainIds].map((c) => this.#reconcileChain(c)));
674
697
  }
698
+ async reconcile(ref) {
699
+ if (ref.ledger !== "evm") return;
700
+ const tracked = this.#refs.get(htlcKey(ref));
701
+ if (!tracked) return;
702
+ const reader = this.#readers.get(tracked.chainId);
703
+ if (!reader) return;
704
+ this.#now.set(tracked.chainId, await reader.getBlockTimeMs());
705
+ await this.#reconcileRef(reader, tracked);
706
+ }
675
707
  dispose() {
676
708
  for (const unsub of this.#watchUnsubs.values()) unsub();
677
709
  this.#watchUnsubs.clear();
@@ -860,6 +892,310 @@ function defaultEvmReaders(overrides) {
860
892
  return readers;
861
893
  }
862
894
 
895
+ // src/hints/swap-worker.ts
896
+ var AUTO_EXECUTABLE = /* @__PURE__ */ new Set([
897
+ "claim"
898
+ ]);
899
+ var SwapWorker = class {
900
+ #tracker;
901
+ #hintSource;
902
+ #execute;
903
+ #onActionRequired;
904
+ #maxAttempts;
905
+ #baseDelayMs;
906
+ #maxDelayMs;
907
+ /** Swaps with an auto action in flight — guards against double-runs. */
908
+ #executing = /* @__PURE__ */ new Set();
909
+ /** Swaps with a scheduled auto-claim retry (backoff timer + attempt count). */
910
+ #retries = /* @__PURE__ */ new Map();
911
+ #unsubs = [];
912
+ #started = false;
913
+ constructor(options) {
914
+ this.#tracker = options.tracker;
915
+ this.#hintSource = options.hintSource;
916
+ this.#execute = options.execute;
917
+ this.#onActionRequired = options.onActionRequired;
918
+ this.#maxAttempts = options.retry?.maxAttempts ?? 5;
919
+ this.#baseDelayMs = options.retry?.baseDelayMs ?? 2e3;
920
+ this.#maxDelayMs = options.retry?.maxDelayMs ?? 3e4;
921
+ }
922
+ /** Watch tracked swaps on the hint feed, wire hints → verify, react to actions. */
923
+ start() {
924
+ if (this.#started) return;
925
+ this.#started = true;
926
+ this.#hintSource.subscribe(this.#tracker.trackedSwapIds());
927
+ this.#unsubs.push(
928
+ this.#hintSource.onStatus(({ swapId }) => {
929
+ void this.#tracker.applyHint(swapId).catch((error) => {
930
+ console.warn(`SwapWorker: applyHint(${swapId}) failed:`, error);
931
+ });
932
+ })
933
+ );
934
+ this.#hintSource.start();
935
+ this.#unsubs.push(
936
+ this.#tracker.subscribeToActions(
937
+ (swapId, actions) => this.#onActions(swapId, actions)
938
+ )
939
+ );
940
+ }
941
+ /** Stop reacting, cancel pending retries, and close the hint feed. */
942
+ stop() {
943
+ this.#started = false;
944
+ for (const unsub of this.#unsubs) unsub();
945
+ this.#unsubs = [];
946
+ this.#hintSource.stop();
947
+ for (const { timer } of this.#retries.values()) clearTimeout(timer);
948
+ this.#retries.clear();
949
+ this.#executing.clear();
950
+ }
951
+ #onActions(swapId, actions) {
952
+ const recommended = actions.recommended;
953
+ if (recommended === "none") {
954
+ this.#hintSource.unsubscribe([swapId]);
955
+ this.#cancelRetry(swapId);
956
+ return;
957
+ }
958
+ this.#hintSource.subscribe([swapId]);
959
+ if (recommended && AUTO_EXECUTABLE.has(recommended)) {
960
+ this.#autoExecute(swapId, actions);
961
+ return;
962
+ }
963
+ this.#cancelRetry(swapId);
964
+ if (!recommended) return;
965
+ const automation = actions.actions.find(
966
+ (a) => a.id === recommended
967
+ )?.automation;
968
+ if (automation === "manual" || automation === "confirm")
969
+ this.#onActionRequired?.(swapId, actions);
970
+ }
971
+ #autoExecute(swapId, actions) {
972
+ if (this.#executing.has(swapId)) return;
973
+ const actionId = actions.recommended;
974
+ if (!actionId) return;
975
+ this.#executing.add(swapId);
976
+ void this.#execute(swapId, actionId).then(() => this.#cancelRetry(swapId)).catch((error) => {
977
+ console.warn(`SwapWorker: ${actionId}(${swapId}) failed:`, error);
978
+ this.#scheduleRetry(swapId, actions);
979
+ }).finally(() => this.#executing.delete(swapId));
980
+ }
981
+ /** Schedule the next backoff retry, or give up and surface once attempts run out. */
982
+ #scheduleRetry(swapId, actions) {
983
+ const attempts = (this.#retries.get(swapId)?.attempts ?? 0) + 1;
984
+ if (attempts > this.#maxAttempts) {
985
+ console.warn(
986
+ `SwapWorker: auto-claim for ${swapId} gave up after ${this.#maxAttempts} attempts`
987
+ );
988
+ this.#cancelRetry(swapId);
989
+ this.#onActionRequired?.(swapId, actions);
990
+ return;
991
+ }
992
+ this.#clearTimer(swapId);
993
+ const delay = Math.min(
994
+ this.#baseDelayMs * 2 ** (attempts - 1),
995
+ this.#maxDelayMs
996
+ );
997
+ const timer = setTimeout(() => this.#retry(swapId, actions), delay);
998
+ this.#retries.set(swapId, { attempts, timer });
999
+ }
1000
+ /**
1001
+ * A retry fires: re-verify against chain (forced re-emit), which drives
1002
+ * {@link #onActions} to re-run the claim only if it is still recommended. If the
1003
+ * re-verify itself fails we can't safely retry, so back off and try the cycle
1004
+ * again.
1005
+ */
1006
+ #retry(swapId, actions) {
1007
+ void this.#tracker.applyHint(swapId, { force: true }).catch((error) => {
1008
+ console.warn(
1009
+ `SwapWorker: re-verify before retrying ${swapId} failed:`,
1010
+ error
1011
+ );
1012
+ this.#scheduleRetry(swapId, actions);
1013
+ });
1014
+ }
1015
+ #cancelRetry(swapId) {
1016
+ this.#clearTimer(swapId);
1017
+ this.#retries.delete(swapId);
1018
+ }
1019
+ #clearTimer(swapId) {
1020
+ const existing = this.#retries.get(swapId);
1021
+ if (existing) clearTimeout(existing.timer);
1022
+ }
1023
+ };
1024
+
1025
+ // src/hints/ws-status-source.ts
1026
+ var MAX_ARGS_PER_FRAME = 64;
1027
+ var MAX_SUBSCRIPTIONS = 256;
1028
+ var WsStatusSource = class {
1029
+ #url;
1030
+ #factory;
1031
+ #reconnectMinMs;
1032
+ #reconnectMaxMs;
1033
+ /**
1034
+ * Ids actually subscribed on the wire — held at or below the server's
1035
+ * per-connection cap, and resent in full on every (re)connect.
1036
+ */
1037
+ #active = /* @__PURE__ */ new Set();
1038
+ /**
1039
+ * Ids we want but have no slot for. Promoted FIFO as slots free (the worker
1040
+ * unsubscribes each swap when it reaches a terminal action, so they do free).
1041
+ * A pending swap is not broken, only slower: it gets no hints, so it advances
1042
+ * on the tracker's chain poll instead.
1043
+ */
1044
+ #pending = /* @__PURE__ */ new Set();
1045
+ #listeners = /* @__PURE__ */ new Set();
1046
+ #ws;
1047
+ #open = false;
1048
+ #stopped = false;
1049
+ #backoffMs;
1050
+ #reconnectTimer;
1051
+ constructor(options) {
1052
+ this.#url = toWsUrl(options.serverUrl);
1053
+ this.#factory = options.webSocketFactory ?? defaultWebSocketFactory;
1054
+ this.#reconnectMinMs = options.reconnectMinMs ?? 1e3;
1055
+ this.#reconnectMaxMs = options.reconnectMaxMs ?? 3e4;
1056
+ this.#backoffMs = this.#reconnectMinMs;
1057
+ }
1058
+ /** Open the connection and begin (re)subscribing. Idempotent. */
1059
+ start() {
1060
+ if (this.#ws || this.#reconnectTimer) return;
1061
+ this.#stopped = false;
1062
+ this.#connect();
1063
+ }
1064
+ /** Close the connection and stop reconnecting. */
1065
+ stop() {
1066
+ this.#stopped = true;
1067
+ if (this.#reconnectTimer) clearTimeout(this.#reconnectTimer);
1068
+ this.#reconnectTimer = void 0;
1069
+ this.#open = false;
1070
+ this.#ws?.close();
1071
+ this.#ws = void 0;
1072
+ }
1073
+ /** Emit for each pushed status update. Returns an unsubscribe fn. */
1074
+ onStatus(cb) {
1075
+ this.#listeners.add(cb);
1076
+ return () => this.#listeners.delete(cb);
1077
+ }
1078
+ /**
1079
+ * Track these swap ids — subscribe on the socket now if open, else on next open.
1080
+ *
1081
+ * Enforces the server's per-connection cap locally. The server silently stops
1082
+ * adding once it is full (it breaks out of the loop but still acks every id),
1083
+ * so sending more would leave us believing ids are subscribed that will never
1084
+ * receive an update. Overflow is queued instead and promoted as slots free.
1085
+ */
1086
+ subscribe(swapIds) {
1087
+ const fresh = swapIds.filter(
1088
+ (id) => !this.#active.has(id) && !this.#pending.has(id)
1089
+ );
1090
+ if (fresh.length === 0) return;
1091
+ const admitted = [];
1092
+ for (const id of fresh) {
1093
+ if (this.#active.size < MAX_SUBSCRIPTIONS) {
1094
+ this.#active.add(id);
1095
+ admitted.push(id);
1096
+ } else {
1097
+ this.#pending.add(id);
1098
+ }
1099
+ }
1100
+ if (this.#pending.size > 0)
1101
+ console.warn(
1102
+ `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`
1103
+ );
1104
+ if (this.#open && admitted.length > 0)
1105
+ this.#sendFrame("subscribe", admitted);
1106
+ }
1107
+ /** Stop tracking these swap ids, promoting queued ones into the freed slots. */
1108
+ unsubscribe(swapIds) {
1109
+ const dropped = [];
1110
+ for (const id of swapIds) {
1111
+ if (this.#active.delete(id)) dropped.push(id);
1112
+ else this.#pending.delete(id);
1113
+ }
1114
+ if (this.#open && dropped.length > 0)
1115
+ this.#sendFrame("unsubscribe", dropped);
1116
+ this.#promotePending();
1117
+ }
1118
+ /** Move queued ids into any free slots, subscribing them if the socket is open. */
1119
+ #promotePending() {
1120
+ if (this.#pending.size === 0) return;
1121
+ const promoted = [];
1122
+ for (const id of this.#pending) {
1123
+ if (this.#active.size >= MAX_SUBSCRIPTIONS) break;
1124
+ this.#pending.delete(id);
1125
+ this.#active.add(id);
1126
+ promoted.push(id);
1127
+ }
1128
+ if (this.#open && promoted.length > 0)
1129
+ this.#sendFrame("subscribe", promoted);
1130
+ }
1131
+ #connect() {
1132
+ this.#reconnectTimer = void 0;
1133
+ const ws = this.#factory(this.#url);
1134
+ this.#ws = ws;
1135
+ ws.onopen = () => {
1136
+ this.#backoffMs = this.#reconnectMinMs;
1137
+ this.#promotePending();
1138
+ this.#open = true;
1139
+ if (this.#active.size > 0)
1140
+ this.#sendFrame("subscribe", [...this.#active]);
1141
+ };
1142
+ ws.onmessage = (event) => this.#handleMessage(event.data);
1143
+ ws.onclose = () => this.#onDisconnect();
1144
+ ws.onerror = () => this.#ws?.close();
1145
+ }
1146
+ #onDisconnect() {
1147
+ this.#open = false;
1148
+ this.#ws = void 0;
1149
+ if (this.#stopped || this.#reconnectTimer) return;
1150
+ const delay = this.#backoffMs;
1151
+ this.#backoffMs = Math.min(this.#backoffMs * 2, this.#reconnectMaxMs);
1152
+ this.#reconnectTimer = setTimeout(() => this.#connect(), delay);
1153
+ }
1154
+ #handleMessage(data) {
1155
+ if (typeof data !== "string") return;
1156
+ let frame;
1157
+ try {
1158
+ frame = JSON.parse(data);
1159
+ } catch {
1160
+ return;
1161
+ }
1162
+ if (!frame || typeof frame !== "object") return;
1163
+ const f = frame;
1164
+ if (f.data && typeof f.data === "object") {
1165
+ for (const [swapId, status] of Object.entries(
1166
+ f.data
1167
+ ))
1168
+ this.#emit({ swapId, status });
1169
+ return;
1170
+ }
1171
+ if (typeof f.message === "string")
1172
+ console.warn(`WsStatusSource: server error: ${f.message}`);
1173
+ }
1174
+ #emit(update) {
1175
+ for (const cb of this.#listeners) cb(update);
1176
+ }
1177
+ #sendFrame(op, ids) {
1178
+ for (let i = 0; i < ids.length; i += MAX_ARGS_PER_FRAME) {
1179
+ const args = ids.slice(i, i + MAX_ARGS_PER_FRAME);
1180
+ this.#ws?.send(JSON.stringify({ op, channel: "swap_status", args }));
1181
+ }
1182
+ }
1183
+ };
1184
+ function toWsUrl(serverUrl) {
1185
+ const url = new URL(serverUrl);
1186
+ const scheme = url.protocol === "https:" ? "wss" : "ws";
1187
+ const path = url.pathname.replace(/\/+$/, "");
1188
+ return `${scheme}://${url.host}${path}/ws`;
1189
+ }
1190
+ function defaultWebSocketFactory(url) {
1191
+ const WS = globalThis.WebSocket;
1192
+ if (!WS)
1193
+ throw new Error(
1194
+ "no global WebSocket available \u2014 pass options.webSocketFactory (Node < 22)"
1195
+ );
1196
+ return new WS(url);
1197
+ }
1198
+
863
1199
  // src/contracts/arkade-vhtlc.ts
864
1200
  import { VHTLC, VHTLCContractHandler } from "@arkade-os/sdk";
865
1201
  import { ripemd160 as ripemd1602 } from "@noble/hashes/legacy.js";
@@ -1132,9 +1468,26 @@ var SwapTracker = class {
1132
1468
  this.#managers = managers;
1133
1469
  this.#refreshIntervalMs = options?.refreshIntervalMs ?? 0;
1134
1470
  }
1471
+ /**
1472
+ * Whether every on-chain leg of `swap` is observable — i.e. the tracker can
1473
+ * derive its status from chain. `false` means a leg is on a ledger/chain this
1474
+ * client can't reach (no manager, or an EVM chain with no RPC), so the swap
1475
+ * can't be tracked. Pure predicate — callers decide the policy (skip vs reject).
1476
+ */
1477
+ canObserve(swap) {
1478
+ return legsOf(swap).every(
1479
+ (leg) => this.#managers.get(leg.ledger)?.canObserve(leg) ?? false
1480
+ );
1481
+ }
1135
1482
  /** Register each swap's on-chain leg(s), subscribe to manager events, and seed state. */
1136
1483
  async startTracking(swaps) {
1137
1484
  for (const swap of swaps) {
1485
+ if (!this.canObserve(swap)) {
1486
+ console.warn(
1487
+ `SwapTracker: skipping swap ${swap.swapId} \u2014 a leg is on a chain this client can't reach (configure it to track this swap)`
1488
+ );
1489
+ continue;
1490
+ }
1138
1491
  this.#swaps.set(swap.swapId, swap);
1139
1492
  for (const leg of legsOf(swap)) await this.#managerFor(leg).register(leg);
1140
1493
  }
@@ -1149,6 +1502,39 @@ var SwapTracker = class {
1149
1502
  this.#refreshIntervalMs
1150
1503
  );
1151
1504
  }
1505
+ /**
1506
+ * Track one more swap after {@link startTracking} — register its on-chain
1507
+ * leg(s), seed the managers they touch, and derive its first action. Idempotent:
1508
+ * a swap already tracked, or one already seen through to a terminal action, is
1509
+ * ignored. New swaps are picked up by the existing per-manager event listeners
1510
+ * and poll timer, so nothing else needs re-wiring. Lets a swap created after
1511
+ * start (e.g. via `createSwap`) be tracked without a restart.
1512
+ */
1513
+ async track(swap) {
1514
+ if (this.#swaps.has(swap.swapId) || this.#lastActions.has(swap.swapId))
1515
+ return;
1516
+ if (!this.canObserve(swap)) {
1517
+ console.warn(
1518
+ `SwapTracker: skipping swap ${swap.swapId} \u2014 a leg is on a chain this client can't reach (configure it to track this swap)`
1519
+ );
1520
+ return;
1521
+ }
1522
+ this.#swaps.set(swap.swapId, swap);
1523
+ const registered = [];
1524
+ try {
1525
+ for (const leg of legsOf(swap)) {
1526
+ await this.#managerFor(leg).register(leg);
1527
+ registered.push(leg);
1528
+ }
1529
+ } catch (error) {
1530
+ this.#swaps.delete(swap.swapId);
1531
+ for (const leg of registered) void this.#managerFor(leg).unregister(leg);
1532
+ throw error;
1533
+ }
1534
+ const managers = new Set(legsOf(swap).map((leg) => this.#managerFor(leg)));
1535
+ await Promise.all([...managers].map((manager) => manager.refresh()));
1536
+ this.#recompute(swap);
1537
+ }
1152
1538
  async #tick() {
1153
1539
  await this.#refreshManagers();
1154
1540
  this.#recomputeAll();
@@ -1169,6 +1555,25 @@ var SwapTracker = class {
1169
1555
  );
1170
1556
  await Promise.all([...managers].map((manager) => manager.refresh()));
1171
1557
  }
1558
+ /** The swap ids currently tracked — e.g. to subscribe them to a hint feed. */
1559
+ trackedSwapIds() {
1560
+ return [...this.#swaps.keys()];
1561
+ }
1562
+ /**
1563
+ * Re-verify one swap against the chain right now — the hook a faster server
1564
+ * status hint drives. Reconciles just that swap's legs (targeted, on the
1565
+ * managers), then recomputes its action. The hint is only a *trigger*: the
1566
+ * chain stays the source of truth, so a premature hint that the chain doesn't
1567
+ * yet reflect changes nothing. A no-op for an untracked swap.
1568
+ */
1569
+ async applyHint(swapId, opts) {
1570
+ const swap = this.#swaps.get(swapId);
1571
+ if (!swap) return;
1572
+ await Promise.all(
1573
+ legsOf(swap).map((leg) => this.#managerFor(leg).reconcile(leg))
1574
+ );
1575
+ this.#recompute(swap, opts?.force ?? false);
1576
+ }
1172
1577
  /** Notify `cb` of the current action for each tracked swap, then on every change. */
1173
1578
  subscribeToActions(cb) {
1174
1579
  this.#subscribers.add(cb);
@@ -1203,7 +1608,7 @@ var SwapTracker = class {
1203
1608
  #recomputeAll() {
1204
1609
  for (const swap of this.#swaps.values()) this.#recompute(swap);
1205
1610
  }
1206
- #recompute(swap) {
1611
+ #recompute(swap, force = false) {
1207
1612
  let clientHtlc;
1208
1613
  let serverHtlc;
1209
1614
  let clientChainNow = 0;
@@ -1234,7 +1639,7 @@ var SwapTracker = class {
1234
1639
  clientFunds: swap.clientHtlc !== void 0
1235
1640
  });
1236
1641
  const previous = this.#lastActions.get(swap.swapId);
1237
- if (previous && JSON.stringify(previous) === JSON.stringify(actions))
1642
+ if (!force && previous && JSON.stringify(previous) === JSON.stringify(actions))
1238
1643
  return;
1239
1644
  this.#lastActions.set(swap.swapId, actions);
1240
1645
  for (const cb of this.#subscribers) cb(swap.swapId, actions);
@@ -1261,6 +1666,8 @@ var Client = class {
1261
1666
  /** Per-ledger monitors, resolved once from {@link #tracking}. */
1262
1667
  #managers;
1263
1668
  #tracker;
1669
+ /** Hint-driven auto-claim worker; only built when `withAutoClaim` opted in. */
1670
+ #worker;
1264
1671
  #started = false;
1265
1672
  constructor(...args) {
1266
1673
  if (args[0] instanceof LegacyClient) {
@@ -1475,31 +1882,41 @@ var Client = class {
1475
1882
  }
1476
1883
  /** Delegated to the legacy client (migration checkpoint). */
1477
1884
  createSwap(...args) {
1478
- return this.#legacy.createSwap(...args);
1885
+ return this.#trackAfterCreate(this.#legacy.createSwap(...args));
1479
1886
  }
1480
1887
  /** Delegated to the legacy client (migration checkpoint). */
1481
1888
  createArkadeToEvmSwapGeneric(...args) {
1482
- return this.#legacy.createArkadeToEvmSwapGeneric(...args);
1889
+ return this.#trackAfterCreate(
1890
+ this.#legacy.createArkadeToEvmSwapGeneric(...args)
1891
+ );
1483
1892
  }
1484
1893
  /** Delegated to the legacy client (migration checkpoint). */
1485
1894
  createLightningToEvmSwapGeneric(...args) {
1486
- return this.#legacy.createLightningToEvmSwapGeneric(...args);
1895
+ return this.#trackAfterCreate(
1896
+ this.#legacy.createLightningToEvmSwapGeneric(...args)
1897
+ );
1487
1898
  }
1488
1899
  /** Delegated to the legacy client (migration checkpoint). */
1489
1900
  createBitcoinToEvmSwap(...args) {
1490
- return this.#legacy.createBitcoinToEvmSwap(...args);
1901
+ return this.#trackAfterCreate(this.#legacy.createBitcoinToEvmSwap(...args));
1491
1902
  }
1492
1903
  /** Delegated to the legacy client (migration checkpoint). */
1493
1904
  createBitcoinToArkadeSwap(...args) {
1494
- return this.#legacy.createBitcoinToArkadeSwap(...args);
1905
+ return this.#trackAfterCreate(
1906
+ this.#legacy.createBitcoinToArkadeSwap(...args)
1907
+ );
1495
1908
  }
1496
1909
  /** Delegated to the legacy client (migration checkpoint). */
1497
1910
  createLightningToArkadeSwap(...args) {
1498
- return this.#legacy.createLightningToArkadeSwap(...args);
1911
+ return this.#trackAfterCreate(
1912
+ this.#legacy.createLightningToArkadeSwap(...args)
1913
+ );
1499
1914
  }
1500
1915
  /** Delegated to the legacy client (migration checkpoint). */
1501
1916
  createArkadeToLightningSwap(...args) {
1502
- return this.#legacy.createArkadeToLightningSwap(...args);
1917
+ return this.#trackAfterCreate(
1918
+ this.#legacy.createArkadeToLightningSwap(...args)
1919
+ );
1503
1920
  }
1504
1921
  /** Delegated to the legacy client (migration checkpoint). */
1505
1922
  getArkadeToLightningQuote(...args) {
@@ -1507,19 +1924,25 @@ var Client = class {
1507
1924
  }
1508
1925
  /** Delegated to the legacy client (migration checkpoint). */
1509
1926
  retryArkadeToLightningSwap(...args) {
1510
- return this.#legacy.retryArkadeToLightningSwap(...args);
1927
+ return this.#trackAfterRetry(
1928
+ this.#legacy.retryArkadeToLightningSwap(...args)
1929
+ );
1511
1930
  }
1512
1931
  /** Delegated to the legacy client (migration checkpoint). */
1513
1932
  createEvmToArkadeSwapGeneric(...args) {
1514
- return this.#legacy.createEvmToArkadeSwapGeneric(...args);
1933
+ return this.#trackAfterCreate(
1934
+ this.#legacy.createEvmToArkadeSwapGeneric(...args)
1935
+ );
1515
1936
  }
1516
1937
  /** Delegated to the legacy client (migration checkpoint). */
1517
1938
  createEvmToBitcoinSwap(...args) {
1518
- return this.#legacy.createEvmToBitcoinSwap(...args);
1939
+ return this.#trackAfterCreate(this.#legacy.createEvmToBitcoinSwap(...args));
1519
1940
  }
1520
1941
  /** Delegated to the legacy client (migration checkpoint). */
1521
1942
  createEvmToLightningSwapGeneric(...args) {
1522
- return this.#legacy.createEvmToLightningSwapGeneric(...args);
1943
+ return this.#trackAfterCreate(
1944
+ this.#legacy.createEvmToLightningSwapGeneric(...args)
1945
+ );
1523
1946
  }
1524
1947
  /** Delegated to the legacy client (migration checkpoint). */
1525
1948
  getCoordinatorFundingCallDataPermit2(...args) {
@@ -1554,6 +1977,14 @@ var Client = class {
1554
1977
  return this.#legacy.getEvmDepositorKey(...args);
1555
1978
  }
1556
1979
  /** Delegated to the legacy client (migration checkpoint). */
1980
+ getRefundedEvmSwapContinuation(...args) {
1981
+ return this.#legacy.getRefundedEvmSwapContinuation(...args);
1982
+ }
1983
+ /** Delegated to the legacy client (migration checkpoint). */
1984
+ continueRefundedEvmSwap(...args) {
1985
+ return this.#trackAfterRetry(this.#legacy.continueRefundedEvmSwap(...args));
1986
+ }
1987
+ /** Delegated to the legacy client (migration checkpoint). */
1557
1988
  recoverGaslessFunds(...args) {
1558
1989
  return this.#legacy.recoverGaslessFunds(...args);
1559
1990
  }
@@ -1583,16 +2014,107 @@ var Client = class {
1583
2014
  refreshIntervalMs: this.#tracking.refreshIntervalMs ?? 5e3
1584
2015
  });
1585
2016
  this.#tracker = tracker;
1586
- const swaps = await this.listAllSwaps();
1587
- const tracked = swaps.map(swapToTracked).filter((s) => s !== void 0);
1588
- await tracker.startTracking(tracked);
2017
+ await tracker.startTracking(await this.#loadTrackedSwaps());
2018
+ this.#startWorker(tracker);
1589
2019
  } catch (error) {
2020
+ this.#worker?.stop();
2021
+ this.#worker = void 0;
1590
2022
  this.#tracker?.stop();
1591
2023
  this.#tracker = void 0;
1592
2024
  this.#started = false;
1593
2025
  throw error;
1594
2026
  }
1595
2027
  }
2028
+ /** Every stored swap whose ledgers are observable, mapped to a {@link TrackedSwap}. */
2029
+ async #loadTrackedSwaps() {
2030
+ const swaps = await this.listAllSwaps();
2031
+ return swaps.map(swapToTracked).filter((s) => s !== void 0);
2032
+ }
2033
+ /**
2034
+ * Run a create, then fold the new swap into tracking.
2035
+ *
2036
+ * When tracking is active, first REJECT a swap this client can't reach: a leg on
2037
+ * an unconfigured chain can be neither observed nor claimed, so creating it would
2038
+ * strand funds. Throwing here (before the caller funds it) is deliberately
2039
+ * stricter than {@link startTracking}, which only *skips* such a pre-existing
2040
+ * swap. Otherwise fold the new swap(s) in — fire-and-forget, so a tracking hiccup
2041
+ * never fails or delays the create; a no-op when tracking isn't running (a later
2042
+ * {@link startTracking} picks it up from storage).
2043
+ */
2044
+ async #trackAfterCreate(op) {
2045
+ const result = await op;
2046
+ const tracker = this.#tracker;
2047
+ if (!tracker) return result;
2048
+ let swaps;
2049
+ try {
2050
+ swaps = await this.#loadTrackedSwaps();
2051
+ } catch (error) {
2052
+ console.warn("Client: loading swaps after create failed:", error);
2053
+ return result;
2054
+ }
2055
+ const created = swaps.find((s) => s.swapId === createdSwapId(result));
2056
+ if (created && !tracker.canObserve(created))
2057
+ throw new Error(
2058
+ `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.`
2059
+ );
2060
+ void this.#trackAll(tracker, swaps);
2061
+ return result;
2062
+ }
2063
+ /**
2064
+ * Run a retry, then fold its replacement swap into tracking.
2065
+ *
2066
+ * Unlike a create there is no unreachable-leg
2067
+ * rejection: by the time the retry returns, the old VHTLC has already been
2068
+ * collab-refunded into the new one, so refusing here can't protect any funds
2069
+ * — and {@link SwapTracker.track} skips a swap it can't observe anyway.
2070
+ */
2071
+ async #trackAfterRetry(op) {
2072
+ const result = await op;
2073
+ const tracker = this.#tracker;
2074
+ if (!tracker) return result;
2075
+ try {
2076
+ void this.#trackAll(tracker, await this.#loadTrackedSwaps());
2077
+ } catch (error) {
2078
+ console.warn("Client: loading swaps after retry failed:", error);
2079
+ }
2080
+ return result;
2081
+ }
2082
+ /** Track each given swap the tracker isn't already watching (isolated per swap). */
2083
+ async #trackAll(tracker, swaps) {
2084
+ for (const swap of swaps) {
2085
+ try {
2086
+ await tracker.track(swap);
2087
+ } catch (error) {
2088
+ console.warn(`Client: tracking swap ${swap.swapId} failed:`, error);
2089
+ }
2090
+ }
2091
+ }
2092
+ /**
2093
+ * If auto-claim was opted in, wire the server status WebSocket into a
2094
+ * {@link SwapWorker}: hints trigger a chain re-verify, and a swap the chain
2095
+ * confirms claimable is auto-claimed; `fund`/refund actions are surfaced via the
2096
+ * configured `onActionRequired`. A no-op unless `withAutoClaim` was set and a
2097
+ * base URL is configured (the WebSocket needs a server to reach).
2098
+ */
2099
+ #startWorker(tracker) {
2100
+ const autoClaim = this.#tracking.autoClaim;
2101
+ if (!autoClaim) return;
2102
+ if (!this.baseUrl) return;
2103
+ const hintSource = new WsStatusSource({ serverUrl: this.baseUrl });
2104
+ const worker = new SwapWorker({
2105
+ tracker,
2106
+ hintSource,
2107
+ execute: async (swapId, actionId) => {
2108
+ if (actionId !== "claim")
2109
+ throw new Error(`refusing to auto-run action '${actionId}'`);
2110
+ const result = await this.claim(swapId);
2111
+ if (!result.success) throw new Error(result.message ?? "claim failed");
2112
+ },
2113
+ onActionRequired: autoClaim.onActionRequired
2114
+ });
2115
+ this.#worker = worker;
2116
+ worker.start();
2117
+ }
1596
2118
  /**
1597
2119
  * Subscribe to next-action updates: `cb` fires with the current action for each
1598
2120
  * tracked swap immediately, then on every change. Call after
@@ -1605,6 +2127,8 @@ var Client = class {
1605
2127
  }
1606
2128
  /** Stop tracking and drop subscribers. The managers themselves are not disposed. */
1607
2129
  stopTracking() {
2130
+ this.#worker?.stop();
2131
+ this.#worker = void 0;
1608
2132
  this.#tracker?.stop();
1609
2133
  this.#tracker = void 0;
1610
2134
  this.#started = false;
@@ -1654,6 +2178,7 @@ var ClientBuilder = class {
1654
2178
  #esploraUrl;
1655
2179
  #evmRpcUrls;
1656
2180
  #managers;
2181
+ #autoClaim;
1657
2182
  /**
1658
2183
  * Override the EVM JSON-RPC endpoint per chainId. Optional — tracking uses
1659
2184
  * tested public defaults otherwise; an override is tried first, with the
@@ -1663,6 +2188,18 @@ var ClientBuilder = class {
1663
2188
  this.#evmRpcUrls = urls;
1664
2189
  return this;
1665
2190
  }
2191
+ /**
2192
+ * Opt in to hint-driven auto-claim. Tracking then also subscribes to the
2193
+ * server status WebSocket (a faster trigger than the chain poll) and, when the
2194
+ * chain confirms a swap is claimable, claims it automatically. Actions that
2195
+ * need the user — a manual `fund`, or a refund to confirm — are surfaced via
2196
+ * `onActionRequired` instead of being run. Off by default: this auto-spends on
2197
+ * the user's behalf, so it must be explicit.
2198
+ */
2199
+ withAutoClaim(options) {
2200
+ this.#autoClaim = { onActionRequired: options?.onActionRequired };
2201
+ return this;
2202
+ }
1666
2203
  /** Turn observe-mode tracking off. */
1667
2204
  withoutTracking() {
1668
2205
  this.#trackingEnabled = false;
@@ -1745,10 +2282,15 @@ var ClientBuilder = class {
1745
2282
  managers: this.#managers,
1746
2283
  arkadeServerUrl: this.#arkadeServerUrl,
1747
2284
  esploraUrl: this.#esploraUrl,
1748
- evmRpcUrls: this.#evmRpcUrls
2285
+ evmRpcUrls: this.#evmRpcUrls,
2286
+ autoClaim: this.#autoClaim
1749
2287
  });
1750
2288
  }
1751
2289
  };
2290
+ function createdSwapId(result) {
2291
+ const id = result?.response?.id;
2292
+ return typeof id === "string" ? id : void 0;
2293
+ }
1752
2294
  export {
1753
2295
  ArkadeContractManager,
1754
2296
  BitcoinContractManager,
@@ -1758,6 +2300,8 @@ export {
1758
2300
  DEFAULT_EVM_RPCS,
1759
2301
  EvmContractManager,
1760
2302
  SwapTracker,
2303
+ SwapWorker,
2304
+ WsStatusSource,
1761
2305
  buildArkadeVhtlcRef,
1762
2306
  createEvmRpcReader,
1763
2307
  defaultArkadeServerUrl,