@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 +563 -17
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +222 -1
- package/dist/index.d.ts +222 -1
- package/dist/index.js +561 -17
- package/dist/index.js.map +1 -1
- package/package.json +2 -2
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.#
|
|
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.#
|
|
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.#
|
|
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.#
|
|
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.#
|
|
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.#
|
|
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.#
|
|
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.#
|
|
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
|
-
|
|
1587
|
-
|
|
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,
|