@satora/swap 0.0.5 → 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/README.md +5 -3
- package/dist/bitcoin-reader-esplora-GRUP7RAG.js +11 -0
- package/dist/bitcoin-reader-esplora-GRUP7RAG.js.map +1 -0
- package/dist/chunk-J35RZBDW.js +65 -0
- package/dist/chunk-J35RZBDW.js.map +1 -0
- package/dist/index.cjs +2402 -0
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +1176 -3
- package/dist/index.d.ts +1176 -3
- package/dist/index.js +2314 -0
- package/dist/index.js.map +1 -1
- package/dist/node.d.cts +2 -0
- package/dist/node.d.ts +2 -0
- package/package.json +10 -3
package/dist/index.js
CHANGED
|
@@ -1,3 +1,2317 @@
|
|
|
1
|
+
import {
|
|
2
|
+
DEFAULT_ESPLORA_URLS,
|
|
3
|
+
esploraReader,
|
|
4
|
+
htlcFactsFromEsploraTxs
|
|
5
|
+
} from "./chunk-J35RZBDW.js";
|
|
6
|
+
|
|
1
7
|
// src/index.ts
|
|
2
8
|
export * from "@lendasat/lendaswap-sdk-pure";
|
|
9
|
+
|
|
10
|
+
// src/actions/derive.ts
|
|
11
|
+
function deriveSwapActions(input) {
|
|
12
|
+
const {
|
|
13
|
+
status,
|
|
14
|
+
clientChainNow,
|
|
15
|
+
serverChainNow,
|
|
16
|
+
clientRefundLocktime,
|
|
17
|
+
serverRefundLocktime,
|
|
18
|
+
clientFunds = true
|
|
19
|
+
} = input;
|
|
20
|
+
switch (status) {
|
|
21
|
+
case "pending": {
|
|
22
|
+
if (!clientFunds) {
|
|
23
|
+
const wait = {
|
|
24
|
+
id: "wait",
|
|
25
|
+
recommended: true,
|
|
26
|
+
automation: "auto",
|
|
27
|
+
reason: "Waiting for the Lightning payment to be locked in."
|
|
28
|
+
};
|
|
29
|
+
return { recommended: "wait", actions: [wait] };
|
|
30
|
+
}
|
|
31
|
+
if (clientChainNow >= clientRefundLocktime) return { actions: [] };
|
|
32
|
+
const fund = {
|
|
33
|
+
id: "fund",
|
|
34
|
+
recommended: true,
|
|
35
|
+
// Funding spends new user money, so it is never auto-run.
|
|
36
|
+
automation: "manual",
|
|
37
|
+
reason: "Send the deposit to start the swap."
|
|
38
|
+
};
|
|
39
|
+
return { recommended: "fund", actions: [fund] };
|
|
40
|
+
}
|
|
41
|
+
case "clientfundingseen": {
|
|
42
|
+
const wait = {
|
|
43
|
+
id: "wait",
|
|
44
|
+
recommended: true,
|
|
45
|
+
automation: "auto",
|
|
46
|
+
reason: "Waiting for your funding transaction to confirm."
|
|
47
|
+
};
|
|
48
|
+
return { recommended: "wait", actions: [wait] };
|
|
49
|
+
}
|
|
50
|
+
case "clientfunded": {
|
|
51
|
+
const refundUnlocked = clientChainNow >= clientRefundLocktime;
|
|
52
|
+
if (refundUnlocked) {
|
|
53
|
+
const refund2 = {
|
|
54
|
+
id: "refund_unilateral",
|
|
55
|
+
recommended: true,
|
|
56
|
+
automation: "confirm",
|
|
57
|
+
reason: "Reclaim your deposit."
|
|
58
|
+
};
|
|
59
|
+
return { recommended: "refund_unilateral", actions: [refund2] };
|
|
60
|
+
}
|
|
61
|
+
const wait = {
|
|
62
|
+
id: "wait",
|
|
63
|
+
recommended: true,
|
|
64
|
+
automation: "auto",
|
|
65
|
+
reason: "Waiting for the server to fund the swap."
|
|
66
|
+
};
|
|
67
|
+
const refund = {
|
|
68
|
+
id: "refund_unilateral",
|
|
69
|
+
recommended: false,
|
|
70
|
+
automation: "confirm",
|
|
71
|
+
reason: "Refund becomes available once the timelock passes.",
|
|
72
|
+
blockedBy: {
|
|
73
|
+
kind: "timelock_not_expired",
|
|
74
|
+
message: "The refund timelock has not passed yet."
|
|
75
|
+
}
|
|
76
|
+
};
|
|
77
|
+
return { recommended: "wait", actions: [wait, refund] };
|
|
78
|
+
}
|
|
79
|
+
case "serverfunded": {
|
|
80
|
+
const claimWindowClosed = serverChainNow >= serverRefundLocktime;
|
|
81
|
+
if (!claimWindowClosed) {
|
|
82
|
+
const claim = {
|
|
83
|
+
id: "claim",
|
|
84
|
+
recommended: true,
|
|
85
|
+
automation: "auto",
|
|
86
|
+
reason: "Redeem the swap with your preimage."
|
|
87
|
+
};
|
|
88
|
+
return { recommended: "claim", actions: [claim] };
|
|
89
|
+
}
|
|
90
|
+
if (!clientFunds) {
|
|
91
|
+
const done = {
|
|
92
|
+
id: "none",
|
|
93
|
+
recommended: true,
|
|
94
|
+
automation: "auto",
|
|
95
|
+
reason: "The claim window passed \u2014 your Lightning payment will be refunded automatically."
|
|
96
|
+
};
|
|
97
|
+
return { recommended: "none", actions: [done] };
|
|
98
|
+
}
|
|
99
|
+
const refundUnlocked = clientChainNow >= clientRefundLocktime;
|
|
100
|
+
if (refundUnlocked) {
|
|
101
|
+
const refund2 = {
|
|
102
|
+
id: "refund_unilateral",
|
|
103
|
+
recommended: true,
|
|
104
|
+
automation: "confirm",
|
|
105
|
+
reason: "Reclaim your deposit \u2014 do not claim, as revealing the preimage would let the server take it."
|
|
106
|
+
};
|
|
107
|
+
return { recommended: "refund_unilateral", actions: [refund2] };
|
|
108
|
+
}
|
|
109
|
+
const wait = {
|
|
110
|
+
id: "wait",
|
|
111
|
+
recommended: true,
|
|
112
|
+
automation: "auto",
|
|
113
|
+
reason: "Wait for your refund timelock \u2014 do not claim, as revealing the preimage would let the server take your deposit."
|
|
114
|
+
};
|
|
115
|
+
const refund = {
|
|
116
|
+
id: "refund_unilateral",
|
|
117
|
+
recommended: false,
|
|
118
|
+
automation: "confirm",
|
|
119
|
+
reason: "Refund becomes available once the timelock passes.",
|
|
120
|
+
blockedBy: {
|
|
121
|
+
kind: "timelock_not_expired",
|
|
122
|
+
message: "The refund timelock has not passed yet."
|
|
123
|
+
}
|
|
124
|
+
};
|
|
125
|
+
return { recommended: "wait", actions: [wait, refund] };
|
|
126
|
+
}
|
|
127
|
+
case "clientredeeming": {
|
|
128
|
+
const wait = {
|
|
129
|
+
id: "wait",
|
|
130
|
+
recommended: true,
|
|
131
|
+
automation: "auto",
|
|
132
|
+
reason: "Redeeming your funds \u2014 waiting for the claim to confirm."
|
|
133
|
+
};
|
|
134
|
+
return { recommended: "wait", actions: [wait] };
|
|
135
|
+
}
|
|
136
|
+
case "clientredeemed":
|
|
137
|
+
case "serverredeemed": {
|
|
138
|
+
const done = {
|
|
139
|
+
id: "none",
|
|
140
|
+
recommended: true,
|
|
141
|
+
automation: "auto",
|
|
142
|
+
reason: "Swap complete \u2014 you've received your funds."
|
|
143
|
+
};
|
|
144
|
+
return { recommended: "none", actions: [done] };
|
|
145
|
+
}
|
|
146
|
+
case "expired": {
|
|
147
|
+
const done = {
|
|
148
|
+
id: "none",
|
|
149
|
+
recommended: true,
|
|
150
|
+
automation: "auto",
|
|
151
|
+
reason: "The swap expired without funding \u2014 nothing was locked."
|
|
152
|
+
};
|
|
153
|
+
return { recommended: "none", actions: [done] };
|
|
154
|
+
}
|
|
155
|
+
case "clientrefunded":
|
|
156
|
+
case "clientrefundedserverrefunded":
|
|
157
|
+
case "clientrefundedserverfunded": {
|
|
158
|
+
const done = {
|
|
159
|
+
id: "none",
|
|
160
|
+
recommended: true,
|
|
161
|
+
automation: "auto",
|
|
162
|
+
reason: "Refunded \u2014 your deposit was returned."
|
|
163
|
+
};
|
|
164
|
+
return { recommended: "none", actions: [done] };
|
|
165
|
+
}
|
|
166
|
+
case "clientredeemedandclientrefunded": {
|
|
167
|
+
const done = {
|
|
168
|
+
id: "none",
|
|
169
|
+
recommended: true,
|
|
170
|
+
automation: "auto",
|
|
171
|
+
reason: "This swap ended in an anomalous state (redeemed and refunded); no action needed."
|
|
172
|
+
};
|
|
173
|
+
return { recommended: "none", actions: [done] };
|
|
174
|
+
}
|
|
175
|
+
case "clientfundedserverrefunded":
|
|
176
|
+
case "serverwontfund":
|
|
177
|
+
case "clientfundedtoolate":
|
|
178
|
+
case "clientinvalidfunded": {
|
|
179
|
+
const refundUnlocked = clientChainNow >= clientRefundLocktime;
|
|
180
|
+
if (refundUnlocked) {
|
|
181
|
+
const refund2 = {
|
|
182
|
+
id: "refund_unilateral",
|
|
183
|
+
recommended: true,
|
|
184
|
+
automation: "confirm",
|
|
185
|
+
reason: "Reclaim your deposit \u2014 the swap did not complete."
|
|
186
|
+
};
|
|
187
|
+
return { recommended: "refund_unilateral", actions: [refund2] };
|
|
188
|
+
}
|
|
189
|
+
const wait = {
|
|
190
|
+
id: "wait",
|
|
191
|
+
recommended: true,
|
|
192
|
+
automation: "auto",
|
|
193
|
+
reason: "The swap did not complete; waiting for the refund timelock to reclaim your deposit."
|
|
194
|
+
};
|
|
195
|
+
const refund = {
|
|
196
|
+
id: "refund_unilateral",
|
|
197
|
+
recommended: false,
|
|
198
|
+
automation: "confirm",
|
|
199
|
+
reason: "Refund becomes available once the timelock passes.",
|
|
200
|
+
blockedBy: {
|
|
201
|
+
kind: "timelock_not_expired",
|
|
202
|
+
message: "The refund timelock has not passed yet."
|
|
203
|
+
}
|
|
204
|
+
};
|
|
205
|
+
return { recommended: "wait", actions: [wait, refund] };
|
|
206
|
+
}
|
|
207
|
+
default:
|
|
208
|
+
return assertNever(status);
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
function assertNever(status) {
|
|
212
|
+
throw new Error(`unhandled swap status: ${String(status)}`);
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
// src/actions/status.ts
|
|
216
|
+
function deriveSwapStatus(obs) {
|
|
217
|
+
const { clientHtlc, serverHtlc } = obs;
|
|
218
|
+
if (serverHtlc === void 0 && clientHtlc !== void 0) {
|
|
219
|
+
return receiveOnLightningStatus(clientHtlc);
|
|
220
|
+
}
|
|
221
|
+
if (clientHtlc === void 0 && serverHtlc !== void 0) {
|
|
222
|
+
return payOnLightningStatus(serverHtlc);
|
|
223
|
+
}
|
|
224
|
+
if (clientHtlc === void 0 || serverHtlc === void 0) return void 0;
|
|
225
|
+
if (clientHtlc === "spent_claim" && serverHtlc === "spent_claim") {
|
|
226
|
+
return "serverredeemed";
|
|
227
|
+
}
|
|
228
|
+
if (clientHtlc === "spent_refund" && serverHtlc === "spent_claim") {
|
|
229
|
+
return "clientredeemedandclientrefunded";
|
|
230
|
+
}
|
|
231
|
+
if (clientHtlc === "spent_refund" && serverHtlc === "spent_refund") {
|
|
232
|
+
return "clientrefundedserverrefunded";
|
|
233
|
+
}
|
|
234
|
+
if (clientHtlc === "spent_refund" && serverHtlc === "confirmed") {
|
|
235
|
+
return "clientrefundedserverfunded";
|
|
236
|
+
}
|
|
237
|
+
if (clientHtlc === "spent_refund" && (serverHtlc === "absent" || serverHtlc === "mempool")) {
|
|
238
|
+
return "clientrefunded";
|
|
239
|
+
}
|
|
240
|
+
if (clientHtlc === "confirmed" && serverHtlc === "spent_claim") {
|
|
241
|
+
return "clientredeemed";
|
|
242
|
+
}
|
|
243
|
+
if (clientHtlc === "confirmed" && serverHtlc === "spent_refund") {
|
|
244
|
+
return "clientfundedserverrefunded";
|
|
245
|
+
}
|
|
246
|
+
if (clientHtlc === "invalid") return "clientinvalidfunded";
|
|
247
|
+
if (clientHtlc === "absent") return "pending";
|
|
248
|
+
if (clientHtlc === "mempool") return "clientfundingseen";
|
|
249
|
+
if (clientHtlc === "confirmed") {
|
|
250
|
+
return serverHtlc === "confirmed" ? "serverfunded" : "clientfunded";
|
|
251
|
+
}
|
|
252
|
+
return void 0;
|
|
253
|
+
}
|
|
254
|
+
function receiveOnLightningStatus(clientHtlc) {
|
|
255
|
+
switch (clientHtlc) {
|
|
256
|
+
case "spent_claim":
|
|
257
|
+
return "serverredeemed";
|
|
258
|
+
// server swept the deposit ⟹ it paid the invoice
|
|
259
|
+
case "spent_refund":
|
|
260
|
+
return "clientrefunded";
|
|
261
|
+
case "invalid":
|
|
262
|
+
return "clientinvalidfunded";
|
|
263
|
+
case "confirmed":
|
|
264
|
+
return "clientfunded";
|
|
265
|
+
// funded; waiting for the server to pay + sweep
|
|
266
|
+
case "mempool":
|
|
267
|
+
return "clientfundingseen";
|
|
268
|
+
case "absent":
|
|
269
|
+
return "pending";
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
function payOnLightningStatus(serverHtlc) {
|
|
273
|
+
switch (serverHtlc) {
|
|
274
|
+
case "spent_claim":
|
|
275
|
+
return "clientredeemed";
|
|
276
|
+
// client claimed the on-chain leg ⟹ complete
|
|
277
|
+
case "confirmed":
|
|
278
|
+
return "serverfunded";
|
|
279
|
+
// ready to claim
|
|
280
|
+
case "spent_refund":
|
|
281
|
+
return "clientrefunded";
|
|
282
|
+
case "invalid":
|
|
283
|
+
case "mempool":
|
|
284
|
+
case "absent":
|
|
285
|
+
return "pending";
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
// src/client.ts
|
|
290
|
+
import {
|
|
291
|
+
Client as LegacyClient
|
|
292
|
+
} from "@lendasat/lendaswap-sdk-pure";
|
|
293
|
+
|
|
294
|
+
// src/contracts/arkade-manager.ts
|
|
295
|
+
import { RestIndexerProvider } from "@arkade-os/sdk";
|
|
296
|
+
import { hex as hex2 } from "@scure/base";
|
|
297
|
+
|
|
298
|
+
// src/contracts/arkade.ts
|
|
299
|
+
import {
|
|
300
|
+
ConditionWitness,
|
|
301
|
+
getArkPsbtFields,
|
|
302
|
+
Transaction
|
|
303
|
+
} from "@arkade-os/sdk";
|
|
304
|
+
import { base64 } from "@scure/base";
|
|
305
|
+
|
|
306
|
+
// src/contracts/preimage.ts
|
|
307
|
+
import { ripemd160 } from "@noble/hashes/legacy.js";
|
|
308
|
+
import { sha256 } from "@noble/hashes/sha2.js";
|
|
309
|
+
import { hex } from "@scure/base";
|
|
310
|
+
function preimageMatches(element, hashLock) {
|
|
311
|
+
const digest = hashLock.length === 20 ? ripemd160(sha256(element)) : sha256(element);
|
|
312
|
+
return hex.encode(digest) === hex.encode(hashLock);
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
// src/contracts/arkade.ts
|
|
316
|
+
function arkadeObservation(facts) {
|
|
317
|
+
if (facts.spend === "claim") return "spent_claim";
|
|
318
|
+
if (facts.spend === "refund") return "spent_refund";
|
|
319
|
+
if (!facts.funded) return "absent";
|
|
320
|
+
return facts.sufficient === false ? "invalid" : "confirmed";
|
|
321
|
+
}
|
|
322
|
+
function classifyArkadeSpend(encodedSpendTx, paymentHash) {
|
|
323
|
+
const tx = Transaction.fromPSBT(base64.decode(encodedSpendTx));
|
|
324
|
+
for (const witness of getArkPsbtFields(tx, 0, ConditionWitness)) {
|
|
325
|
+
for (const element of witness) {
|
|
326
|
+
if (element.length === 32 && preimageMatches(element, paymentHash)) {
|
|
327
|
+
return { spend: "claim", preimage: element };
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
return { spend: "refund" };
|
|
332
|
+
}
|
|
333
|
+
async function fetchArkadeSpend(source, spentByTxid, paymentHash) {
|
|
334
|
+
const { txs } = await source.getVirtualTxs([spentByTxid]);
|
|
335
|
+
const encoded = txs[0];
|
|
336
|
+
return encoded ? classifyArkadeSpend(encoded, paymentHash) : void 0;
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
// src/contracts/arkade-manager.ts
|
|
340
|
+
function isFunded(vtxo) {
|
|
341
|
+
const { state } = vtxo.virtualStatus;
|
|
342
|
+
return state === "preconfirmed" || state === "settled";
|
|
343
|
+
}
|
|
344
|
+
function spendTxid(vtxos) {
|
|
345
|
+
const spent = vtxos.find(
|
|
346
|
+
(v) => v.arkTxId || v.spentBy || v.isSpent || v.virtualStatus.state === "spent"
|
|
347
|
+
);
|
|
348
|
+
return spent?.arkTxId ?? spent?.spentBy;
|
|
349
|
+
}
|
|
350
|
+
var ArkadeContractManager = class _ArkadeContractManager {
|
|
351
|
+
ledger = "arkade";
|
|
352
|
+
#indexer;
|
|
353
|
+
#chainTime;
|
|
354
|
+
/** pkScript → the ref we're tracking. */
|
|
355
|
+
#refs = /* @__PURE__ */ new Map();
|
|
356
|
+
/** pkScript → last known observation. */
|
|
357
|
+
#obs = /* @__PURE__ */ new Map();
|
|
358
|
+
/** pkScript → the verified preimage recovered from a claim spend. */
|
|
359
|
+
#preimages = /* @__PURE__ */ new Map();
|
|
360
|
+
#listeners = /* @__PURE__ */ new Set();
|
|
361
|
+
#now;
|
|
362
|
+
constructor(deps) {
|
|
363
|
+
this.#indexer = deps.indexer;
|
|
364
|
+
this.#chainTime = deps.chainTime;
|
|
365
|
+
}
|
|
366
|
+
static fromDeps(deps) {
|
|
367
|
+
return new _ArkadeContractManager(deps);
|
|
368
|
+
}
|
|
369
|
+
/** Construct a manager backed by a `RestIndexerProvider` over the Ark server. */
|
|
370
|
+
static async create(config) {
|
|
371
|
+
const indexer = new RestIndexerProvider(config.serverUrl);
|
|
372
|
+
return _ArkadeContractManager.fromDeps({
|
|
373
|
+
indexer,
|
|
374
|
+
chainTime: config.chainTime
|
|
375
|
+
});
|
|
376
|
+
}
|
|
377
|
+
canObserve(ref) {
|
|
378
|
+
return ref.ledger === "arkade";
|
|
379
|
+
}
|
|
380
|
+
async register(ref) {
|
|
381
|
+
if (ref.ledger !== "arkade")
|
|
382
|
+
throw new Error(
|
|
383
|
+
`ArkadeContractManager can't track a '${ref.ledger}' HTLC`
|
|
384
|
+
);
|
|
385
|
+
this.#refs.set(ref.script, ref);
|
|
386
|
+
await this.#reconcileRef(ref);
|
|
387
|
+
}
|
|
388
|
+
async unregister(ref) {
|
|
389
|
+
if (ref.ledger !== "arkade") return;
|
|
390
|
+
this.#refs.delete(ref.script);
|
|
391
|
+
this.#obs.delete(ref.script);
|
|
392
|
+
this.#preimages.delete(ref.script);
|
|
393
|
+
}
|
|
394
|
+
getState(ref) {
|
|
395
|
+
return ref.ledger === "arkade" ? this.#obs.get(ref.script) : void 0;
|
|
396
|
+
}
|
|
397
|
+
chainNow(_ref) {
|
|
398
|
+
return this.#now;
|
|
399
|
+
}
|
|
400
|
+
onEvent(cb) {
|
|
401
|
+
this.#listeners.add(cb);
|
|
402
|
+
return () => this.#listeners.delete(cb);
|
|
403
|
+
}
|
|
404
|
+
async refresh() {
|
|
405
|
+
if (this.#chainTime) this.#now = await this.#chainTime();
|
|
406
|
+
await Promise.all(
|
|
407
|
+
[...this.#refs.values()].map((ref) => this.#reconcileRef(ref))
|
|
408
|
+
);
|
|
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
|
+
}
|
|
417
|
+
dispose() {
|
|
418
|
+
this.#listeners.clear();
|
|
419
|
+
}
|
|
420
|
+
/** The verified preimage recovered from a claim spend, if one was seen. */
|
|
421
|
+
getPreimage(ref) {
|
|
422
|
+
return ref.ledger === "arkade" ? this.#preimages.get(ref.script) : void 0;
|
|
423
|
+
}
|
|
424
|
+
/** Read the VHTLC's vtxos and map them to an observation. */
|
|
425
|
+
async #reconcileRef(ref) {
|
|
426
|
+
const { vtxos } = await this.#indexer.getVtxos({ scripts: [ref.script] });
|
|
427
|
+
const spend = spendTxid(vtxos);
|
|
428
|
+
if (spend) {
|
|
429
|
+
const resolved = await fetchArkadeSpend(
|
|
430
|
+
this.#indexer,
|
|
431
|
+
spend,
|
|
432
|
+
hex2.decode(ref.preimageHash)
|
|
433
|
+
);
|
|
434
|
+
if (resolved) {
|
|
435
|
+
if (resolved.spend === "claim")
|
|
436
|
+
this.#preimages.set(ref.script, resolved.preimage);
|
|
437
|
+
this.#set(
|
|
438
|
+
ref.script,
|
|
439
|
+
arkadeObservation({ funded: true, spend: resolved.spend })
|
|
440
|
+
);
|
|
441
|
+
return;
|
|
442
|
+
}
|
|
443
|
+
}
|
|
444
|
+
const funded = vtxos.filter(isFunded);
|
|
445
|
+
const total = funded.reduce((sum, vtxo) => sum + vtxo.value, 0);
|
|
446
|
+
this.#set(
|
|
447
|
+
ref.script,
|
|
448
|
+
arkadeObservation({
|
|
449
|
+
funded: funded.length > 0,
|
|
450
|
+
sufficient: total >= ref.expectedSats
|
|
451
|
+
})
|
|
452
|
+
);
|
|
453
|
+
}
|
|
454
|
+
/** Update an observation and notify on change; never downgrade a resolved spend. */
|
|
455
|
+
#set(script, observation) {
|
|
456
|
+
const ref = this.#refs.get(script);
|
|
457
|
+
if (!ref) return;
|
|
458
|
+
const current = this.#obs.get(script);
|
|
459
|
+
if (current === observation) return;
|
|
460
|
+
const spendStates = ["spent_claim", "spent_refund"];
|
|
461
|
+
if (current && spendStates.includes(current) && !spendStates.includes(observation))
|
|
462
|
+
return;
|
|
463
|
+
this.#obs.set(script, observation);
|
|
464
|
+
for (const listener of this.#listeners) listener(ref, observation);
|
|
465
|
+
}
|
|
466
|
+
};
|
|
467
|
+
|
|
468
|
+
// src/contracts/arkade-network.ts
|
|
469
|
+
var DEFAULT_ARKADE_URLS = {
|
|
470
|
+
bitcoin: "https://arkade.computer",
|
|
471
|
+
mainnet: "https://arkade.computer",
|
|
472
|
+
signet: "https://mutinynet.arkade.sh",
|
|
473
|
+
mutinynet: "https://mutinynet.arkade.sh"
|
|
474
|
+
};
|
|
475
|
+
function defaultArkadeServerUrl(network) {
|
|
476
|
+
return DEFAULT_ARKADE_URLS[network.toLowerCase()];
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
// src/contracts/bitcoin-manager.ts
|
|
480
|
+
import { hex as hex4 } from "@scure/base";
|
|
481
|
+
|
|
482
|
+
// src/contracts/bitcoin.ts
|
|
483
|
+
import { hex as hex3 } from "@scure/base";
|
|
484
|
+
function classifyBitcoinSpend(witness, paymentHash) {
|
|
485
|
+
for (const item of witness) {
|
|
486
|
+
if (item.length !== 64) continue;
|
|
487
|
+
const bytes = hex3.decode(item);
|
|
488
|
+
if (preimageMatches(bytes, paymentHash))
|
|
489
|
+
return { spend: "claim", preimage: bytes };
|
|
490
|
+
}
|
|
491
|
+
return { spend: "refund" };
|
|
492
|
+
}
|
|
493
|
+
function bitcoinObservation(facts, paymentHash, expectedSats) {
|
|
494
|
+
if (facts.spendWitness) {
|
|
495
|
+
const spend = classifyBitcoinSpend(facts.spendWitness, paymentHash);
|
|
496
|
+
return spend.spend === "claim" ? { observation: "spent_claim", preimage: spend.preimage } : { observation: "spent_refund" };
|
|
497
|
+
}
|
|
498
|
+
if (facts.funding === "confirmed" && facts.fundedSats < expectedSats) {
|
|
499
|
+
return { observation: "invalid" };
|
|
500
|
+
}
|
|
501
|
+
return { observation: facts.funding };
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
// src/contracts/bitcoin-manager.ts
|
|
505
|
+
var BitcoinContractManager = class _BitcoinContractManager {
|
|
506
|
+
ledger = "bitcoin";
|
|
507
|
+
#reader;
|
|
508
|
+
#chainTime;
|
|
509
|
+
/** address → the ref we're tracking. */
|
|
510
|
+
#refs = /* @__PURE__ */ new Map();
|
|
511
|
+
/** address → last known observation. */
|
|
512
|
+
#obs = /* @__PURE__ */ new Map();
|
|
513
|
+
/** address → the preimage a claim revealed. */
|
|
514
|
+
#preimages = /* @__PURE__ */ new Map();
|
|
515
|
+
#listeners = /* @__PURE__ */ new Set();
|
|
516
|
+
#now;
|
|
517
|
+
constructor(deps) {
|
|
518
|
+
this.#reader = deps.reader;
|
|
519
|
+
this.#chainTime = deps.chainTime;
|
|
520
|
+
}
|
|
521
|
+
static fromDeps(deps) {
|
|
522
|
+
return new _BitcoinContractManager(deps);
|
|
523
|
+
}
|
|
524
|
+
static async create(config) {
|
|
525
|
+
const { esploraReader: esploraReader2 } = await import("./bitcoin-reader-esplora-GRUP7RAG.js");
|
|
526
|
+
return _BitcoinContractManager.fromDeps({
|
|
527
|
+
reader: esploraReader2(config.esploraUrl),
|
|
528
|
+
chainTime: config.chainTime
|
|
529
|
+
});
|
|
530
|
+
}
|
|
531
|
+
canObserve(ref) {
|
|
532
|
+
return ref.ledger === "bitcoin";
|
|
533
|
+
}
|
|
534
|
+
async register(ref) {
|
|
535
|
+
if (ref.ledger !== "bitcoin")
|
|
536
|
+
throw new Error(
|
|
537
|
+
`BitcoinContractManager can't track a '${ref.ledger}' HTLC`
|
|
538
|
+
);
|
|
539
|
+
this.#refs.set(ref.address, ref);
|
|
540
|
+
await this.#reconcileRef(ref);
|
|
541
|
+
}
|
|
542
|
+
async unregister(ref) {
|
|
543
|
+
if (ref.ledger !== "bitcoin") return;
|
|
544
|
+
this.#refs.delete(ref.address);
|
|
545
|
+
this.#obs.delete(ref.address);
|
|
546
|
+
this.#preimages.delete(ref.address);
|
|
547
|
+
}
|
|
548
|
+
getState(ref) {
|
|
549
|
+
return ref.ledger === "bitcoin" ? this.#obs.get(ref.address) : void 0;
|
|
550
|
+
}
|
|
551
|
+
chainNow(_ref) {
|
|
552
|
+
return this.#now;
|
|
553
|
+
}
|
|
554
|
+
onEvent(cb) {
|
|
555
|
+
this.#listeners.add(cb);
|
|
556
|
+
return () => this.#listeners.delete(cb);
|
|
557
|
+
}
|
|
558
|
+
async refresh() {
|
|
559
|
+
if (this.#chainTime) this.#now = await this.#chainTime();
|
|
560
|
+
await Promise.all(
|
|
561
|
+
[...this.#refs.values()].map((ref) => this.#reconcileRef(ref))
|
|
562
|
+
);
|
|
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
|
+
}
|
|
571
|
+
dispose() {
|
|
572
|
+
this.#listeners.clear();
|
|
573
|
+
}
|
|
574
|
+
/** The preimage a claim revealed on this HTLC, if one was seen. */
|
|
575
|
+
getPreimage(ref) {
|
|
576
|
+
return ref.ledger === "bitcoin" ? this.#preimages.get(ref.address) : void 0;
|
|
577
|
+
}
|
|
578
|
+
async #reconcileRef(ref) {
|
|
579
|
+
const facts = await this.#reader.getHtlcFacts(ref.address);
|
|
580
|
+
const { observation, preimage } = bitcoinObservation(
|
|
581
|
+
facts,
|
|
582
|
+
hex4.decode(ref.preimageHash),
|
|
583
|
+
ref.expectedSats
|
|
584
|
+
);
|
|
585
|
+
if (preimage) this.#preimages.set(ref.address, preimage);
|
|
586
|
+
this.#set(ref.address, observation);
|
|
587
|
+
}
|
|
588
|
+
/** Update an observation and notify on change; never downgrade a resolved spend. */
|
|
589
|
+
#set(address, observation) {
|
|
590
|
+
const ref = this.#refs.get(address);
|
|
591
|
+
if (!ref) return;
|
|
592
|
+
const current = this.#obs.get(address);
|
|
593
|
+
if (current === observation) return;
|
|
594
|
+
const spendStates = ["spent_claim", "spent_refund"];
|
|
595
|
+
if (current && spendStates.includes(current) && !spendStates.includes(observation))
|
|
596
|
+
return;
|
|
597
|
+
this.#obs.set(address, observation);
|
|
598
|
+
for (const listener of this.#listeners) listener(ref, observation);
|
|
599
|
+
}
|
|
600
|
+
};
|
|
601
|
+
|
|
602
|
+
// src/contracts/evm.ts
|
|
603
|
+
function evmObservation(events, expected, pending = false) {
|
|
604
|
+
const redeemed = events.find((e) => e.kind === "redeemed");
|
|
605
|
+
if (redeemed?.kind === "redeemed")
|
|
606
|
+
return { observation: "spent_claim", preimage: redeemed.preimage };
|
|
607
|
+
if (events.some((e) => e.kind === "refunded"))
|
|
608
|
+
return { observation: "spent_refund" };
|
|
609
|
+
const created = events.find((e) => e.kind === "created");
|
|
610
|
+
if (created?.kind === "created") {
|
|
611
|
+
const amountOk = created.amount >= expected.amount;
|
|
612
|
+
const tokenOk = expected.token === void 0 || created.token.toLowerCase() === expected.token.toLowerCase();
|
|
613
|
+
return { observation: amountOk && tokenOk ? "confirmed" : "invalid" };
|
|
614
|
+
}
|
|
615
|
+
return { observation: pending ? "mempool" : "absent" };
|
|
616
|
+
}
|
|
617
|
+
|
|
618
|
+
// src/contracts/types.ts
|
|
619
|
+
function htlcKey(ref) {
|
|
620
|
+
switch (ref.ledger) {
|
|
621
|
+
case "arkade":
|
|
622
|
+
return `arkade:${ref.script}`;
|
|
623
|
+
case "bitcoin":
|
|
624
|
+
return `bitcoin:${ref.address}`;
|
|
625
|
+
case "evm":
|
|
626
|
+
return `evm:${ref.chainId}:${ref.preimageHash}`;
|
|
627
|
+
case "lightning":
|
|
628
|
+
return `lightning:${ref.paymentHash}`;
|
|
629
|
+
}
|
|
630
|
+
}
|
|
631
|
+
|
|
632
|
+
// src/contracts/evm-manager.ts
|
|
633
|
+
var EvmContractManager = class _EvmContractManager {
|
|
634
|
+
ledger = "evm";
|
|
635
|
+
#readers;
|
|
636
|
+
/** htlcKey → the ref we're tracking. */
|
|
637
|
+
#refs = /* @__PURE__ */ new Map();
|
|
638
|
+
/** htlcKey → last known observation. */
|
|
639
|
+
#obs = /* @__PURE__ */ new Map();
|
|
640
|
+
/** htlcKey → the preimage a claim revealed. */
|
|
641
|
+
#preimages = /* @__PURE__ */ new Map();
|
|
642
|
+
/** chainId → its latest block time (ms). */
|
|
643
|
+
#now = /* @__PURE__ */ new Map();
|
|
644
|
+
/** chainId → unsubscribe for its (single, shared) watch. */
|
|
645
|
+
#watchUnsubs = /* @__PURE__ */ new Map();
|
|
646
|
+
/** chainId → a block-event reconcile is currently running (coalescing). */
|
|
647
|
+
#reconciling = /* @__PURE__ */ new Set();
|
|
648
|
+
/** chainId → a block event arrived mid-reconcile; run exactly one more. */
|
|
649
|
+
#reconcileAgain = /* @__PURE__ */ new Set();
|
|
650
|
+
#listeners = /* @__PURE__ */ new Set();
|
|
651
|
+
constructor(deps) {
|
|
652
|
+
this.#readers = deps.readers;
|
|
653
|
+
}
|
|
654
|
+
static fromDeps(deps) {
|
|
655
|
+
return new _EvmContractManager(deps);
|
|
656
|
+
}
|
|
657
|
+
canObserve(ref) {
|
|
658
|
+
return ref.ledger === "evm" && this.#readers.has(ref.chainId);
|
|
659
|
+
}
|
|
660
|
+
async register(ref) {
|
|
661
|
+
if (ref.ledger !== "evm")
|
|
662
|
+
throw new Error(`EvmContractManager can't track a '${ref.ledger}' HTLC`);
|
|
663
|
+
if (!this.#readers.has(ref.chainId))
|
|
664
|
+
throw new Error(
|
|
665
|
+
`no EVM reader for chain ${ref.chainId} \u2014 configure it via ClientBuilder.withEvmRpcUrls()`
|
|
666
|
+
);
|
|
667
|
+
this.#refs.set(htlcKey(ref), ref);
|
|
668
|
+
this.#ensureWatch(ref.chainId);
|
|
669
|
+
await this.#reconcileChain(ref.chainId);
|
|
670
|
+
}
|
|
671
|
+
async unregister(ref) {
|
|
672
|
+
if (ref.ledger !== "evm") return;
|
|
673
|
+
const key = htlcKey(ref);
|
|
674
|
+
this.#refs.delete(key);
|
|
675
|
+
this.#obs.delete(key);
|
|
676
|
+
this.#preimages.delete(key);
|
|
677
|
+
if (![...this.#refs.values()].some((r) => r.chainId === ref.chainId)) {
|
|
678
|
+
this.#watchUnsubs.get(ref.chainId)?.();
|
|
679
|
+
this.#watchUnsubs.delete(ref.chainId);
|
|
680
|
+
this.#now.delete(ref.chainId);
|
|
681
|
+
this.#reconcileAgain.delete(ref.chainId);
|
|
682
|
+
}
|
|
683
|
+
}
|
|
684
|
+
getState(ref) {
|
|
685
|
+
return ref.ledger === "evm" ? this.#obs.get(htlcKey(ref)) : void 0;
|
|
686
|
+
}
|
|
687
|
+
chainNow(ref) {
|
|
688
|
+
return ref.ledger === "evm" ? this.#now.get(ref.chainId) : void 0;
|
|
689
|
+
}
|
|
690
|
+
onEvent(cb) {
|
|
691
|
+
this.#listeners.add(cb);
|
|
692
|
+
return () => this.#listeners.delete(cb);
|
|
693
|
+
}
|
|
694
|
+
async refresh() {
|
|
695
|
+
const chainIds = new Set([...this.#refs.values()].map((r) => r.chainId));
|
|
696
|
+
await Promise.all([...chainIds].map((c) => this.#reconcileChain(c)));
|
|
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
|
+
}
|
|
707
|
+
dispose() {
|
|
708
|
+
for (const unsub of this.#watchUnsubs.values()) unsub();
|
|
709
|
+
this.#watchUnsubs.clear();
|
|
710
|
+
this.#reconciling.clear();
|
|
711
|
+
this.#reconcileAgain.clear();
|
|
712
|
+
this.#listeners.clear();
|
|
713
|
+
}
|
|
714
|
+
/** The preimage a claim revealed on this HTLC, if one was seen. */
|
|
715
|
+
getPreimage(ref) {
|
|
716
|
+
return ref.ledger === "evm" ? this.#preimages.get(htlcKey(ref)) : void 0;
|
|
717
|
+
}
|
|
718
|
+
#ensureWatch(chainId) {
|
|
719
|
+
if (this.#watchUnsubs.has(chainId)) return;
|
|
720
|
+
const reader = this.#readers.get(chainId);
|
|
721
|
+
if (!reader) return;
|
|
722
|
+
this.#watchUnsubs.set(
|
|
723
|
+
chainId,
|
|
724
|
+
reader.watch(() => this.#scheduleReconcile(chainId))
|
|
725
|
+
);
|
|
726
|
+
}
|
|
727
|
+
/**
|
|
728
|
+
* Reconcile a chain in response to a block/log event, coalesced: at most one
|
|
729
|
+
* reconcile runs per chain at a time, with at most one queued rerun to pick up
|
|
730
|
+
* changes that arrived mid-run. This stops fast chains / slow RPCs from stacking
|
|
731
|
+
* redundant full-log scans (which amplify rate limits), and the `.catch` keeps a
|
|
732
|
+
* transient RPC failure in a background reconcile from becoming an unhandled
|
|
733
|
+
* rejection. `register`/`refresh` still await `#reconcileChain` directly.
|
|
734
|
+
*/
|
|
735
|
+
#scheduleReconcile(chainId) {
|
|
736
|
+
if (this.#reconciling.has(chainId)) {
|
|
737
|
+
this.#reconcileAgain.add(chainId);
|
|
738
|
+
return;
|
|
739
|
+
}
|
|
740
|
+
this.#reconciling.add(chainId);
|
|
741
|
+
void this.#reconcileChain(chainId).catch((error) => {
|
|
742
|
+
console.warn(`EVM reconcile failed for chain ${chainId}:`, error);
|
|
743
|
+
}).finally(() => {
|
|
744
|
+
this.#reconciling.delete(chainId);
|
|
745
|
+
if (this.#reconcileAgain.delete(chainId))
|
|
746
|
+
this.#scheduleReconcile(chainId);
|
|
747
|
+
});
|
|
748
|
+
}
|
|
749
|
+
/** Refresh one chain's clock and re-observe every HTLC tracked on it. */
|
|
750
|
+
async #reconcileChain(chainId) {
|
|
751
|
+
const reader = this.#readers.get(chainId);
|
|
752
|
+
if (!reader) return;
|
|
753
|
+
this.#now.set(chainId, await reader.getBlockTimeMs());
|
|
754
|
+
await Promise.all(
|
|
755
|
+
[...this.#refs.values()].filter((r) => r.chainId === chainId).map((r) => this.#reconcileRef(reader, r))
|
|
756
|
+
);
|
|
757
|
+
}
|
|
758
|
+
async #reconcileRef(reader, ref) {
|
|
759
|
+
const events = await reader.getHtlcEvents(
|
|
760
|
+
ref.htlc,
|
|
761
|
+
ref.preimageHash,
|
|
762
|
+
ref.claimAddress
|
|
763
|
+
);
|
|
764
|
+
const { observation, preimage } = evmObservation(events, {
|
|
765
|
+
amount: ref.expectedAmount,
|
|
766
|
+
token: ref.expectedToken
|
|
767
|
+
});
|
|
768
|
+
const key = htlcKey(ref);
|
|
769
|
+
if (preimage) this.#preimages.set(key, preimage);
|
|
770
|
+
this.#set(key, observation);
|
|
771
|
+
}
|
|
772
|
+
/** Update an observation and notify on change; never downgrade a resolved spend. */
|
|
773
|
+
#set(key, observation) {
|
|
774
|
+
const ref = this.#refs.get(key);
|
|
775
|
+
if (!ref) return;
|
|
776
|
+
const current = this.#obs.get(key);
|
|
777
|
+
if (current === observation) return;
|
|
778
|
+
const spendStates = ["spent_claim", "spent_refund"];
|
|
779
|
+
if (current && spendStates.includes(current) && !spendStates.includes(observation))
|
|
780
|
+
return;
|
|
781
|
+
this.#obs.set(key, observation);
|
|
782
|
+
for (const listener of this.#listeners) listener(ref, observation);
|
|
783
|
+
}
|
|
784
|
+
};
|
|
785
|
+
|
|
786
|
+
// src/contracts/evm-reader-viem.ts
|
|
787
|
+
import {
|
|
788
|
+
createPublicClient,
|
|
789
|
+
fallback,
|
|
790
|
+
http,
|
|
791
|
+
parseAbiItem
|
|
792
|
+
} from "viem";
|
|
793
|
+
var DEFAULT_EVM_RPCS = {
|
|
794
|
+
// Polygon — viem's default (polygon.drpc.org) misbehaves for some calls, so
|
|
795
|
+
// list working public RPCs explicitly.
|
|
796
|
+
137: [
|
|
797
|
+
"https://polygon.drpc.org",
|
|
798
|
+
"https://tenderly.rpc.polygon.community",
|
|
799
|
+
"https://polygon-bor-rpc.publicnode.com"
|
|
800
|
+
],
|
|
801
|
+
1: [
|
|
802
|
+
"https://ethereum-rpc.publicnode.com",
|
|
803
|
+
"https://rpc.ankr.com/eth",
|
|
804
|
+
"https://eth.drpc.org"
|
|
805
|
+
],
|
|
806
|
+
42161: [
|
|
807
|
+
"https://arbitrum-one-rpc.publicnode.com",
|
|
808
|
+
"https://rpc.ankr.com/arbitrum",
|
|
809
|
+
"https://arb1.arbitrum.io/rpc"
|
|
810
|
+
]
|
|
811
|
+
};
|
|
812
|
+
var SWAP_CREATED = parseAbiItem(
|
|
813
|
+
"event SwapCreated(bytes32 indexed preimageHash, address indexed refundAddress, address indexed claimAddress, address token, uint256 amount, uint256 timelock)"
|
|
814
|
+
);
|
|
815
|
+
var SWAP_REDEEMED = parseAbiItem(
|
|
816
|
+
"event SwapRedeemed(bytes32 indexed preimageHash, bytes32 preimage)"
|
|
817
|
+
);
|
|
818
|
+
var SWAP_REFUNDED = parseAbiItem(
|
|
819
|
+
"event SwapRefunded(bytes32 indexed preimageHash)"
|
|
820
|
+
);
|
|
821
|
+
function evmReaderFromClient(client) {
|
|
822
|
+
return {
|
|
823
|
+
async getHtlcEvents(htlc, preimageHash, claimAddress) {
|
|
824
|
+
const [created, redeemed, refunded] = await Promise.all([
|
|
825
|
+
// Filter by claimAddress too (also indexed), so only the HTLC actually
|
|
826
|
+
// claimable on the swap's terms is seen.
|
|
827
|
+
client.getLogs({
|
|
828
|
+
address: htlc,
|
|
829
|
+
event: SWAP_CREATED,
|
|
830
|
+
args: { preimageHash, claimAddress },
|
|
831
|
+
fromBlock: "earliest"
|
|
832
|
+
}),
|
|
833
|
+
client.getLogs({
|
|
834
|
+
address: htlc,
|
|
835
|
+
event: SWAP_REDEEMED,
|
|
836
|
+
args: { preimageHash },
|
|
837
|
+
fromBlock: "earliest"
|
|
838
|
+
}),
|
|
839
|
+
client.getLogs({
|
|
840
|
+
address: htlc,
|
|
841
|
+
event: SWAP_REFUNDED,
|
|
842
|
+
args: { preimageHash },
|
|
843
|
+
fromBlock: "earliest"
|
|
844
|
+
})
|
|
845
|
+
]);
|
|
846
|
+
const events = [];
|
|
847
|
+
for (const log of created) {
|
|
848
|
+
const args = log.args;
|
|
849
|
+
events.push({
|
|
850
|
+
kind: "created",
|
|
851
|
+
amount: args?.amount ?? 0n,
|
|
852
|
+
token: args?.token ?? "0x"
|
|
853
|
+
});
|
|
854
|
+
}
|
|
855
|
+
for (const log of redeemed) {
|
|
856
|
+
const preimage = log.args?.preimage;
|
|
857
|
+
if (preimage) events.push({ kind: "redeemed", preimage });
|
|
858
|
+
}
|
|
859
|
+
if (refunded.length > 0) events.push({ kind: "refunded" });
|
|
860
|
+
return events;
|
|
861
|
+
},
|
|
862
|
+
async getBlockTimeMs() {
|
|
863
|
+
const block = await client.getBlock();
|
|
864
|
+
return Number(block.timestamp) * 1e3;
|
|
865
|
+
},
|
|
866
|
+
watch(cb) {
|
|
867
|
+
return client.watchBlocks({ onBlock: () => cb() });
|
|
868
|
+
}
|
|
869
|
+
};
|
|
870
|
+
}
|
|
871
|
+
function createEvmRpcReader(rpcUrls) {
|
|
872
|
+
const urls = Array.isArray(rpcUrls) ? rpcUrls : [rpcUrls];
|
|
873
|
+
const transport = urls.length > 1 ? fallback(
|
|
874
|
+
urls.map((url) => http(url)),
|
|
875
|
+
{ rank: { interval: 6e4, sampleCount: 3, timeout: 1e3 } }
|
|
876
|
+
) : http(urls[0]);
|
|
877
|
+
const client = createPublicClient({ transport });
|
|
878
|
+
return evmReaderFromClient(client);
|
|
879
|
+
}
|
|
880
|
+
function defaultEvmReaders(overrides) {
|
|
881
|
+
const chainIds = /* @__PURE__ */ new Set([
|
|
882
|
+
...Object.keys(DEFAULT_EVM_RPCS).map(Number),
|
|
883
|
+
...Object.keys(overrides ?? {}).map(Number)
|
|
884
|
+
]);
|
|
885
|
+
const readers = /* @__PURE__ */ new Map();
|
|
886
|
+
for (const chainId of chainIds) {
|
|
887
|
+
const override = overrides?.[chainId];
|
|
888
|
+
const defaults = DEFAULT_EVM_RPCS[chainId] ?? [];
|
|
889
|
+
const urls = override ? [override, ...defaults] : defaults;
|
|
890
|
+
if (urls.length > 0) readers.set(chainId, createEvmRpcReader(urls));
|
|
891
|
+
}
|
|
892
|
+
return readers;
|
|
893
|
+
}
|
|
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
|
+
|
|
1199
|
+
// src/contracts/arkade-vhtlc.ts
|
|
1200
|
+
import { VHTLC, VHTLCContractHandler } from "@arkade-os/sdk";
|
|
1201
|
+
import { ripemd160 as ripemd1602 } from "@noble/hashes/legacy.js";
|
|
1202
|
+
import { hex as hex5 } from "@scure/base";
|
|
1203
|
+
function strip0x(value) {
|
|
1204
|
+
return value.startsWith("0x") ? value.slice(2) : value;
|
|
1205
|
+
}
|
|
1206
|
+
function xOnly(pubKeyHex) {
|
|
1207
|
+
const bytes = hex5.decode(strip0x(pubKeyHex));
|
|
1208
|
+
if (bytes.length === 33) return bytes.slice(1);
|
|
1209
|
+
if (bytes.length === 32) return bytes;
|
|
1210
|
+
throw new Error(`invalid public key length: ${bytes.length}`);
|
|
1211
|
+
}
|
|
1212
|
+
var seconds = (value) => ({ type: "seconds", value: BigInt(value) });
|
|
1213
|
+
function buildArkadeVhtlcRef(input) {
|
|
1214
|
+
const hashLockBytes = hex5.decode(strip0x(input.hashLock));
|
|
1215
|
+
const params = {
|
|
1216
|
+
sender: xOnly(input.senderPk),
|
|
1217
|
+
receiver: xOnly(input.receiverPk),
|
|
1218
|
+
server: xOnly(input.serverPk),
|
|
1219
|
+
preimageHash: hashLockBytes.length === 20 ? hashLockBytes : ripemd1602(hashLockBytes),
|
|
1220
|
+
refundLocktime: BigInt(input.refundLocktime),
|
|
1221
|
+
unilateralClaimDelay: seconds(input.unilateralClaimDelay),
|
|
1222
|
+
unilateralRefundDelay: seconds(input.unilateralRefundDelay),
|
|
1223
|
+
unilateralRefundWithoutReceiverDelay: seconds(
|
|
1224
|
+
input.unilateralRefundWithoutReceiverDelay
|
|
1225
|
+
)
|
|
1226
|
+
};
|
|
1227
|
+
const vhtlc = new VHTLC.Script(params);
|
|
1228
|
+
return {
|
|
1229
|
+
ledger: "arkade",
|
|
1230
|
+
script: hex5.encode(vhtlc.pkScript),
|
|
1231
|
+
address: input.address,
|
|
1232
|
+
preimageHash: strip0x(input.hashLock),
|
|
1233
|
+
expectedSats: input.expectedSats,
|
|
1234
|
+
params: VHTLCContractHandler.serializeParams(params)
|
|
1235
|
+
};
|
|
1236
|
+
}
|
|
1237
|
+
|
|
1238
|
+
// src/tracker/from-swap.ts
|
|
1239
|
+
var ms = (seconds2) => seconds2 * 1e3;
|
|
1240
|
+
var ensure0x = (value) => value.startsWith("0x") ? value : `0x${value}`;
|
|
1241
|
+
var strip0x2 = (value) => value.startsWith("0x") ? value.slice(2) : value;
|
|
1242
|
+
function arkadeLeg(r, address, expectedSats) {
|
|
1243
|
+
return buildArkadeVhtlcRef({
|
|
1244
|
+
senderPk: r.sender_pk,
|
|
1245
|
+
receiverPk: r.receiver_pk,
|
|
1246
|
+
serverPk: r.arkade_server_pk,
|
|
1247
|
+
hashLock: r.hash_lock,
|
|
1248
|
+
address,
|
|
1249
|
+
refundLocktime: r.vhtlc_refund_locktime,
|
|
1250
|
+
unilateralClaimDelay: r.unilateral_claim_delay,
|
|
1251
|
+
unilateralRefundDelay: r.unilateral_refund_delay,
|
|
1252
|
+
unilateralRefundWithoutReceiverDelay: r.unilateral_refund_without_receiver_delay,
|
|
1253
|
+
expectedSats
|
|
1254
|
+
});
|
|
1255
|
+
}
|
|
1256
|
+
function evmLeg(args) {
|
|
1257
|
+
return {
|
|
1258
|
+
ledger: "evm",
|
|
1259
|
+
chainId: args.chainId,
|
|
1260
|
+
htlc: ensure0x(args.htlc),
|
|
1261
|
+
preimageHash: ensure0x(args.hashLock),
|
|
1262
|
+
claimAddress: ensure0x(args.claimAddress),
|
|
1263
|
+
expectedAmount: BigInt(args.expectedSats),
|
|
1264
|
+
expectedToken: args.token ? ensure0x(args.token) : void 0
|
|
1265
|
+
};
|
|
1266
|
+
}
|
|
1267
|
+
function bitcoinLeg(address, hashLock, expectedSats) {
|
|
1268
|
+
return {
|
|
1269
|
+
ledger: "bitcoin",
|
|
1270
|
+
address,
|
|
1271
|
+
preimageHash: strip0x2(hashLock),
|
|
1272
|
+
expectedSats
|
|
1273
|
+
};
|
|
1274
|
+
}
|
|
1275
|
+
function swapToTracked(stored) {
|
|
1276
|
+
const r = stored.response;
|
|
1277
|
+
switch (r.direction) {
|
|
1278
|
+
// Client sends Arkade (funds the VHTLC with source_amount sats) and claims EVM.
|
|
1279
|
+
case "arkade_to_evm":
|
|
1280
|
+
return {
|
|
1281
|
+
swapId: r.id,
|
|
1282
|
+
clientHtlc: arkadeLeg(r, r.btc_vhtlc_address, Number(r.source_amount)),
|
|
1283
|
+
serverHtlc: evmLeg({
|
|
1284
|
+
chainId: r.evm_chain_id,
|
|
1285
|
+
htlc: r.evm_htlc_address,
|
|
1286
|
+
hashLock: r.hash_lock,
|
|
1287
|
+
claimAddress: r.client_evm_address,
|
|
1288
|
+
// the client claims the server's EVM HTLC
|
|
1289
|
+
expectedSats: r.evm_expected_sats,
|
|
1290
|
+
token: r.wbtc_address
|
|
1291
|
+
}),
|
|
1292
|
+
clientRefundLocktime: ms(r.vhtlc_refund_locktime),
|
|
1293
|
+
serverRefundLocktime: ms(r.evm_refund_locktime)
|
|
1294
|
+
};
|
|
1295
|
+
// Client sends EVM and claims the Arkade VHTLC (server funds it with target_amount sats).
|
|
1296
|
+
case "evm_to_arkade":
|
|
1297
|
+
return {
|
|
1298
|
+
swapId: r.id,
|
|
1299
|
+
clientHtlc: evmLeg({
|
|
1300
|
+
chainId: r.evm_chain_id,
|
|
1301
|
+
htlc: r.evm_htlc_address,
|
|
1302
|
+
hashLock: r.hash_lock,
|
|
1303
|
+
claimAddress: r.server_evm_address,
|
|
1304
|
+
// the server claims the client's EVM HTLC
|
|
1305
|
+
expectedSats: r.evm_expected_sats
|
|
1306
|
+
// evm_to_arkade doesn't expose the locked token — verify amount only.
|
|
1307
|
+
}),
|
|
1308
|
+
serverHtlc: arkadeLeg(r, r.btc_vhtlc_address, Number(r.target_amount)),
|
|
1309
|
+
clientRefundLocktime: ms(r.evm_refund_locktime),
|
|
1310
|
+
serverRefundLocktime: ms(r.vhtlc_refund_locktime)
|
|
1311
|
+
};
|
|
1312
|
+
// Client sends BTC (funds the on-chain HTLC with source_amount sats) and claims EVM.
|
|
1313
|
+
case "bitcoin_to_evm":
|
|
1314
|
+
return {
|
|
1315
|
+
swapId: r.id,
|
|
1316
|
+
clientHtlc: bitcoinLeg(
|
|
1317
|
+
r.btc_htlc_address,
|
|
1318
|
+
r.evm_hash_lock,
|
|
1319
|
+
Number(r.source_amount)
|
|
1320
|
+
),
|
|
1321
|
+
serverHtlc: evmLeg({
|
|
1322
|
+
chainId: r.evm_chain_id,
|
|
1323
|
+
htlc: r.evm_htlc_address,
|
|
1324
|
+
hashLock: r.evm_hash_lock,
|
|
1325
|
+
claimAddress: r.client_evm_address,
|
|
1326
|
+
// the client claims the server's EVM HTLC
|
|
1327
|
+
expectedSats: r.evm_expected_sats,
|
|
1328
|
+
token: r.wbtc_address
|
|
1329
|
+
}),
|
|
1330
|
+
clientRefundLocktime: ms(r.btc_refund_locktime),
|
|
1331
|
+
serverRefundLocktime: ms(r.evm_refund_locktime)
|
|
1332
|
+
};
|
|
1333
|
+
// Client sends EVM and claims the BTC HTLC (server funds it with target_amount sats).
|
|
1334
|
+
case "evm_to_bitcoin":
|
|
1335
|
+
return {
|
|
1336
|
+
swapId: r.id,
|
|
1337
|
+
clientHtlc: evmLeg({
|
|
1338
|
+
chainId: r.evm_chain_id,
|
|
1339
|
+
htlc: r.evm_htlc_address,
|
|
1340
|
+
hashLock: r.evm_hash_lock,
|
|
1341
|
+
claimAddress: r.server_evm_address,
|
|
1342
|
+
// the server claims the client's EVM HTLC
|
|
1343
|
+
expectedSats: r.evm_expected_sats,
|
|
1344
|
+
token: r.wbtc_address
|
|
1345
|
+
}),
|
|
1346
|
+
serverHtlc: bitcoinLeg(
|
|
1347
|
+
r.btc_htlc_address,
|
|
1348
|
+
r.evm_hash_lock,
|
|
1349
|
+
Number(r.target_amount)
|
|
1350
|
+
),
|
|
1351
|
+
clientRefundLocktime: ms(r.evm_refund_locktime),
|
|
1352
|
+
serverRefundLocktime: ms(r.btc_refund_locktime)
|
|
1353
|
+
};
|
|
1354
|
+
// Client sends BTC (funds the on-chain HTLC) and claims the Arkade VHTLC (the
|
|
1355
|
+
// server funds it). Unlike the other Arkade directions, the VHTLC's receiver is
|
|
1356
|
+
// the client's own key (not in the response) and its funder is `server_vhtlc_pk`;
|
|
1357
|
+
// the hash lock is a 20-byte HASH160, which buildArkadeVhtlcRef takes as-is.
|
|
1358
|
+
case "btc_to_arkade":
|
|
1359
|
+
return {
|
|
1360
|
+
swapId: r.id,
|
|
1361
|
+
clientHtlc: bitcoinLeg(
|
|
1362
|
+
r.btc_htlc_address,
|
|
1363
|
+
r.hash_lock,
|
|
1364
|
+
Number(r.source_amount)
|
|
1365
|
+
),
|
|
1366
|
+
serverHtlc: buildArkadeVhtlcRef({
|
|
1367
|
+
senderPk: r.server_vhtlc_pk,
|
|
1368
|
+
// the server funds the VHTLC
|
|
1369
|
+
receiverPk: stored.publicKey,
|
|
1370
|
+
// the client claims it
|
|
1371
|
+
serverPk: r.arkade_server_pk,
|
|
1372
|
+
hashLock: r.hash_lock,
|
|
1373
|
+
address: r.arkade_vhtlc_address,
|
|
1374
|
+
refundLocktime: r.vhtlc_refund_locktime,
|
|
1375
|
+
unilateralClaimDelay: r.unilateral_claim_delay,
|
|
1376
|
+
unilateralRefundDelay: r.unilateral_refund_delay,
|
|
1377
|
+
unilateralRefundWithoutReceiverDelay: r.unilateral_refund_without_receiver_delay,
|
|
1378
|
+
expectedSats: Number(r.target_amount)
|
|
1379
|
+
}),
|
|
1380
|
+
clientRefundLocktime: ms(r.btc_refund_locktime),
|
|
1381
|
+
serverRefundLocktime: ms(r.vhtlc_refund_locktime)
|
|
1382
|
+
};
|
|
1383
|
+
// ─── Lightning: one on-chain leg, one off-chain LN payment ───────────────
|
|
1384
|
+
// Receive-on-Lightning: the client funds an on-chain HTLC and is paid over
|
|
1385
|
+
// Lightning. No server leg to watch — the server sweeping the client's leg
|
|
1386
|
+
// (with the preimage it got by paying the invoice) is the done signal.
|
|
1387
|
+
// Client funds the Arkade VHTLC; Boltz pays the invoice. The deposit is
|
|
1388
|
+
// `boltz_amount_sats` (source minus fees) — the amount actually locked, not
|
|
1389
|
+
// `source_amount` — else the client's own funding reads as under-funded.
|
|
1390
|
+
case "arkade_to_lightning":
|
|
1391
|
+
return {
|
|
1392
|
+
swapId: r.id,
|
|
1393
|
+
clientHtlc: arkadeLeg(
|
|
1394
|
+
r,
|
|
1395
|
+
r.arkade_vhtlc_address,
|
|
1396
|
+
Number(r.boltz_amount_sats)
|
|
1397
|
+
),
|
|
1398
|
+
clientRefundLocktime: ms(r.vhtlc_refund_locktime),
|
|
1399
|
+
serverRefundLocktime: 0
|
|
1400
|
+
// no on-chain server leg
|
|
1401
|
+
};
|
|
1402
|
+
// Client funds the EVM HTLC (server claims it after paying the invoice).
|
|
1403
|
+
case "evm_to_lightning":
|
|
1404
|
+
return {
|
|
1405
|
+
swapId: r.id,
|
|
1406
|
+
clientHtlc: evmLeg({
|
|
1407
|
+
chainId: r.evm_chain_id,
|
|
1408
|
+
htlc: r.evm_htlc_address,
|
|
1409
|
+
hashLock: r.hash_lock,
|
|
1410
|
+
claimAddress: r.server_evm_address,
|
|
1411
|
+
// the server claims the client's leg
|
|
1412
|
+
expectedSats: r.evm_expected_sats
|
|
1413
|
+
// evm_to_lightning doesn't expose the locked token — verify amount only.
|
|
1414
|
+
}),
|
|
1415
|
+
clientRefundLocktime: ms(r.evm_refund_locktime),
|
|
1416
|
+
serverRefundLocktime: 0
|
|
1417
|
+
// no on-chain server leg
|
|
1418
|
+
};
|
|
1419
|
+
// Pay-on-Lightning: the client pays a Lightning invoice (off-chain, nothing to
|
|
1420
|
+
// watch) and claims an on-chain HTLC. No client-funded leg — the client's
|
|
1421
|
+
// claim completes the swap; a hold invoice that never settles auto-unwinds.
|
|
1422
|
+
// Client pays the invoice, then claims the Arkade VHTLC (target_amount).
|
|
1423
|
+
case "lightning_to_arkade":
|
|
1424
|
+
return {
|
|
1425
|
+
swapId: r.id,
|
|
1426
|
+
serverHtlc: arkadeLeg(
|
|
1427
|
+
r,
|
|
1428
|
+
r.arkade_vhtlc_address,
|
|
1429
|
+
Number(r.target_amount)
|
|
1430
|
+
),
|
|
1431
|
+
clientRefundLocktime: 0,
|
|
1432
|
+
// no on-chain client leg
|
|
1433
|
+
serverRefundLocktime: ms(r.vhtlc_refund_locktime)
|
|
1434
|
+
};
|
|
1435
|
+
// Client pays the invoice, then claims the EVM HTLC.
|
|
1436
|
+
case "lightning_to_evm":
|
|
1437
|
+
return {
|
|
1438
|
+
swapId: r.id,
|
|
1439
|
+
serverHtlc: evmLeg({
|
|
1440
|
+
chainId: r.evm_chain_id,
|
|
1441
|
+
htlc: r.evm_htlc_address,
|
|
1442
|
+
hashLock: r.hash_lock,
|
|
1443
|
+
claimAddress: r.client_evm_address,
|
|
1444
|
+
// the client claims the server's leg
|
|
1445
|
+
expectedSats: r.evm_expected_sats,
|
|
1446
|
+
token: r.wbtc_address
|
|
1447
|
+
}),
|
|
1448
|
+
clientRefundLocktime: 0,
|
|
1449
|
+
// no on-chain client leg
|
|
1450
|
+
serverRefundLocktime: ms(r.evm_refund_locktime)
|
|
1451
|
+
};
|
|
1452
|
+
default:
|
|
1453
|
+
return void 0;
|
|
1454
|
+
}
|
|
1455
|
+
}
|
|
1456
|
+
|
|
1457
|
+
// src/tracker/swap-tracker.ts
|
|
1458
|
+
var SwapTracker = class {
|
|
1459
|
+
#managers;
|
|
1460
|
+
#swaps = /* @__PURE__ */ new Map();
|
|
1461
|
+
/** Last actions emitted per swap — for dedupe and the subscribe-time snapshot. */
|
|
1462
|
+
#lastActions = /* @__PURE__ */ new Map();
|
|
1463
|
+
#subscribers = /* @__PURE__ */ new Set();
|
|
1464
|
+
#refreshIntervalMs;
|
|
1465
|
+
#eventUnsubs = [];
|
|
1466
|
+
#timer;
|
|
1467
|
+
constructor(managers, options) {
|
|
1468
|
+
this.#managers = managers;
|
|
1469
|
+
this.#refreshIntervalMs = options?.refreshIntervalMs ?? 0;
|
|
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
|
+
}
|
|
1482
|
+
/** Register each swap's on-chain leg(s), subscribe to manager events, and seed state. */
|
|
1483
|
+
async startTracking(swaps) {
|
|
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
|
+
}
|
|
1491
|
+
this.#swaps.set(swap.swapId, swap);
|
|
1492
|
+
for (const leg of legsOf(swap)) await this.#managerFor(leg).register(leg);
|
|
1493
|
+
}
|
|
1494
|
+
for (const manager of new Set(this.#managers.values())) {
|
|
1495
|
+
this.#eventUnsubs.push(manager.onEvent(() => this.#recomputeAll()));
|
|
1496
|
+
}
|
|
1497
|
+
await this.#refreshManagers();
|
|
1498
|
+
this.#recomputeAll();
|
|
1499
|
+
if (this.#refreshIntervalMs > 0)
|
|
1500
|
+
this.#timer = setInterval(
|
|
1501
|
+
() => void this.#tick(),
|
|
1502
|
+
this.#refreshIntervalMs
|
|
1503
|
+
);
|
|
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
|
+
}
|
|
1538
|
+
async #tick() {
|
|
1539
|
+
await this.#refreshManagers();
|
|
1540
|
+
this.#recomputeAll();
|
|
1541
|
+
}
|
|
1542
|
+
/**
|
|
1543
|
+
* Re-poll the managers that have a registered leg — seeding/advancing their
|
|
1544
|
+
* clocks and reconciling observations. Managers with nothing tracked are
|
|
1545
|
+
* skipped: refreshing them still hits their clock source (e.g. Arkade/Bitcoin
|
|
1546
|
+
* MTP via the API), so a swap that doesn't touch a ledger can't fail on that
|
|
1547
|
+
* ledger's endpoint being down (the default client builds every manager).
|
|
1548
|
+
*/
|
|
1549
|
+
async #refreshManagers() {
|
|
1550
|
+
const active = /* @__PURE__ */ new Set();
|
|
1551
|
+
for (const swap of this.#swaps.values())
|
|
1552
|
+
for (const leg of legsOf(swap)) active.add(leg.ledger);
|
|
1553
|
+
const managers = new Set(
|
|
1554
|
+
[...active].map((ledger) => this.#managers.get(ledger)).filter((m) => m !== void 0)
|
|
1555
|
+
);
|
|
1556
|
+
await Promise.all([...managers].map((manager) => manager.refresh()));
|
|
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
|
+
}
|
|
1577
|
+
/** Notify `cb` of the current action for each tracked swap, then on every change. */
|
|
1578
|
+
subscribeToActions(cb) {
|
|
1579
|
+
this.#subscribers.add(cb);
|
|
1580
|
+
for (const [swapId, actions] of this.#lastActions) cb(swapId, actions);
|
|
1581
|
+
return () => this.#subscribers.delete(cb);
|
|
1582
|
+
}
|
|
1583
|
+
/**
|
|
1584
|
+
* Stop tracking: unregister every still-tracked leg (releasing its manager's
|
|
1585
|
+
* watch — e.g. the EVM per-chain block watch that only `unregister` tears down),
|
|
1586
|
+
* drop the poll timer, and detach event listeners + subscribers. Mirrors
|
|
1587
|
+
* `startTracking`'s `register`, so no manager keeps watching a stopped tracker's
|
|
1588
|
+
* swaps. The managers themselves are not owned here, so they are unregistered but
|
|
1589
|
+
* never disposed.
|
|
1590
|
+
*/
|
|
1591
|
+
stop() {
|
|
1592
|
+
if (this.#timer) clearInterval(this.#timer);
|
|
1593
|
+
this.#timer = void 0;
|
|
1594
|
+
for (const unsub of this.#eventUnsubs) unsub();
|
|
1595
|
+
this.#eventUnsubs = [];
|
|
1596
|
+
for (const swap of this.#swaps.values())
|
|
1597
|
+
for (const leg of legsOf(swap))
|
|
1598
|
+
void this.#managerFor(leg).unregister(leg);
|
|
1599
|
+
this.#swaps.clear();
|
|
1600
|
+
this.#subscribers.clear();
|
|
1601
|
+
}
|
|
1602
|
+
#managerFor(ref) {
|
|
1603
|
+
const manager = this.#managers.get(ref.ledger);
|
|
1604
|
+
if (!manager)
|
|
1605
|
+
throw new Error(`no ContractManager for ledger '${ref.ledger}'`);
|
|
1606
|
+
return manager;
|
|
1607
|
+
}
|
|
1608
|
+
#recomputeAll() {
|
|
1609
|
+
for (const swap of this.#swaps.values()) this.#recompute(swap);
|
|
1610
|
+
}
|
|
1611
|
+
#recompute(swap, force = false) {
|
|
1612
|
+
let clientHtlc;
|
|
1613
|
+
let serverHtlc;
|
|
1614
|
+
let clientChainNow = 0;
|
|
1615
|
+
let serverChainNow = 0;
|
|
1616
|
+
if (swap.clientHtlc) {
|
|
1617
|
+
const m = this.#managerFor(swap.clientHtlc);
|
|
1618
|
+
clientHtlc = m.getState(swap.clientHtlc);
|
|
1619
|
+
const now = m.chainNow(swap.clientHtlc);
|
|
1620
|
+
if (clientHtlc === void 0 || now === void 0) return;
|
|
1621
|
+
clientChainNow = now;
|
|
1622
|
+
}
|
|
1623
|
+
if (swap.serverHtlc) {
|
|
1624
|
+
const m = this.#managerFor(swap.serverHtlc);
|
|
1625
|
+
serverHtlc = m.getState(swap.serverHtlc);
|
|
1626
|
+
const now = m.chainNow(swap.serverHtlc);
|
|
1627
|
+
if (serverHtlc === void 0 || now === void 0) return;
|
|
1628
|
+
serverChainNow = now;
|
|
1629
|
+
}
|
|
1630
|
+
const status = deriveSwapStatus({ clientHtlc, serverHtlc });
|
|
1631
|
+
if (status === void 0) return;
|
|
1632
|
+
const actions = deriveSwapActions({
|
|
1633
|
+
status,
|
|
1634
|
+
clientChainNow,
|
|
1635
|
+
serverChainNow,
|
|
1636
|
+
clientRefundLocktime: swap.clientRefundLocktime,
|
|
1637
|
+
serverRefundLocktime: swap.serverRefundLocktime,
|
|
1638
|
+
// Pay-on-Lightning swaps have no client-funded on-chain leg.
|
|
1639
|
+
clientFunds: swap.clientHtlc !== void 0
|
|
1640
|
+
});
|
|
1641
|
+
const previous = this.#lastActions.get(swap.swapId);
|
|
1642
|
+
if (!force && previous && JSON.stringify(previous) === JSON.stringify(actions))
|
|
1643
|
+
return;
|
|
1644
|
+
this.#lastActions.set(swap.swapId, actions);
|
|
1645
|
+
for (const cb of this.#subscribers) cb(swap.swapId, actions);
|
|
1646
|
+
if (actions.recommended === "none") this.#untrack(swap);
|
|
1647
|
+
}
|
|
1648
|
+
#untrack(swap) {
|
|
1649
|
+
this.#swaps.delete(swap.swapId);
|
|
1650
|
+
for (const leg of legsOf(swap)) void this.#managerFor(leg).unregister(leg);
|
|
1651
|
+
}
|
|
1652
|
+
};
|
|
1653
|
+
function legsOf(swap) {
|
|
1654
|
+
const legs = [];
|
|
1655
|
+
if (swap.clientHtlc) legs.push(swap.clientHtlc);
|
|
1656
|
+
if (swap.serverHtlc) legs.push(swap.serverHtlc);
|
|
1657
|
+
return legs;
|
|
1658
|
+
}
|
|
1659
|
+
|
|
1660
|
+
// src/client.ts
|
|
1661
|
+
var Client = class {
|
|
1662
|
+
/** The wrapped legacy client. Calls are forwarded here until migrated. */
|
|
1663
|
+
#legacy;
|
|
1664
|
+
/** Tracking configuration; managers are built lazily from it on first use. */
|
|
1665
|
+
#tracking;
|
|
1666
|
+
/** Per-ledger monitors, resolved once from {@link #tracking}. */
|
|
1667
|
+
#managers;
|
|
1668
|
+
#tracker;
|
|
1669
|
+
/** Hint-driven auto-claim worker; only built when `withAutoClaim` opted in. */
|
|
1670
|
+
#worker;
|
|
1671
|
+
#started = false;
|
|
1672
|
+
constructor(...args) {
|
|
1673
|
+
if (args[0] instanceof LegacyClient) {
|
|
1674
|
+
this.#legacy = args[0];
|
|
1675
|
+
this.#tracking = args[1] ?? {
|
|
1676
|
+
enabled: false
|
|
1677
|
+
};
|
|
1678
|
+
} else {
|
|
1679
|
+
this.#legacy = new LegacyClient(
|
|
1680
|
+
...args
|
|
1681
|
+
);
|
|
1682
|
+
this.#tracking = { enabled: false };
|
|
1683
|
+
}
|
|
1684
|
+
}
|
|
1685
|
+
/** Start building a {@link Client}. */
|
|
1686
|
+
static builder() {
|
|
1687
|
+
return new ClientBuilder();
|
|
1688
|
+
}
|
|
1689
|
+
/** Escape hatch: the underlying legacy client, during migration. */
|
|
1690
|
+
get legacy() {
|
|
1691
|
+
return this.#legacy;
|
|
1692
|
+
}
|
|
1693
|
+
// --- delegated legacy surface (getters) ---
|
|
1694
|
+
/** Delegated to the legacy client (migration checkpoint). */
|
|
1695
|
+
get api() {
|
|
1696
|
+
return this.#legacy.api;
|
|
1697
|
+
}
|
|
1698
|
+
/** Delegated to the legacy client (migration checkpoint). */
|
|
1699
|
+
get baseUrl() {
|
|
1700
|
+
return this.#legacy.baseUrl;
|
|
1701
|
+
}
|
|
1702
|
+
/** Delegated to the legacy client (migration checkpoint). */
|
|
1703
|
+
get cctpInbound() {
|
|
1704
|
+
return this.#legacy.cctpInbound;
|
|
1705
|
+
}
|
|
1706
|
+
/** Delegated to the legacy client (migration checkpoint). */
|
|
1707
|
+
get swapStorage() {
|
|
1708
|
+
return this.#legacy.swapStorage;
|
|
1709
|
+
}
|
|
1710
|
+
// --- delegated legacy surface (methods) ---
|
|
1711
|
+
/** Delegated to the legacy client (migration checkpoint). */
|
|
1712
|
+
subscribeToSwaps(...args) {
|
|
1713
|
+
return this.#legacy.subscribeToSwaps(...args);
|
|
1714
|
+
}
|
|
1715
|
+
/** Delegated to the legacy client (migration checkpoint). */
|
|
1716
|
+
unsubscribeFromSwaps(...args) {
|
|
1717
|
+
return this.#legacy.unsubscribeFromSwaps(...args);
|
|
1718
|
+
}
|
|
1719
|
+
/** Delegated to the legacy client (migration checkpoint). */
|
|
1720
|
+
closeSwapStatusSocket(...args) {
|
|
1721
|
+
return this.#legacy.closeSwapStatusSocket(...args);
|
|
1722
|
+
}
|
|
1723
|
+
/** Delegated to the legacy client (migration checkpoint). */
|
|
1724
|
+
getMnemonic(...args) {
|
|
1725
|
+
return this.#legacy.getMnemonic(...args);
|
|
1726
|
+
}
|
|
1727
|
+
/** Delegated to the legacy client (migration checkpoint). */
|
|
1728
|
+
loadMnemonic(...args) {
|
|
1729
|
+
return this.#legacy.loadMnemonic(...args);
|
|
1730
|
+
}
|
|
1731
|
+
/** Delegated to the legacy client (migration checkpoint). */
|
|
1732
|
+
getNostrKeyHex(...args) {
|
|
1733
|
+
return this.#legacy.getNostrKeyHex(...args);
|
|
1734
|
+
}
|
|
1735
|
+
/** Delegated to the legacy client (migration checkpoint). */
|
|
1736
|
+
getUserIdXpub(...args) {
|
|
1737
|
+
return this.#legacy.getUserIdXpub(...args);
|
|
1738
|
+
}
|
|
1739
|
+
/** Delegated to the legacy client (migration checkpoint). */
|
|
1740
|
+
deriveSwapParams(...args) {
|
|
1741
|
+
return this.#legacy.deriveSwapParams(...args);
|
|
1742
|
+
}
|
|
1743
|
+
/** Delegated to the legacy client (migration checkpoint). */
|
|
1744
|
+
deriveSwapParamsAtIndex(...args) {
|
|
1745
|
+
return this.#legacy.deriveSwapParamsAtIndex(...args);
|
|
1746
|
+
}
|
|
1747
|
+
/** Delegated to the legacy client (migration checkpoint). */
|
|
1748
|
+
getEvmAddress(...args) {
|
|
1749
|
+
return this.#legacy.getEvmAddress(...args);
|
|
1750
|
+
}
|
|
1751
|
+
/** Delegated to the legacy client (migration checkpoint). */
|
|
1752
|
+
getKeyIndex(...args) {
|
|
1753
|
+
return this.#legacy.getKeyIndex(...args);
|
|
1754
|
+
}
|
|
1755
|
+
/** Delegated to the legacy client (migration checkpoint). */
|
|
1756
|
+
setKeyIndex(...args) {
|
|
1757
|
+
return this.#legacy.setKeyIndex(...args);
|
|
1758
|
+
}
|
|
1759
|
+
/** Delegated to the legacy client (migration checkpoint). */
|
|
1760
|
+
getStatus(...args) {
|
|
1761
|
+
return this.#legacy.getStatus(...args);
|
|
1762
|
+
}
|
|
1763
|
+
/** Delegated to the legacy client (migration checkpoint). */
|
|
1764
|
+
healthCheck(...args) {
|
|
1765
|
+
return this.#legacy.healthCheck(...args);
|
|
1766
|
+
}
|
|
1767
|
+
/** Delegated to the legacy client (migration checkpoint). */
|
|
1768
|
+
getVersion(...args) {
|
|
1769
|
+
return this.#legacy.getVersion(...args);
|
|
1770
|
+
}
|
|
1771
|
+
/** Delegated to the legacy client (migration checkpoint). */
|
|
1772
|
+
getSupportAgents(...args) {
|
|
1773
|
+
return this.#legacy.getSupportAgents(...args);
|
|
1774
|
+
}
|
|
1775
|
+
/** Delegated to the legacy client (migration checkpoint). */
|
|
1776
|
+
getMtp(...args) {
|
|
1777
|
+
return this.#legacy.getMtp(...args);
|
|
1778
|
+
}
|
|
1779
|
+
/** Delegated to the legacy client (migration checkpoint). */
|
|
1780
|
+
getTokens(...args) {
|
|
1781
|
+
return this.#legacy.getTokens(...args);
|
|
1782
|
+
}
|
|
1783
|
+
/** Delegated to the legacy client (migration checkpoint). */
|
|
1784
|
+
getSwapPairs(...args) {
|
|
1785
|
+
return this.#legacy.getSwapPairs(...args);
|
|
1786
|
+
}
|
|
1787
|
+
/** Delegated to the legacy client (migration checkpoint). */
|
|
1788
|
+
getChainConfig(...args) {
|
|
1789
|
+
return this.#legacy.getChainConfig(...args);
|
|
1790
|
+
}
|
|
1791
|
+
/** Delegated to the legacy client (migration checkpoint). */
|
|
1792
|
+
getReferralFee(...args) {
|
|
1793
|
+
return this.#legacy.getReferralFee(...args);
|
|
1794
|
+
}
|
|
1795
|
+
/** Delegated to the legacy client (migration checkpoint). */
|
|
1796
|
+
getNetworkFees(...args) {
|
|
1797
|
+
return this.#legacy.getNetworkFees(...args);
|
|
1798
|
+
}
|
|
1799
|
+
/** Delegated to the legacy client (migration checkpoint). */
|
|
1800
|
+
getDexQuote(...args) {
|
|
1801
|
+
return this.#legacy.getDexQuote(...args);
|
|
1802
|
+
}
|
|
1803
|
+
/** Delegated to the legacy client (migration checkpoint). */
|
|
1804
|
+
composeQuote(...args) {
|
|
1805
|
+
return this.#legacy.composeQuote(...args);
|
|
1806
|
+
}
|
|
1807
|
+
/** Delegated to the legacy client (migration checkpoint). */
|
|
1808
|
+
getQuote(...args) {
|
|
1809
|
+
return this.#legacy.getQuote(...args);
|
|
1810
|
+
}
|
|
1811
|
+
/** Delegated to the legacy client (migration checkpoint). */
|
|
1812
|
+
getSwap(...args) {
|
|
1813
|
+
return this.#legacy.getSwap(...args);
|
|
1814
|
+
}
|
|
1815
|
+
/** Delegated to the legacy client (migration checkpoint). */
|
|
1816
|
+
getBulkStatus(...args) {
|
|
1817
|
+
return this.#legacy.getBulkStatus(...args);
|
|
1818
|
+
}
|
|
1819
|
+
/** Delegated to the legacy client (migration checkpoint). */
|
|
1820
|
+
getStoredSwap(...args) {
|
|
1821
|
+
return this.#legacy.getStoredSwap(...args);
|
|
1822
|
+
}
|
|
1823
|
+
/** Delegated to the legacy client (migration checkpoint). */
|
|
1824
|
+
listAllSwaps(...args) {
|
|
1825
|
+
return this.#legacy.listAllSwaps(...args);
|
|
1826
|
+
}
|
|
1827
|
+
/** Delegated to the legacy client (migration checkpoint). */
|
|
1828
|
+
deleteSwap(...args) {
|
|
1829
|
+
return this.#legacy.deleteSwap(...args);
|
|
1830
|
+
}
|
|
1831
|
+
/** Delegated to the legacy client (migration checkpoint). */
|
|
1832
|
+
clearSwapStorage(...args) {
|
|
1833
|
+
return this.#legacy.clearSwapStorage(...args);
|
|
1834
|
+
}
|
|
1835
|
+
/** Delegated to the legacy client (migration checkpoint). */
|
|
1836
|
+
recoverSwaps(...args) {
|
|
1837
|
+
return this.#legacy.recoverSwaps(...args);
|
|
1838
|
+
}
|
|
1839
|
+
/** Delegated to the legacy client (migration checkpoint). */
|
|
1840
|
+
recoverAllSwaps(...args) {
|
|
1841
|
+
return this.#legacy.recoverAllSwaps(...args);
|
|
1842
|
+
}
|
|
1843
|
+
/** Delegated to the legacy client (migration checkpoint). */
|
|
1844
|
+
amountsForSwap(...args) {
|
|
1845
|
+
return this.#legacy.amountsForSwap(...args);
|
|
1846
|
+
}
|
|
1847
|
+
/** Delegated to the legacy client (migration checkpoint). */
|
|
1848
|
+
claim(...args) {
|
|
1849
|
+
return this.#legacy.claim(...args);
|
|
1850
|
+
}
|
|
1851
|
+
/** Delegated to the legacy client (migration checkpoint). */
|
|
1852
|
+
claimViaGasless(...args) {
|
|
1853
|
+
return this.#legacy.claimViaGasless(...args);
|
|
1854
|
+
}
|
|
1855
|
+
/** Delegated to the legacy client (migration checkpoint). */
|
|
1856
|
+
claimArkade(...args) {
|
|
1857
|
+
return this.#legacy.claimArkade(...args);
|
|
1858
|
+
}
|
|
1859
|
+
/** Delegated to the legacy client (migration checkpoint). */
|
|
1860
|
+
continueArkadeClaimSwap(...args) {
|
|
1861
|
+
return this.#legacy.continueArkadeClaimSwap(...args);
|
|
1862
|
+
}
|
|
1863
|
+
/** Delegated to the legacy client (migration checkpoint). */
|
|
1864
|
+
refundSwap(...args) {
|
|
1865
|
+
return this.#legacy.refundSwap(...args);
|
|
1866
|
+
}
|
|
1867
|
+
/** Delegated to the legacy client (migration checkpoint). */
|
|
1868
|
+
getCollabRefundEvmParams(...args) {
|
|
1869
|
+
return this.#legacy.getCollabRefundEvmParams(...args);
|
|
1870
|
+
}
|
|
1871
|
+
/** Delegated to the legacy client (migration checkpoint). */
|
|
1872
|
+
buildCollabRefundEvmTypedData(...args) {
|
|
1873
|
+
return this.#legacy.buildCollabRefundEvmTypedData(...args);
|
|
1874
|
+
}
|
|
1875
|
+
/** Delegated to the legacy client (migration checkpoint). */
|
|
1876
|
+
collabRefundEvmSwap(...args) {
|
|
1877
|
+
return this.#legacy.collabRefundEvmSwap(...args);
|
|
1878
|
+
}
|
|
1879
|
+
/** Delegated to the legacy client (migration checkpoint). */
|
|
1880
|
+
submitCollabRefundEvm(...args) {
|
|
1881
|
+
return this.#legacy.submitCollabRefundEvm(...args);
|
|
1882
|
+
}
|
|
1883
|
+
/** Delegated to the legacy client (migration checkpoint). */
|
|
1884
|
+
createSwap(...args) {
|
|
1885
|
+
return this.#trackAfterCreate(this.#legacy.createSwap(...args));
|
|
1886
|
+
}
|
|
1887
|
+
/** Delegated to the legacy client (migration checkpoint). */
|
|
1888
|
+
createArkadeToEvmSwapGeneric(...args) {
|
|
1889
|
+
return this.#trackAfterCreate(
|
|
1890
|
+
this.#legacy.createArkadeToEvmSwapGeneric(...args)
|
|
1891
|
+
);
|
|
1892
|
+
}
|
|
1893
|
+
/** Delegated to the legacy client (migration checkpoint). */
|
|
1894
|
+
createLightningToEvmSwapGeneric(...args) {
|
|
1895
|
+
return this.#trackAfterCreate(
|
|
1896
|
+
this.#legacy.createLightningToEvmSwapGeneric(...args)
|
|
1897
|
+
);
|
|
1898
|
+
}
|
|
1899
|
+
/** Delegated to the legacy client (migration checkpoint). */
|
|
1900
|
+
createBitcoinToEvmSwap(...args) {
|
|
1901
|
+
return this.#trackAfterCreate(this.#legacy.createBitcoinToEvmSwap(...args));
|
|
1902
|
+
}
|
|
1903
|
+
/** Delegated to the legacy client (migration checkpoint). */
|
|
1904
|
+
createBitcoinToArkadeSwap(...args) {
|
|
1905
|
+
return this.#trackAfterCreate(
|
|
1906
|
+
this.#legacy.createBitcoinToArkadeSwap(...args)
|
|
1907
|
+
);
|
|
1908
|
+
}
|
|
1909
|
+
/** Delegated to the legacy client (migration checkpoint). */
|
|
1910
|
+
createLightningToArkadeSwap(...args) {
|
|
1911
|
+
return this.#trackAfterCreate(
|
|
1912
|
+
this.#legacy.createLightningToArkadeSwap(...args)
|
|
1913
|
+
);
|
|
1914
|
+
}
|
|
1915
|
+
/** Delegated to the legacy client (migration checkpoint). */
|
|
1916
|
+
createArkadeToLightningSwap(...args) {
|
|
1917
|
+
return this.#trackAfterCreate(
|
|
1918
|
+
this.#legacy.createArkadeToLightningSwap(...args)
|
|
1919
|
+
);
|
|
1920
|
+
}
|
|
1921
|
+
/** Delegated to the legacy client (migration checkpoint). */
|
|
1922
|
+
getArkadeToLightningQuote(...args) {
|
|
1923
|
+
return this.#legacy.getArkadeToLightningQuote(...args);
|
|
1924
|
+
}
|
|
1925
|
+
/** Delegated to the legacy client (migration checkpoint). */
|
|
1926
|
+
retryArkadeToLightningSwap(...args) {
|
|
1927
|
+
return this.#trackAfterRetry(
|
|
1928
|
+
this.#legacy.retryArkadeToLightningSwap(...args)
|
|
1929
|
+
);
|
|
1930
|
+
}
|
|
1931
|
+
/** Delegated to the legacy client (migration checkpoint). */
|
|
1932
|
+
createEvmToArkadeSwapGeneric(...args) {
|
|
1933
|
+
return this.#trackAfterCreate(
|
|
1934
|
+
this.#legacy.createEvmToArkadeSwapGeneric(...args)
|
|
1935
|
+
);
|
|
1936
|
+
}
|
|
1937
|
+
/** Delegated to the legacy client (migration checkpoint). */
|
|
1938
|
+
createEvmToBitcoinSwap(...args) {
|
|
1939
|
+
return this.#trackAfterCreate(this.#legacy.createEvmToBitcoinSwap(...args));
|
|
1940
|
+
}
|
|
1941
|
+
/** Delegated to the legacy client (migration checkpoint). */
|
|
1942
|
+
createEvmToLightningSwapGeneric(...args) {
|
|
1943
|
+
return this.#trackAfterCreate(
|
|
1944
|
+
this.#legacy.createEvmToLightningSwapGeneric(...args)
|
|
1945
|
+
);
|
|
1946
|
+
}
|
|
1947
|
+
/** Delegated to the legacy client (migration checkpoint). */
|
|
1948
|
+
getCoordinatorFundingCallDataPermit2(...args) {
|
|
1949
|
+
return this.#legacy.getCoordinatorFundingCallDataPermit2(...args);
|
|
1950
|
+
}
|
|
1951
|
+
/** Delegated to the legacy client (migration checkpoint). */
|
|
1952
|
+
getPermit2FundingParamsUnsigned(...args) {
|
|
1953
|
+
return this.#legacy.getPermit2FundingParamsUnsigned(...args);
|
|
1954
|
+
}
|
|
1955
|
+
/** Delegated to the legacy client (migration checkpoint). */
|
|
1956
|
+
fundSwap(...args) {
|
|
1957
|
+
return this.#legacy.fundSwap(...args);
|
|
1958
|
+
}
|
|
1959
|
+
/** Delegated to the legacy client (migration checkpoint). */
|
|
1960
|
+
refundEvmWithSigner(...args) {
|
|
1961
|
+
return this.#legacy.refundEvmWithSigner(...args);
|
|
1962
|
+
}
|
|
1963
|
+
/** Delegated to the legacy client (migration checkpoint). */
|
|
1964
|
+
collabRefundEvmWithSigner(...args) {
|
|
1965
|
+
return this.#legacy.collabRefundEvmWithSigner(...args);
|
|
1966
|
+
}
|
|
1967
|
+
/** Delegated to the legacy client (migration checkpoint). */
|
|
1968
|
+
fundSwapGasless(...args) {
|
|
1969
|
+
return this.#legacy.fundSwapGasless(...args);
|
|
1970
|
+
}
|
|
1971
|
+
/** Delegated to the legacy client (migration checkpoint). */
|
|
1972
|
+
getSwapDepositorKey(...args) {
|
|
1973
|
+
return this.#legacy.getSwapDepositorKey(...args);
|
|
1974
|
+
}
|
|
1975
|
+
/** Delegated to the legacy client (migration checkpoint). */
|
|
1976
|
+
getEvmDepositorKey(...args) {
|
|
1977
|
+
return this.#legacy.getEvmDepositorKey(...args);
|
|
1978
|
+
}
|
|
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). */
|
|
1988
|
+
recoverGaslessFunds(...args) {
|
|
1989
|
+
return this.#legacy.recoverGaslessFunds(...args);
|
|
1990
|
+
}
|
|
1991
|
+
/** Delegated to the legacy client (migration checkpoint). */
|
|
1992
|
+
hasReceivedVtxo(...args) {
|
|
1993
|
+
return this.#legacy.hasReceivedVtxo(...args);
|
|
1994
|
+
}
|
|
1995
|
+
// --- Satora-native features go below ---
|
|
1996
|
+
/**
|
|
1997
|
+
* Start observing the user's active swaps and deriving each one's next action.
|
|
1998
|
+
*
|
|
1999
|
+
* Loads stored swaps, maps the ones whose ledgers are observable to
|
|
2000
|
+
* {@link TrackedSwap}s, and runs the per-ledger monitors. Idempotent. Requires
|
|
2001
|
+
* the client to have been built with {@link ClientBuilder.withTracking}.
|
|
2002
|
+
* Subscribe with {@link subscribeToActions}; release with
|
|
2003
|
+
* {@link stopTracking}.
|
|
2004
|
+
*/
|
|
2005
|
+
async startTracking() {
|
|
2006
|
+
if (!this.#tracking.enabled)
|
|
2007
|
+
throw new Error(
|
|
2008
|
+
"tracking is disabled \u2014 remove .withoutTracking() to enable it"
|
|
2009
|
+
);
|
|
2010
|
+
if (this.#started) return;
|
|
2011
|
+
this.#started = true;
|
|
2012
|
+
try {
|
|
2013
|
+
const tracker = new SwapTracker(await this.#ensureManagers(), {
|
|
2014
|
+
refreshIntervalMs: this.#tracking.refreshIntervalMs ?? 5e3
|
|
2015
|
+
});
|
|
2016
|
+
this.#tracker = tracker;
|
|
2017
|
+
await tracker.startTracking(await this.#loadTrackedSwaps());
|
|
2018
|
+
this.#startWorker(tracker);
|
|
2019
|
+
} catch (error) {
|
|
2020
|
+
this.#worker?.stop();
|
|
2021
|
+
this.#worker = void 0;
|
|
2022
|
+
this.#tracker?.stop();
|
|
2023
|
+
this.#tracker = void 0;
|
|
2024
|
+
this.#started = false;
|
|
2025
|
+
throw error;
|
|
2026
|
+
}
|
|
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
|
+
}
|
|
2118
|
+
/**
|
|
2119
|
+
* Subscribe to next-action updates: `cb` fires with the current action for each
|
|
2120
|
+
* tracked swap immediately, then on every change. Call after
|
|
2121
|
+
* {@link startTracking}. Returns an unsubscribe fn.
|
|
2122
|
+
*/
|
|
2123
|
+
subscribeToActions(cb) {
|
|
2124
|
+
if (!this.#tracker)
|
|
2125
|
+
throw new Error("call startTracking() before subscribeToActions()");
|
|
2126
|
+
return this.#tracker.subscribeToActions(cb);
|
|
2127
|
+
}
|
|
2128
|
+
/** Stop tracking and drop subscribers. The managers themselves are not disposed. */
|
|
2129
|
+
stopTracking() {
|
|
2130
|
+
this.#worker?.stop();
|
|
2131
|
+
this.#worker = void 0;
|
|
2132
|
+
this.#tracker?.stop();
|
|
2133
|
+
this.#tracker = void 0;
|
|
2134
|
+
this.#started = false;
|
|
2135
|
+
}
|
|
2136
|
+
/**
|
|
2137
|
+
* Resolve the per-ledger managers once — the explicit override if given, else
|
|
2138
|
+
* auto-built from config: an {@link ArkadeContractManager} from the configured
|
|
2139
|
+
* Ark server URL (defaulting to the mainnet server), clocked by `getMtp`, and an
|
|
2140
|
+
* {@link EvmContractManager} from the tested default RPCs.
|
|
2141
|
+
*/
|
|
2142
|
+
async #ensureManagers() {
|
|
2143
|
+
if (this.#managers) return this.#managers;
|
|
2144
|
+
if (this.#tracking.managers) {
|
|
2145
|
+
this.#managers = this.#tracking.managers;
|
|
2146
|
+
return this.#managers;
|
|
2147
|
+
}
|
|
2148
|
+
const managers = /* @__PURE__ */ new Map();
|
|
2149
|
+
const { arkadeServerUrl, evmRpcUrls, esploraUrl } = this.#tracking;
|
|
2150
|
+
const chainTime = async () => (await this.getMtp()).mtp * 1e3;
|
|
2151
|
+
const arkadeUrl = arkadeServerUrl ?? defaultArkadeServerUrl("bitcoin");
|
|
2152
|
+
if (arkadeUrl) {
|
|
2153
|
+
managers.set(
|
|
2154
|
+
"arkade",
|
|
2155
|
+
await ArkadeContractManager.create({ serverUrl: arkadeUrl, chainTime })
|
|
2156
|
+
);
|
|
2157
|
+
}
|
|
2158
|
+
const readers = defaultEvmReaders(evmRpcUrls);
|
|
2159
|
+
if (readers.size > 0)
|
|
2160
|
+
managers.set("evm", EvmContractManager.fromDeps({ readers }));
|
|
2161
|
+
managers.set(
|
|
2162
|
+
"bitcoin",
|
|
2163
|
+
await BitcoinContractManager.create({
|
|
2164
|
+
esploraUrl: esploraUrl ? [esploraUrl] : DEFAULT_ESPLORA_URLS,
|
|
2165
|
+
chainTime
|
|
2166
|
+
})
|
|
2167
|
+
);
|
|
2168
|
+
this.#managers = managers;
|
|
2169
|
+
return managers;
|
|
2170
|
+
}
|
|
2171
|
+
};
|
|
2172
|
+
var ClientBuilder = class {
|
|
2173
|
+
#inner = LegacyClient.builder();
|
|
2174
|
+
// Observe-mode tracking is on by default; the built client auto-builds its
|
|
2175
|
+
// managers from config unless disabled or given an explicit override.
|
|
2176
|
+
#trackingEnabled = true;
|
|
2177
|
+
#arkadeServerUrl;
|
|
2178
|
+
#esploraUrl;
|
|
2179
|
+
#evmRpcUrls;
|
|
2180
|
+
#managers;
|
|
2181
|
+
#autoClaim;
|
|
2182
|
+
/**
|
|
2183
|
+
* Override the EVM JSON-RPC endpoint per chainId. Optional — tracking uses
|
|
2184
|
+
* tested public defaults otherwise; an override is tried first, with the
|
|
2185
|
+
* defaults kept as fallbacks.
|
|
2186
|
+
*/
|
|
2187
|
+
withEvmRpcUrls(urls) {
|
|
2188
|
+
this.#evmRpcUrls = urls;
|
|
2189
|
+
return this;
|
|
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
|
+
}
|
|
2203
|
+
/** Turn observe-mode tracking off. */
|
|
2204
|
+
withoutTracking() {
|
|
2205
|
+
this.#trackingEnabled = false;
|
|
2206
|
+
return this;
|
|
2207
|
+
}
|
|
2208
|
+
/**
|
|
2209
|
+
* Advanced: supply per-ledger {@link ContractManager}s explicitly instead of
|
|
2210
|
+
* letting the client auto-build them from config (useful for tests or custom
|
|
2211
|
+
* chain sources).
|
|
2212
|
+
*/
|
|
2213
|
+
withContractManagers(managers) {
|
|
2214
|
+
this.#managers = managers;
|
|
2215
|
+
return this;
|
|
2216
|
+
}
|
|
2217
|
+
withBaseUrl(...args) {
|
|
2218
|
+
this.#inner.withBaseUrl(...args);
|
|
2219
|
+
return this;
|
|
2220
|
+
}
|
|
2221
|
+
withReferralCode(...args) {
|
|
2222
|
+
this.#inner.withReferralCode(...args);
|
|
2223
|
+
return this;
|
|
2224
|
+
}
|
|
2225
|
+
withOrgCode(...args) {
|
|
2226
|
+
this.#inner.withOrgCode(...args);
|
|
2227
|
+
return this;
|
|
2228
|
+
}
|
|
2229
|
+
withDefaultHeaders(...args) {
|
|
2230
|
+
this.#inner.withDefaultHeaders(...args);
|
|
2231
|
+
return this;
|
|
2232
|
+
}
|
|
2233
|
+
/**
|
|
2234
|
+
* Override the Esplora REST URL. Optional for tracking — the Bitcoin manager
|
|
2235
|
+
* otherwise defaults to mainnet mempool.space; set this for a dev/regtest node.
|
|
2236
|
+
*/
|
|
2237
|
+
withEsploraUrl(url) {
|
|
2238
|
+
this.#inner.withEsploraUrl(url);
|
|
2239
|
+
this.#esploraUrl = url;
|
|
2240
|
+
return this;
|
|
2241
|
+
}
|
|
2242
|
+
/**
|
|
2243
|
+
* Override the Ark server URL. Optional for tracking — it otherwise defaults to
|
|
2244
|
+
* the mainnet server; set this to track a non-mainnet (dev/signet) deployment.
|
|
2245
|
+
*/
|
|
2246
|
+
withArkadeServerUrl(url) {
|
|
2247
|
+
this.#inner.withArkadeServerUrl(url);
|
|
2248
|
+
this.#arkadeServerUrl = url;
|
|
2249
|
+
return this;
|
|
2250
|
+
}
|
|
2251
|
+
withSignerStorage(...args) {
|
|
2252
|
+
this.#inner.withSignerStorage(...args);
|
|
2253
|
+
return this;
|
|
2254
|
+
}
|
|
2255
|
+
withSwapStorage(...args) {
|
|
2256
|
+
this.#inner.withSwapStorage(...args);
|
|
2257
|
+
return this;
|
|
2258
|
+
}
|
|
2259
|
+
withMnemonic(...args) {
|
|
2260
|
+
this.#inner.withMnemonic(...args);
|
|
2261
|
+
return this;
|
|
2262
|
+
}
|
|
2263
|
+
withXprv(...args) {
|
|
2264
|
+
this.#inner.withXprv(...args);
|
|
2265
|
+
return this;
|
|
2266
|
+
}
|
|
2267
|
+
withAa(...args) {
|
|
2268
|
+
this.#inner.withAa(...args);
|
|
2269
|
+
return this;
|
|
2270
|
+
}
|
|
2271
|
+
withLogger(...args) {
|
|
2272
|
+
this.#inner.withLogger(...args);
|
|
2273
|
+
return this;
|
|
2274
|
+
}
|
|
2275
|
+
withLogLevel(...args) {
|
|
2276
|
+
this.#inner.withLogLevel(...args);
|
|
2277
|
+
return this;
|
|
2278
|
+
}
|
|
2279
|
+
async build() {
|
|
2280
|
+
return new Client(await this.#inner.build(), {
|
|
2281
|
+
enabled: this.#trackingEnabled,
|
|
2282
|
+
managers: this.#managers,
|
|
2283
|
+
arkadeServerUrl: this.#arkadeServerUrl,
|
|
2284
|
+
esploraUrl: this.#esploraUrl,
|
|
2285
|
+
evmRpcUrls: this.#evmRpcUrls,
|
|
2286
|
+
autoClaim: this.#autoClaim
|
|
2287
|
+
});
|
|
2288
|
+
}
|
|
2289
|
+
};
|
|
2290
|
+
function createdSwapId(result) {
|
|
2291
|
+
const id = result?.response?.id;
|
|
2292
|
+
return typeof id === "string" ? id : void 0;
|
|
2293
|
+
}
|
|
2294
|
+
export {
|
|
2295
|
+
ArkadeContractManager,
|
|
2296
|
+
BitcoinContractManager,
|
|
2297
|
+
Client,
|
|
2298
|
+
ClientBuilder,
|
|
2299
|
+
DEFAULT_ESPLORA_URLS,
|
|
2300
|
+
DEFAULT_EVM_RPCS,
|
|
2301
|
+
EvmContractManager,
|
|
2302
|
+
SwapTracker,
|
|
2303
|
+
SwapWorker,
|
|
2304
|
+
WsStatusSource,
|
|
2305
|
+
buildArkadeVhtlcRef,
|
|
2306
|
+
createEvmRpcReader,
|
|
2307
|
+
defaultArkadeServerUrl,
|
|
2308
|
+
defaultEvmReaders,
|
|
2309
|
+
deriveSwapActions,
|
|
2310
|
+
deriveSwapStatus,
|
|
2311
|
+
esploraReader,
|
|
2312
|
+
evmReaderFromClient,
|
|
2313
|
+
htlcFactsFromEsploraTxs,
|
|
2314
|
+
htlcKey,
|
|
2315
|
+
swapToTracked
|
|
2316
|
+
};
|
|
3
2317
|
//# sourceMappingURL=index.js.map
|