@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.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.#
|
|
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.#
|
|
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.#
|
|
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.#
|
|
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.#
|
|
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.#
|
|
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.#
|
|
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.#
|
|
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
|
-
|
|
1690
|
-
|
|
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,
|