@satora/swap 0.2.0 → 0.3.1
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 +4 -0
- package/dist/{bitcoin-reader-esplora-GRUP7RAG.js → bitcoin-reader-esplora-HV6CANAA.js} +2 -2
- package/dist/{chunk-J35RZBDW.js → chunk-EIWELJJK.js} +25 -9
- package/dist/chunk-EIWELJJK.js.map +1 -0
- package/dist/index.cjs +737 -208
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +301 -87
- package/dist/index.d.ts +301 -87
- package/dist/index.js +720 -199
- package/dist/index.js.map +1 -1
- package/package.json +3 -3
- package/dist/chunk-J35RZBDW.js.map +0 -1
- /package/dist/{bitcoin-reader-esplora-GRUP7RAG.js.map → bitcoin-reader-esplora-HV6CANAA.js.map} +0 -0
package/dist/index.js
CHANGED
|
@@ -2,12 +2,13 @@ import {
|
|
|
2
2
|
DEFAULT_ESPLORA_URLS,
|
|
3
3
|
esploraReader,
|
|
4
4
|
htlcFactsFromEsploraTxs
|
|
5
|
-
} from "./chunk-
|
|
5
|
+
} from "./chunk-EIWELJJK.js";
|
|
6
6
|
|
|
7
7
|
// src/index.ts
|
|
8
8
|
export * from "@lendasat/lendaswap-sdk-pure";
|
|
9
9
|
|
|
10
10
|
// src/actions/derive.ts
|
|
11
|
+
var FUNDING_REAP_GRACE_MS = 60 * 60 * 1e3;
|
|
11
12
|
function deriveSwapActions(input) {
|
|
12
13
|
const {
|
|
13
14
|
status,
|
|
@@ -22,12 +23,23 @@ function deriveSwapActions(input) {
|
|
|
22
23
|
if (!clientFunds) {
|
|
23
24
|
const wait = {
|
|
24
25
|
id: "wait",
|
|
26
|
+
waitingOn: "client_payment",
|
|
25
27
|
recommended: true,
|
|
26
28
|
automation: "auto",
|
|
27
29
|
reason: "Waiting for the Lightning payment to be locked in."
|
|
28
30
|
};
|
|
29
31
|
return { recommended: "wait", actions: [wait] };
|
|
30
32
|
}
|
|
33
|
+
if (clientChainNow >= clientRefundLocktime + FUNDING_REAP_GRACE_MS) {
|
|
34
|
+
const done = {
|
|
35
|
+
id: "none",
|
|
36
|
+
outcome: "expired",
|
|
37
|
+
recommended: true,
|
|
38
|
+
automation: "auto",
|
|
39
|
+
reason: "The funding window closed."
|
|
40
|
+
};
|
|
41
|
+
return { recommended: "none", actions: [done] };
|
|
42
|
+
}
|
|
31
43
|
if (clientChainNow >= clientRefundLocktime) return { actions: [] };
|
|
32
44
|
const fund = {
|
|
33
45
|
id: "fund",
|
|
@@ -41,6 +53,7 @@ function deriveSwapActions(input) {
|
|
|
41
53
|
case "clientfundingseen": {
|
|
42
54
|
const wait = {
|
|
43
55
|
id: "wait",
|
|
56
|
+
waitingOn: "client_funding_confirmation",
|
|
44
57
|
recommended: true,
|
|
45
58
|
automation: "auto",
|
|
46
59
|
reason: "Waiting for your funding transaction to confirm."
|
|
@@ -60,6 +73,7 @@ function deriveSwapActions(input) {
|
|
|
60
73
|
}
|
|
61
74
|
const wait = {
|
|
62
75
|
id: "wait",
|
|
76
|
+
waitingOn: "server_funding",
|
|
63
77
|
recommended: true,
|
|
64
78
|
automation: "auto",
|
|
65
79
|
reason: "Waiting for the server to fund the swap."
|
|
@@ -90,6 +104,7 @@ function deriveSwapActions(input) {
|
|
|
90
104
|
if (!clientFunds) {
|
|
91
105
|
const done = {
|
|
92
106
|
id: "none",
|
|
107
|
+
outcome: "refunded",
|
|
93
108
|
recommended: true,
|
|
94
109
|
automation: "auto",
|
|
95
110
|
reason: "The claim window passed \u2014 your Lightning payment will be refunded automatically."
|
|
@@ -108,6 +123,7 @@ function deriveSwapActions(input) {
|
|
|
108
123
|
}
|
|
109
124
|
const wait = {
|
|
110
125
|
id: "wait",
|
|
126
|
+
waitingOn: "refund_timelock",
|
|
111
127
|
recommended: true,
|
|
112
128
|
automation: "auto",
|
|
113
129
|
reason: "Wait for your refund timelock \u2014 do not claim, as revealing the preimage would let the server take your deposit."
|
|
@@ -127,6 +143,7 @@ function deriveSwapActions(input) {
|
|
|
127
143
|
case "clientredeeming": {
|
|
128
144
|
const wait = {
|
|
129
145
|
id: "wait",
|
|
146
|
+
waitingOn: "claim_confirmation",
|
|
130
147
|
recommended: true,
|
|
131
148
|
automation: "auto",
|
|
132
149
|
reason: "Redeeming your funds \u2014 waiting for the claim to confirm."
|
|
@@ -137,6 +154,7 @@ function deriveSwapActions(input) {
|
|
|
137
154
|
case "serverredeemed": {
|
|
138
155
|
const done = {
|
|
139
156
|
id: "none",
|
|
157
|
+
outcome: "completed",
|
|
140
158
|
recommended: true,
|
|
141
159
|
automation: "auto",
|
|
142
160
|
reason: "Swap complete \u2014 you've received your funds."
|
|
@@ -146,6 +164,7 @@ function deriveSwapActions(input) {
|
|
|
146
164
|
case "expired": {
|
|
147
165
|
const done = {
|
|
148
166
|
id: "none",
|
|
167
|
+
outcome: "expired",
|
|
149
168
|
recommended: true,
|
|
150
169
|
automation: "auto",
|
|
151
170
|
reason: "The swap expired without funding \u2014 nothing was locked."
|
|
@@ -157,6 +176,7 @@ function deriveSwapActions(input) {
|
|
|
157
176
|
case "clientrefundedserverfunded": {
|
|
158
177
|
const done = {
|
|
159
178
|
id: "none",
|
|
179
|
+
outcome: "refunded",
|
|
160
180
|
recommended: true,
|
|
161
181
|
automation: "auto",
|
|
162
182
|
reason: "Refunded \u2014 your deposit was returned."
|
|
@@ -166,6 +186,8 @@ function deriveSwapActions(input) {
|
|
|
166
186
|
case "clientredeemedandclientrefunded": {
|
|
167
187
|
const done = {
|
|
168
188
|
id: "none",
|
|
189
|
+
// The client holds both sides' funds — "completed" from its viewpoint.
|
|
190
|
+
outcome: "completed",
|
|
169
191
|
recommended: true,
|
|
170
192
|
automation: "auto",
|
|
171
193
|
reason: "This swap ended in an anomalous state (redeemed and refunded); no action needed."
|
|
@@ -188,6 +210,7 @@ function deriveSwapActions(input) {
|
|
|
188
210
|
}
|
|
189
211
|
const wait = {
|
|
190
212
|
id: "wait",
|
|
213
|
+
waitingOn: "refund_timelock",
|
|
191
214
|
recommended: true,
|
|
192
215
|
automation: "auto",
|
|
193
216
|
reason: "The swap did not complete; waiting for the refund timelock to reclaim your deposit."
|
|
@@ -358,6 +381,7 @@ var ArkadeContractManager = class _ArkadeContractManager {
|
|
|
358
381
|
/** pkScript → the verified preimage recovered from a claim spend. */
|
|
359
382
|
#preimages = /* @__PURE__ */ new Map();
|
|
360
383
|
#listeners = /* @__PURE__ */ new Set();
|
|
384
|
+
/** Last MTP reading (ms) + when it was fetched, for extrapolation. */
|
|
361
385
|
#now;
|
|
362
386
|
constructor(deps) {
|
|
363
387
|
this.#indexer = deps.indexer;
|
|
@@ -383,7 +407,6 @@ var ArkadeContractManager = class _ArkadeContractManager {
|
|
|
383
407
|
`ArkadeContractManager can't track a '${ref.ledger}' HTLC`
|
|
384
408
|
);
|
|
385
409
|
this.#refs.set(ref.script, ref);
|
|
386
|
-
await this.#reconcileRef(ref);
|
|
387
410
|
}
|
|
388
411
|
async unregister(ref) {
|
|
389
412
|
if (ref.ledger !== "arkade") return;
|
|
@@ -395,23 +418,27 @@ var ArkadeContractManager = class _ArkadeContractManager {
|
|
|
395
418
|
return ref.ledger === "arkade" ? this.#obs.get(ref.script) : void 0;
|
|
396
419
|
}
|
|
397
420
|
chainNow(_ref) {
|
|
398
|
-
|
|
421
|
+
if (!this.#now) return void 0;
|
|
422
|
+
return this.#now.mtpMs + Math.max(0, Date.now() - this.#now.fetchedAtMs);
|
|
399
423
|
}
|
|
400
424
|
onEvent(cb) {
|
|
401
425
|
this.#listeners.add(cb);
|
|
402
426
|
return () => this.#listeners.delete(cb);
|
|
403
427
|
}
|
|
428
|
+
/**
|
|
429
|
+
* Full reconcile of every tracked ref (clock + vtxos). Called on demand
|
|
430
|
+
* (tracker start / newly tracked swap), NOT periodically: onward, hints and
|
|
431
|
+
* the tracker's at-risk safety net drive targeted `reconcile`s.
|
|
432
|
+
*/
|
|
404
433
|
async refresh() {
|
|
405
|
-
|
|
406
|
-
await Promise.all(
|
|
407
|
-
[...this.#refs.values()].map((ref) => this.#reconcileRef(ref))
|
|
408
|
-
);
|
|
434
|
+
await this.#scanAll();
|
|
409
435
|
}
|
|
436
|
+
/** Targeted verify (hint / pre-action path) — never gated. */
|
|
410
437
|
async reconcile(ref) {
|
|
411
438
|
if (ref.ledger !== "arkade") return;
|
|
412
439
|
const tracked = this.#refs.get(ref.script);
|
|
413
440
|
if (!tracked) return;
|
|
414
|
-
|
|
441
|
+
await this.#readClock();
|
|
415
442
|
await this.#reconcileRef(tracked);
|
|
416
443
|
}
|
|
417
444
|
dispose() {
|
|
@@ -421,6 +448,18 @@ var ArkadeContractManager = class _ArkadeContractManager {
|
|
|
421
448
|
getPreimage(ref) {
|
|
422
449
|
return ref.ledger === "arkade" ? this.#preimages.get(ref.script) : void 0;
|
|
423
450
|
}
|
|
451
|
+
/** Read and store the MTP clock (with fetch time, for extrapolation). */
|
|
452
|
+
async #readClock() {
|
|
453
|
+
if (!this.#chainTime) return;
|
|
454
|
+
this.#now = { mtpMs: await this.#chainTime(), fetchedAtMs: Date.now() };
|
|
455
|
+
}
|
|
456
|
+
/** Full scan of every tracked ref (clock + vtxos). */
|
|
457
|
+
async #scanAll() {
|
|
458
|
+
await this.#readClock();
|
|
459
|
+
await Promise.all(
|
|
460
|
+
[...this.#refs.values()].map((ref) => this.#reconcileRef(ref))
|
|
461
|
+
);
|
|
462
|
+
}
|
|
424
463
|
/** Read the VHTLC's vtxos and map them to an observation. */
|
|
425
464
|
async #reconcileRef(ref) {
|
|
426
465
|
const { vtxos } = await this.#indexer.getVtxos({ scripts: [ref.script] });
|
|
@@ -513,6 +552,7 @@ var BitcoinContractManager = class _BitcoinContractManager {
|
|
|
513
552
|
/** address → the preimage a claim revealed. */
|
|
514
553
|
#preimages = /* @__PURE__ */ new Map();
|
|
515
554
|
#listeners = /* @__PURE__ */ new Set();
|
|
555
|
+
/** Last MTP reading (ms) + when it was fetched, for extrapolation. */
|
|
516
556
|
#now;
|
|
517
557
|
constructor(deps) {
|
|
518
558
|
this.#reader = deps.reader;
|
|
@@ -522,9 +562,11 @@ var BitcoinContractManager = class _BitcoinContractManager {
|
|
|
522
562
|
return new _BitcoinContractManager(deps);
|
|
523
563
|
}
|
|
524
564
|
static async create(config) {
|
|
525
|
-
const { esploraReader: esploraReader2 } = await import("./bitcoin-reader-esplora-
|
|
565
|
+
const { esploraReader: esploraReader2 } = await import("./bitcoin-reader-esplora-HV6CANAA.js");
|
|
526
566
|
return _BitcoinContractManager.fromDeps({
|
|
527
|
-
reader: esploraReader2(config.esploraUrl
|
|
567
|
+
reader: esploraReader2(config.esploraUrl, void 0, {
|
|
568
|
+
minConfirmations: config.minConfirmations
|
|
569
|
+
}),
|
|
528
570
|
chainTime: config.chainTime
|
|
529
571
|
});
|
|
530
572
|
}
|
|
@@ -549,14 +591,15 @@ var BitcoinContractManager = class _BitcoinContractManager {
|
|
|
549
591
|
return ref.ledger === "bitcoin" ? this.#obs.get(ref.address) : void 0;
|
|
550
592
|
}
|
|
551
593
|
chainNow(_ref) {
|
|
552
|
-
|
|
594
|
+
if (!this.#now) return void 0;
|
|
595
|
+
return this.#now.mtpMs + Math.max(0, Date.now() - this.#now.fetchedAtMs);
|
|
553
596
|
}
|
|
554
597
|
onEvent(cb) {
|
|
555
598
|
this.#listeners.add(cb);
|
|
556
599
|
return () => this.#listeners.delete(cb);
|
|
557
600
|
}
|
|
558
601
|
async refresh() {
|
|
559
|
-
|
|
602
|
+
await this.#readClock();
|
|
560
603
|
await Promise.all(
|
|
561
604
|
[...this.#refs.values()].map((ref) => this.#reconcileRef(ref))
|
|
562
605
|
);
|
|
@@ -565,9 +608,14 @@ var BitcoinContractManager = class _BitcoinContractManager {
|
|
|
565
608
|
if (ref.ledger !== "bitcoin") return;
|
|
566
609
|
const tracked = this.#refs.get(ref.address);
|
|
567
610
|
if (!tracked) return;
|
|
568
|
-
|
|
611
|
+
await this.#readClock();
|
|
569
612
|
await this.#reconcileRef(tracked);
|
|
570
613
|
}
|
|
614
|
+
/** Read and store the MTP clock (with fetch time, for extrapolation). */
|
|
615
|
+
async #readClock() {
|
|
616
|
+
if (!this.#chainTime) return;
|
|
617
|
+
this.#now = { mtpMs: await this.#chainTime(), fetchedAtMs: Date.now() };
|
|
618
|
+
}
|
|
571
619
|
dispose() {
|
|
572
620
|
this.#listeners.clear();
|
|
573
621
|
}
|
|
@@ -576,7 +624,10 @@ var BitcoinContractManager = class _BitcoinContractManager {
|
|
|
576
624
|
return ref.ledger === "bitcoin" ? this.#preimages.get(ref.address) : void 0;
|
|
577
625
|
}
|
|
578
626
|
async #reconcileRef(ref) {
|
|
579
|
-
const facts = await this.#reader.getHtlcFacts(
|
|
627
|
+
const facts = await this.#reader.getHtlcFacts(
|
|
628
|
+
ref.address,
|
|
629
|
+
ref.minConfirmations
|
|
630
|
+
);
|
|
580
631
|
const { observation, preimage } = bitcoinObservation(
|
|
581
632
|
facts,
|
|
582
633
|
hex4.decode(ref.preimageHash),
|
|
@@ -622,14 +673,59 @@ function htlcKey(ref) {
|
|
|
622
673
|
return `arkade:${ref.script}`;
|
|
623
674
|
case "bitcoin":
|
|
624
675
|
return `bitcoin:${ref.address}`;
|
|
625
|
-
case "evm":
|
|
626
|
-
|
|
676
|
+
case "evm": {
|
|
677
|
+
const base = `evm:${ref.chainId}:${ref.htlc.toLowerCase()}:${ref.preimageHash.toLowerCase()}`;
|
|
678
|
+
if (!ref.expectedToken || !ref.sender || ref.timelockSec === void 0) {
|
|
679
|
+
return base;
|
|
680
|
+
}
|
|
681
|
+
return [
|
|
682
|
+
base,
|
|
683
|
+
ref.expectedAmount,
|
|
684
|
+
ref.expectedToken.toLowerCase(),
|
|
685
|
+
ref.sender.toLowerCase(),
|
|
686
|
+
ref.claimAddress.toLowerCase(),
|
|
687
|
+
ref.timelockSec
|
|
688
|
+
].join(":");
|
|
689
|
+
}
|
|
627
690
|
case "lightning":
|
|
628
691
|
return `lightning:${ref.paymentHash}`;
|
|
629
692
|
}
|
|
630
693
|
}
|
|
631
694
|
|
|
632
695
|
// src/contracts/evm-manager.ts
|
|
696
|
+
function htlcQueryKey(q) {
|
|
697
|
+
const base = `${q.htlc.toLowerCase()}:${q.preimageHash.toLowerCase()}`;
|
|
698
|
+
if (!q.terms || !q.claimAddress) return base;
|
|
699
|
+
const { amount, token, sender, timelockSec } = q.terms;
|
|
700
|
+
return [
|
|
701
|
+
base,
|
|
702
|
+
amount,
|
|
703
|
+
token.toLowerCase(),
|
|
704
|
+
sender.toLowerCase(),
|
|
705
|
+
q.claimAddress.toLowerCase(),
|
|
706
|
+
timelockSec
|
|
707
|
+
].join(":");
|
|
708
|
+
}
|
|
709
|
+
function refQueryKey(ref) {
|
|
710
|
+
const full = activeQuery(ref);
|
|
711
|
+
return htlcQueryKey({
|
|
712
|
+
htlc: ref.htlc,
|
|
713
|
+
preimageHash: ref.preimageHash,
|
|
714
|
+
claimAddress: ref.claimAddress,
|
|
715
|
+
terms: full && {
|
|
716
|
+
amount: full.amount,
|
|
717
|
+
token: full.token,
|
|
718
|
+
sender: full.sender,
|
|
719
|
+
timelockSec: full.timelockSec
|
|
720
|
+
}
|
|
721
|
+
});
|
|
722
|
+
}
|
|
723
|
+
var AVG_BLOCK_MS = {
|
|
724
|
+
1: 12e3,
|
|
725
|
+
137: 2100,
|
|
726
|
+
42161: 250
|
|
727
|
+
};
|
|
728
|
+
var SCAN_MARGIN_BLOCKS = 1000n;
|
|
633
729
|
var EvmContractManager = class _EvmContractManager {
|
|
634
730
|
ledger = "evm";
|
|
635
731
|
#readers;
|
|
@@ -639,14 +735,8 @@ var EvmContractManager = class _EvmContractManager {
|
|
|
639
735
|
#obs = /* @__PURE__ */ new Map();
|
|
640
736
|
/** htlcKey → the preimage a claim revealed. */
|
|
641
737
|
#preimages = /* @__PURE__ */ new Map();
|
|
642
|
-
/** chainId → its
|
|
738
|
+
/** chainId → its last clock reading (extrapolated in {@link chainNow}). */
|
|
643
739
|
#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
740
|
#listeners = /* @__PURE__ */ new Set();
|
|
651
741
|
constructor(deps) {
|
|
652
742
|
this.#readers = deps.readers;
|
|
@@ -665,8 +755,6 @@ var EvmContractManager = class _EvmContractManager {
|
|
|
665
755
|
`no EVM reader for chain ${ref.chainId} \u2014 configure it via ClientBuilder.withEvmRpcUrls()`
|
|
666
756
|
);
|
|
667
757
|
this.#refs.set(htlcKey(ref), ref);
|
|
668
|
-
this.#ensureWatch(ref.chainId);
|
|
669
|
-
await this.#reconcileChain(ref.chainId);
|
|
670
758
|
}
|
|
671
759
|
async unregister(ref) {
|
|
672
760
|
if (ref.ledger !== "evm") return;
|
|
@@ -674,100 +762,176 @@ var EvmContractManager = class _EvmContractManager {
|
|
|
674
762
|
this.#refs.delete(key);
|
|
675
763
|
this.#obs.delete(key);
|
|
676
764
|
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);
|
|
765
|
+
if (![...this.#refs.values()].some((r) => r.chainId === ref.chainId))
|
|
680
766
|
this.#now.delete(ref.chainId);
|
|
681
|
-
this.#reconcileAgain.delete(ref.chainId);
|
|
682
|
-
}
|
|
683
767
|
}
|
|
684
768
|
getState(ref) {
|
|
685
769
|
return ref.ledger === "evm" ? this.#obs.get(htlcKey(ref)) : void 0;
|
|
686
770
|
}
|
|
687
771
|
chainNow(ref) {
|
|
688
|
-
|
|
772
|
+
if (ref.ledger !== "evm") return void 0;
|
|
773
|
+
const clock = this.#now.get(ref.chainId);
|
|
774
|
+
if (!clock) return void 0;
|
|
775
|
+
return clock.blockTimeMs + Math.max(0, Date.now() - clock.fetchedAtMs);
|
|
689
776
|
}
|
|
690
777
|
onEvent(cb) {
|
|
691
778
|
this.#listeners.add(cb);
|
|
692
779
|
return () => this.#listeners.delete(cb);
|
|
693
780
|
}
|
|
781
|
+
/**
|
|
782
|
+
* Full reconcile of every tracked chain — seeds clocks + observations. Called
|
|
783
|
+
* on demand (tracker start / newly tracked swap), NOT periodically: onward,
|
|
784
|
+
* hints drive targeted `reconcile`s and the tracker's at-risk safety net
|
|
785
|
+
* re-reads only swaps with client funds on the line.
|
|
786
|
+
*/
|
|
694
787
|
async refresh() {
|
|
695
|
-
const chainIds =
|
|
696
|
-
|
|
788
|
+
const chainIds = [
|
|
789
|
+
...new Set([...this.#refs.values()].map((r) => r.chainId))
|
|
790
|
+
];
|
|
791
|
+
const results = await Promise.allSettled(
|
|
792
|
+
chainIds.map((c) => this.#reconcileChain(c))
|
|
793
|
+
);
|
|
794
|
+
results.forEach((result, index) => {
|
|
795
|
+
if (result.status === "rejected")
|
|
796
|
+
console.warn(
|
|
797
|
+
`EVM reconcile failed for chain ${chainIds[index]}:`,
|
|
798
|
+
result.reason
|
|
799
|
+
);
|
|
800
|
+
});
|
|
697
801
|
}
|
|
802
|
+
/** Targeted verify (hint / pre-action path) — never gated. */
|
|
698
803
|
async reconcile(ref) {
|
|
699
804
|
if (ref.ledger !== "evm") return;
|
|
700
805
|
const tracked = this.#refs.get(htlcKey(ref));
|
|
701
806
|
if (!tracked) return;
|
|
702
807
|
const reader = this.#readers.get(tracked.chainId);
|
|
703
808
|
if (!reader) return;
|
|
704
|
-
this.#
|
|
705
|
-
await this.#
|
|
809
|
+
await this.#readClock(reader, tracked.chainId);
|
|
810
|
+
await this.#reconcileRefs(reader, tracked.chainId, [tracked]);
|
|
706
811
|
}
|
|
707
812
|
dispose() {
|
|
708
|
-
for (const unsub of this.#watchUnsubs.values()) unsub();
|
|
709
|
-
this.#watchUnsubs.clear();
|
|
710
|
-
this.#reconciling.clear();
|
|
711
|
-
this.#reconcileAgain.clear();
|
|
712
813
|
this.#listeners.clear();
|
|
713
814
|
}
|
|
714
815
|
/** The preimage a claim revealed on this HTLC, if one was seen. */
|
|
715
816
|
getPreimage(ref) {
|
|
716
817
|
return ref.ledger === "evm" ? this.#preimages.get(htlcKey(ref)) : void 0;
|
|
717
818
|
}
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
const
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
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);
|
|
819
|
+
/** Read and store a chain's clock (with its fetch time, for extrapolation). */
|
|
820
|
+
async #readClock(reader, chainId) {
|
|
821
|
+
const block = await reader.getLatestBlock();
|
|
822
|
+
this.#now.set(chainId, {
|
|
823
|
+
blockTimeMs: block.timeMs,
|
|
824
|
+
blockNumber: block.number,
|
|
825
|
+
fetchedAtMs: Date.now()
|
|
747
826
|
});
|
|
748
827
|
}
|
|
749
828
|
/** Refresh one chain's clock and re-observe every HTLC tracked on it. */
|
|
750
829
|
async #reconcileChain(chainId) {
|
|
751
830
|
const reader = this.#readers.get(chainId);
|
|
752
831
|
if (!reader) return;
|
|
753
|
-
this.#
|
|
832
|
+
await this.#readClock(reader, chainId);
|
|
833
|
+
const refs = [...this.#refs.values()].filter((r) => r.chainId === chainId);
|
|
834
|
+
await this.#reconcileRefs(reader, chainId, refs);
|
|
835
|
+
}
|
|
836
|
+
/**
|
|
837
|
+
* Re-observe the given refs. Two tiers:
|
|
838
|
+
*
|
|
839
|
+
* 1. Refs with the full contract tuple get the cheap `isActive` check —
|
|
840
|
+
* `true` proves open-and-on-expected-terms (the swap key hashes them) →
|
|
841
|
+
* `confirmed`, no log scan at all. This is the common case.
|
|
842
|
+
* 2. Everything else — an incomplete tuple, an inactive-and-unclassified
|
|
843
|
+
* HTLC (never funded vs claimed vs refunded), or an `isActive` transport
|
|
844
|
+
* failure (e.g. no Multicall3 on a dev chain) — is classified from one
|
|
845
|
+
* batched log read, lower-bounded by the swaps' creation-block estimate.
|
|
846
|
+
*
|
|
847
|
+
* A ref already latched on a spend is skipped entirely: terminal per leg,
|
|
848
|
+
* nothing left to learn.
|
|
849
|
+
*/
|
|
850
|
+
async #reconcileRefs(reader, chainId, refs) {
|
|
851
|
+
const open = refs.filter((r) => !this.#isSpent(r));
|
|
852
|
+
if (open.length === 0) return;
|
|
853
|
+
const fast = open.filter((r) => activeQuery(r) !== void 0);
|
|
854
|
+
let needLogs = open.filter((r) => activeQuery(r) === void 0);
|
|
855
|
+
if (fast.length > 0) {
|
|
856
|
+
try {
|
|
857
|
+
const active = await reader.isActiveBatch(
|
|
858
|
+
fast.map((r) => activeQuery(r))
|
|
859
|
+
);
|
|
860
|
+
for (const ref of fast) {
|
|
861
|
+
if (active.get(refQueryKey(ref)) === true)
|
|
862
|
+
this.#set(htlcKey(ref), "confirmed");
|
|
863
|
+
else needLogs.push(ref);
|
|
864
|
+
}
|
|
865
|
+
} catch (error) {
|
|
866
|
+
console.warn(
|
|
867
|
+
`EVM isActive check failed for chain ${chainId} \u2014 falling back to logs:`,
|
|
868
|
+
error
|
|
869
|
+
);
|
|
870
|
+
needLogs = open;
|
|
871
|
+
}
|
|
872
|
+
}
|
|
873
|
+
if (needLogs.length === 0) return;
|
|
874
|
+
const known = needLogs.filter((r) => r.createdAtMs !== void 0);
|
|
875
|
+
const unknown = needLogs.filter((r) => r.createdAtMs === void 0);
|
|
754
876
|
await Promise.all(
|
|
755
|
-
[
|
|
877
|
+
[
|
|
878
|
+
[known, this.#estimateFromBlock(chainId, known)],
|
|
879
|
+
[unknown, 0n]
|
|
880
|
+
].filter(([refs2]) => refs2.length > 0).map(
|
|
881
|
+
([refs2, fromBlock]) => this.#classifyFromLogs(reader, refs2, fromBlock)
|
|
882
|
+
)
|
|
756
883
|
);
|
|
757
884
|
}
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
885
|
+
/** Read the refs' lifecycle logs (from `fromBlock`) and set observations. */
|
|
886
|
+
async #classifyFromLogs(reader, refs, fromBlock) {
|
|
887
|
+
const events = await reader.getHtlcEventsBatch(
|
|
888
|
+
refs.map((r) => {
|
|
889
|
+
const full = activeQuery(r);
|
|
890
|
+
return {
|
|
891
|
+
htlc: r.htlc,
|
|
892
|
+
preimageHash: r.preimageHash,
|
|
893
|
+
claimAddress: r.claimAddress,
|
|
894
|
+
terms: full && {
|
|
895
|
+
amount: full.amount,
|
|
896
|
+
token: full.token,
|
|
897
|
+
sender: full.sender,
|
|
898
|
+
timelockSec: full.timelockSec
|
|
899
|
+
}
|
|
900
|
+
};
|
|
901
|
+
}),
|
|
902
|
+
fromBlock
|
|
763
903
|
);
|
|
764
|
-
const
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
904
|
+
for (const ref of refs) {
|
|
905
|
+
const { observation, preimage } = evmObservation(
|
|
906
|
+
events.get(refQueryKey(ref)) ?? [],
|
|
907
|
+
{ amount: ref.expectedAmount, token: ref.expectedToken }
|
|
908
|
+
);
|
|
909
|
+
const key = htlcKey(ref);
|
|
910
|
+
if (preimage) this.#preimages.set(key, preimage);
|
|
911
|
+
this.#set(key, observation);
|
|
912
|
+
}
|
|
913
|
+
}
|
|
914
|
+
#isSpent(ref) {
|
|
915
|
+
const obs = this.#obs.get(htlcKey(ref));
|
|
916
|
+
return obs === "spent_claim" || obs === "spent_refund";
|
|
917
|
+
}
|
|
918
|
+
/**
|
|
919
|
+
* The block to scan logs from: roughly where the OLDEST of the given swaps
|
|
920
|
+
* was created (an HTLC has no events before its swap existed). Only a lower
|
|
921
|
+
* bound, so estimation errs early — ×1.5 on elapsed time plus a fixed block
|
|
922
|
+
* margin. Falls back to genesis when the creation time, clock, or the
|
|
923
|
+
* chain's block time is unknown.
|
|
924
|
+
*/
|
|
925
|
+
#estimateFromBlock(chainId, refs) {
|
|
926
|
+
const clock = this.#now.get(chainId);
|
|
927
|
+
const avgBlockMs = AVG_BLOCK_MS[chainId];
|
|
928
|
+
const createdAts = refs.map((r) => r.createdAtMs ?? 0);
|
|
929
|
+
const oldestMs = Math.min(...createdAts);
|
|
930
|
+
if (!clock || !avgBlockMs || oldestMs <= 0) return 0n;
|
|
931
|
+
const elapsedMs = Math.max(0, clock.blockTimeMs - oldestMs);
|
|
932
|
+
const blocksBehind = BigInt(Math.ceil(elapsedMs / avgBlockMs * 1.5)) + SCAN_MARGIN_BLOCKS;
|
|
933
|
+
const from = clock.blockNumber - blocksBehind;
|
|
934
|
+
return from > 0n ? from : 0n;
|
|
771
935
|
}
|
|
772
936
|
/** Update an observation and notify on change; never downgrade a resolved spend. */
|
|
773
937
|
#set(key, observation) {
|
|
@@ -782,13 +946,31 @@ var EvmContractManager = class _EvmContractManager {
|
|
|
782
946
|
for (const listener of this.#listeners) listener(ref, observation);
|
|
783
947
|
}
|
|
784
948
|
};
|
|
949
|
+
function activeQuery(ref) {
|
|
950
|
+
if (ref.sender === void 0 || ref.expectedToken === void 0 || ref.timelockSec === void 0)
|
|
951
|
+
return void 0;
|
|
952
|
+
return {
|
|
953
|
+
htlc: ref.htlc,
|
|
954
|
+
preimageHash: ref.preimageHash,
|
|
955
|
+
amount: ref.expectedAmount,
|
|
956
|
+
token: ref.expectedToken,
|
|
957
|
+
sender: ref.sender,
|
|
958
|
+
claimAddress: ref.claimAddress,
|
|
959
|
+
timelockSec: ref.timelockSec
|
|
960
|
+
};
|
|
961
|
+
}
|
|
785
962
|
|
|
786
963
|
// src/contracts/evm-reader-viem.ts
|
|
787
964
|
import {
|
|
788
965
|
createPublicClient,
|
|
966
|
+
decodeEventLog,
|
|
967
|
+
decodeFunctionResult,
|
|
968
|
+
encodeFunctionData,
|
|
789
969
|
fallback,
|
|
790
970
|
http,
|
|
791
|
-
|
|
971
|
+
numberToHex,
|
|
972
|
+
parseAbiItem,
|
|
973
|
+
toEventSelector
|
|
792
974
|
} from "viem";
|
|
793
975
|
var DEFAULT_EVM_RPCS = {
|
|
794
976
|
// Polygon — viem's default (polygon.drpc.org) misbehaves for some calls, so
|
|
@@ -798,82 +980,185 @@ var DEFAULT_EVM_RPCS = {
|
|
|
798
980
|
"https://tenderly.rpc.polygon.community",
|
|
799
981
|
"https://polygon-bor-rpc.publicnode.com"
|
|
800
982
|
],
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
|
|
805
|
-
|
|
983
|
+
// No publicnode: it rate-limits by IP across its whole fleet (403s), and as
|
|
984
|
+
// a PRIMARY that costs a full retry cycle per read before failing over.
|
|
985
|
+
// Both entries verified for eth_getLogs + eth_call from a browser origin.
|
|
986
|
+
1: ["https://eth.drpc.org", "https://rpc.mevblocker.io"],
|
|
987
|
+
// The official gateway first; publicnode last (see above).
|
|
806
988
|
42161: [
|
|
807
|
-
"https://arbitrum
|
|
808
|
-
"https://
|
|
809
|
-
"https://
|
|
989
|
+
"https://arb1.arbitrum.io/rpc",
|
|
990
|
+
"https://arbitrum.drpc.org",
|
|
991
|
+
"https://arbitrum-one-rpc.publicnode.com"
|
|
810
992
|
]
|
|
811
993
|
};
|
|
812
994
|
var SWAP_CREATED = parseAbiItem(
|
|
813
|
-
"event SwapCreated(bytes32 indexed preimageHash, address indexed refundAddress, address indexed claimAddress, address token, uint256 amount, uint256 timelock)"
|
|
995
|
+
"event SwapCreated(bytes32 indexed preimageHash, address indexed refundAddress, address indexed claimAddress, address token, uint256 amount, uint256 timelock, bytes32 key)"
|
|
814
996
|
);
|
|
815
997
|
var SWAP_REDEEMED = parseAbiItem(
|
|
816
|
-
"event SwapRedeemed(bytes32 indexed preimageHash, bytes32 preimage)"
|
|
998
|
+
"event SwapRedeemed(bytes32 indexed preimageHash, bytes32 indexed key, bytes32 preimage)"
|
|
817
999
|
);
|
|
818
1000
|
var SWAP_REFUNDED = parseAbiItem(
|
|
819
|
-
"event SwapRefunded(bytes32 indexed preimageHash)"
|
|
1001
|
+
"event SwapRefunded(bytes32 indexed preimageHash, bytes32 indexed key)"
|
|
1002
|
+
);
|
|
1003
|
+
var HTLC_EVENTS_ABI = [SWAP_CREATED, SWAP_REDEEMED, SWAP_REFUNDED];
|
|
1004
|
+
var HTLC_EVENT_TOPICS = HTLC_EVENTS_ABI.map(toEventSelector);
|
|
1005
|
+
var IS_ACTIVE = parseAbiItem(
|
|
1006
|
+
"function isActive(bytes32 preimageHash, uint256 amount, address token, address sender, address claimAddress, uint256 timelock) view returns (bool)"
|
|
820
1007
|
);
|
|
1008
|
+
var MULTICALL3_ADDRESS = "0xcA11bde05977b3631167028862bE2a173976CA11";
|
|
1009
|
+
var AGGREGATE3 = parseAbiItem(
|
|
1010
|
+
"function aggregate3((address target, bool allowFailure, bytes callData)[] calls) payable returns ((bool success, bytes returnData)[] returnData)"
|
|
1011
|
+
);
|
|
1012
|
+
function activeKey(q) {
|
|
1013
|
+
return htlcQueryKey({
|
|
1014
|
+
htlc: q.htlc,
|
|
1015
|
+
preimageHash: q.preimageHash,
|
|
1016
|
+
claimAddress: q.claimAddress,
|
|
1017
|
+
terms: {
|
|
1018
|
+
amount: q.amount,
|
|
1019
|
+
token: q.token,
|
|
1020
|
+
sender: q.sender,
|
|
1021
|
+
timelockSec: q.timelockSec
|
|
1022
|
+
}
|
|
1023
|
+
});
|
|
1024
|
+
}
|
|
821
1025
|
function evmReaderFromClient(client) {
|
|
822
1026
|
return {
|
|
823
|
-
async
|
|
824
|
-
const
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
|
|
831
|
-
|
|
832
|
-
|
|
833
|
-
|
|
834
|
-
|
|
835
|
-
|
|
836
|
-
|
|
837
|
-
|
|
838
|
-
|
|
839
|
-
|
|
840
|
-
|
|
841
|
-
|
|
842
|
-
|
|
843
|
-
|
|
1027
|
+
async getHtlcEventsBatch(queries, fromBlock = 0n) {
|
|
1028
|
+
const results = /* @__PURE__ */ new Map();
|
|
1029
|
+
if (queries.length === 0) return results;
|
|
1030
|
+
for (const q of queries) results.set(htlcQueryKey(q), []);
|
|
1031
|
+
const logs = await client.request({
|
|
1032
|
+
method: "eth_getLogs",
|
|
1033
|
+
params: [
|
|
1034
|
+
{
|
|
1035
|
+
address: unique(queries.map((q) => q.htlc)),
|
|
1036
|
+
topics: [
|
|
1037
|
+
HTLC_EVENT_TOPICS,
|
|
1038
|
+
unique(queries.map((q) => q.preimageHash))
|
|
1039
|
+
],
|
|
1040
|
+
fromBlock: numberToHex(fromBlock)
|
|
1041
|
+
}
|
|
1042
|
+
]
|
|
1043
|
+
});
|
|
1044
|
+
const grouped = /* @__PURE__ */ new Map();
|
|
1045
|
+
for (const log of logs) {
|
|
1046
|
+
const decoded = tryDecode(log);
|
|
1047
|
+
if (!decoded) continue;
|
|
1048
|
+
const group = `${log.address.toLowerCase()}:${decoded.preimageHash.toLowerCase()}`;
|
|
1049
|
+
const existing = grouped.get(group);
|
|
1050
|
+
if (existing) existing.push(decoded);
|
|
1051
|
+
else grouped.set(group, [decoded]);
|
|
1052
|
+
}
|
|
1053
|
+
for (const query of queries) {
|
|
1054
|
+
const group = `${query.htlc.toLowerCase()}:${query.preimageHash.toLowerCase()}`;
|
|
1055
|
+
const events = results.get(htlcQueryKey(query));
|
|
1056
|
+
if (!events) continue;
|
|
1057
|
+
for (const decoded of grouped.get(group) ?? []) {
|
|
1058
|
+
const event = toHtlcEvent(decoded, query);
|
|
1059
|
+
if (event) events.push(event);
|
|
1060
|
+
}
|
|
1061
|
+
}
|
|
1062
|
+
return results;
|
|
1063
|
+
},
|
|
1064
|
+
async isActiveBatch(queries) {
|
|
1065
|
+
const results = /* @__PURE__ */ new Map();
|
|
1066
|
+
if (queries.length === 0) return results;
|
|
1067
|
+
const calldatas = queries.map(
|
|
1068
|
+
(q) => encodeFunctionData({
|
|
1069
|
+
abi: [IS_ACTIVE],
|
|
1070
|
+
args: [
|
|
1071
|
+
q.preimageHash,
|
|
1072
|
+
q.amount,
|
|
1073
|
+
q.token,
|
|
1074
|
+
q.sender,
|
|
1075
|
+
q.claimAddress,
|
|
1076
|
+
BigInt(q.timelockSec)
|
|
1077
|
+
]
|
|
844
1078
|
})
|
|
845
|
-
|
|
846
|
-
const
|
|
847
|
-
|
|
848
|
-
|
|
849
|
-
|
|
850
|
-
kind: "created",
|
|
851
|
-
amount: args?.amount ?? 0n,
|
|
852
|
-
token: args?.token ?? "0x"
|
|
1079
|
+
);
|
|
1080
|
+
const callOne = async (i) => {
|
|
1081
|
+
const { data } = await client.call({
|
|
1082
|
+
to: queries[i].htlc,
|
|
1083
|
+
data: calldatas[i]
|
|
853
1084
|
});
|
|
1085
|
+
results.set(activeKey(queries[i]), decodeIsActive(data));
|
|
1086
|
+
};
|
|
1087
|
+
if (queries.length === 1) {
|
|
1088
|
+
await callOne(0);
|
|
1089
|
+
return results;
|
|
854
1090
|
}
|
|
855
|
-
|
|
856
|
-
const
|
|
857
|
-
|
|
1091
|
+
try {
|
|
1092
|
+
const { data } = await client.call({
|
|
1093
|
+
to: MULTICALL3_ADDRESS,
|
|
1094
|
+
data: encodeFunctionData({
|
|
1095
|
+
abi: [AGGREGATE3],
|
|
1096
|
+
args: [
|
|
1097
|
+
queries.map((q, i) => ({
|
|
1098
|
+
target: q.htlc,
|
|
1099
|
+
allowFailure: true,
|
|
1100
|
+
callData: calldatas[i]
|
|
1101
|
+
}))
|
|
1102
|
+
]
|
|
1103
|
+
})
|
|
1104
|
+
});
|
|
1105
|
+
if (data === void 0 || data === "0x")
|
|
1106
|
+
throw new Error("empty multicall result");
|
|
1107
|
+
const decoded = decodeFunctionResult({
|
|
1108
|
+
abi: [AGGREGATE3],
|
|
1109
|
+
data
|
|
1110
|
+
});
|
|
1111
|
+
queries.forEach((q, i) => {
|
|
1112
|
+
const r = decoded[i];
|
|
1113
|
+
results.set(
|
|
1114
|
+
activeKey(q),
|
|
1115
|
+
r?.success === true && decodeIsActive(r.returnData)
|
|
1116
|
+
);
|
|
1117
|
+
});
|
|
1118
|
+
} catch {
|
|
1119
|
+
await Promise.all(queries.map((_, i) => callOne(i)));
|
|
858
1120
|
}
|
|
859
|
-
|
|
860
|
-
return events;
|
|
1121
|
+
return results;
|
|
861
1122
|
},
|
|
862
|
-
async
|
|
1123
|
+
async getLatestBlock() {
|
|
863
1124
|
const block = await client.getBlock();
|
|
864
|
-
return
|
|
865
|
-
|
|
866
|
-
|
|
867
|
-
|
|
1125
|
+
return {
|
|
1126
|
+
timeMs: Number(block.timestamp) * 1e3,
|
|
1127
|
+
number: block.number ?? 0n
|
|
1128
|
+
};
|
|
868
1129
|
}
|
|
869
1130
|
};
|
|
870
1131
|
}
|
|
1132
|
+
function decodeIsActive(data) {
|
|
1133
|
+
if (data === void 0 || data === "0x") return false;
|
|
1134
|
+
return decodeFunctionResult({ abi: [IS_ACTIVE], data }) === true;
|
|
1135
|
+
}
|
|
1136
|
+
function tryDecode(log) {
|
|
1137
|
+
try {
|
|
1138
|
+
const { eventName, args } = decodeEventLog({
|
|
1139
|
+
abi: HTLC_EVENTS_ABI,
|
|
1140
|
+
data: log.data,
|
|
1141
|
+
topics: log.topics
|
|
1142
|
+
});
|
|
1143
|
+
return { eventName, ...args };
|
|
1144
|
+
} catch {
|
|
1145
|
+
return void 0;
|
|
1146
|
+
}
|
|
1147
|
+
}
|
|
1148
|
+
function toHtlcEvent(decoded, query) {
|
|
1149
|
+
if (decoded.eventName === "SwapCreated") {
|
|
1150
|
+
if (decoded.claimAddress.toLowerCase() !== query.claimAddress.toLowerCase())
|
|
1151
|
+
return void 0;
|
|
1152
|
+
return { kind: "created", amount: decoded.amount, token: decoded.token };
|
|
1153
|
+
}
|
|
1154
|
+
return decoded.eventName === "SwapRedeemed" ? { kind: "redeemed", preimage: decoded.preimage } : { kind: "refunded" };
|
|
1155
|
+
}
|
|
1156
|
+
function unique(values) {
|
|
1157
|
+
return [...new Set(values)];
|
|
1158
|
+
}
|
|
871
1159
|
function createEvmRpcReader(rpcUrls) {
|
|
872
1160
|
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]);
|
|
1161
|
+
const transport = urls.length > 1 ? fallback(urls.map((url) => http(url))) : http(urls[0]);
|
|
877
1162
|
const client = createPublicClient({ transport });
|
|
878
1163
|
return evmReaderFromClient(client);
|
|
879
1164
|
}
|
|
@@ -931,6 +1216,10 @@ var SwapWorker = class {
|
|
|
931
1216
|
});
|
|
932
1217
|
})
|
|
933
1218
|
);
|
|
1219
|
+
if (this.#hintSource.onReconnect)
|
|
1220
|
+
this.#unsubs.push(
|
|
1221
|
+
this.#hintSource.onReconnect(() => this.#reverifyAllTracked())
|
|
1222
|
+
);
|
|
934
1223
|
this.#hintSource.start();
|
|
935
1224
|
this.#unsubs.push(
|
|
936
1225
|
this.#tracker.subscribeToActions(
|
|
@@ -948,6 +1237,17 @@ var SwapWorker = class {
|
|
|
948
1237
|
this.#retries.clear();
|
|
949
1238
|
this.#executing.clear();
|
|
950
1239
|
}
|
|
1240
|
+
/** Chain-reconcile every tracked swap (the post-outage catch-up sweep). */
|
|
1241
|
+
#reverifyAllTracked() {
|
|
1242
|
+
for (const swapId of this.#tracker.trackedSwapIds()) {
|
|
1243
|
+
void this.#tracker.applyHint(swapId).catch((error) => {
|
|
1244
|
+
console.warn(
|
|
1245
|
+
`SwapWorker: post-reconnect re-verify of ${swapId} failed:`,
|
|
1246
|
+
error
|
|
1247
|
+
);
|
|
1248
|
+
});
|
|
1249
|
+
}
|
|
1250
|
+
}
|
|
951
1251
|
#onActions(swapId, actions) {
|
|
952
1252
|
const recommended = actions.recommended;
|
|
953
1253
|
if (recommended === "none") {
|
|
@@ -956,7 +1256,7 @@ var SwapWorker = class {
|
|
|
956
1256
|
return;
|
|
957
1257
|
}
|
|
958
1258
|
this.#hintSource.subscribe([swapId]);
|
|
959
|
-
if (recommended && AUTO_EXECUTABLE.has(recommended)) {
|
|
1259
|
+
if (recommended && AUTO_EXECUTABLE.has(recommended) && this.#execute) {
|
|
960
1260
|
this.#autoExecute(swapId, actions);
|
|
961
1261
|
return;
|
|
962
1262
|
}
|
|
@@ -969,11 +1269,13 @@ var SwapWorker = class {
|
|
|
969
1269
|
this.#onActionRequired?.(swapId, actions);
|
|
970
1270
|
}
|
|
971
1271
|
#autoExecute(swapId, actions) {
|
|
1272
|
+
const execute = this.#execute;
|
|
1273
|
+
if (!execute) return;
|
|
972
1274
|
if (this.#executing.has(swapId)) return;
|
|
973
1275
|
const actionId = actions.recommended;
|
|
974
1276
|
if (!actionId) return;
|
|
975
1277
|
this.#executing.add(swapId);
|
|
976
|
-
void
|
|
1278
|
+
void execute(swapId, actionId).then(() => this.#cancelRetry(swapId)).catch((error) => {
|
|
977
1279
|
console.warn(`SwapWorker: ${actionId}(${swapId}) failed:`, error);
|
|
978
1280
|
this.#scheduleRetry(swapId, actions);
|
|
979
1281
|
}).finally(() => this.#executing.delete(swapId));
|
|
@@ -1038,14 +1340,17 @@ var WsStatusSource = class {
|
|
|
1038
1340
|
/**
|
|
1039
1341
|
* Ids we want but have no slot for. Promoted FIFO as slots free (the worker
|
|
1040
1342
|
* unsubscribes each swap when it reaches a terminal action, so they do free).
|
|
1041
|
-
* A
|
|
1042
|
-
* on the tracker's
|
|
1343
|
+
* A queued swap is not broken, only slower: it gets no hints, so it advances
|
|
1344
|
+
* on the tracker's at-risk reconciles and the worker's reconnect sweep.
|
|
1043
1345
|
*/
|
|
1044
1346
|
#pending = /* @__PURE__ */ new Set();
|
|
1045
1347
|
#listeners = /* @__PURE__ */ new Set();
|
|
1348
|
+
#reconnectListeners = /* @__PURE__ */ new Set();
|
|
1046
1349
|
#ws;
|
|
1047
1350
|
#open = false;
|
|
1048
1351
|
#stopped = false;
|
|
1352
|
+
/** A connect failed or the socket dropped — the next open is a RE-connect. */
|
|
1353
|
+
#hadDisruption = false;
|
|
1049
1354
|
#backoffMs;
|
|
1050
1355
|
#reconnectTimer;
|
|
1051
1356
|
constructor(options) {
|
|
@@ -1075,6 +1380,18 @@ var WsStatusSource = class {
|
|
|
1075
1380
|
this.#listeners.add(cb);
|
|
1076
1381
|
return () => this.#listeners.delete(cb);
|
|
1077
1382
|
}
|
|
1383
|
+
/**
|
|
1384
|
+
* Emit whenever the socket comes (back) up after a disruption — a drop, or a
|
|
1385
|
+
* first connect that had to be retried. NOT emitted on a pristine first open:
|
|
1386
|
+
* hints were never live before it, so there is no gap to recover from. While
|
|
1387
|
+
* the socket was down, pushed transitions were lost for good (the server only
|
|
1388
|
+
* snapshots on subscribe), so a consumer should re-verify its world against
|
|
1389
|
+
* the chain on this signal.
|
|
1390
|
+
*/
|
|
1391
|
+
onReconnect(cb) {
|
|
1392
|
+
this.#reconnectListeners.add(cb);
|
|
1393
|
+
return () => this.#reconnectListeners.delete(cb);
|
|
1394
|
+
}
|
|
1078
1395
|
/**
|
|
1079
1396
|
* Track these swap ids — subscribe on the socket now if open, else on next open.
|
|
1080
1397
|
*
|
|
@@ -1138,6 +1455,10 @@ var WsStatusSource = class {
|
|
|
1138
1455
|
this.#open = true;
|
|
1139
1456
|
if (this.#active.size > 0)
|
|
1140
1457
|
this.#sendFrame("subscribe", [...this.#active]);
|
|
1458
|
+
if (this.#hadDisruption) {
|
|
1459
|
+
this.#hadDisruption = false;
|
|
1460
|
+
for (const cb of this.#reconnectListeners) cb();
|
|
1461
|
+
}
|
|
1141
1462
|
};
|
|
1142
1463
|
ws.onmessage = (event) => this.#handleMessage(event.data);
|
|
1143
1464
|
ws.onclose = () => this.#onDisconnect();
|
|
@@ -1146,6 +1467,7 @@ var WsStatusSource = class {
|
|
|
1146
1467
|
#onDisconnect() {
|
|
1147
1468
|
this.#open = false;
|
|
1148
1469
|
this.#ws = void 0;
|
|
1470
|
+
this.#hadDisruption = true;
|
|
1149
1471
|
if (this.#stopped || this.#reconnectTimer) return;
|
|
1150
1472
|
const delay = this.#backoffMs;
|
|
1151
1473
|
this.#backoffMs = Math.min(this.#backoffMs * 2, this.#reconnectMaxMs);
|
|
@@ -1198,6 +1520,11 @@ function defaultWebSocketFactory(url) {
|
|
|
1198
1520
|
|
|
1199
1521
|
// src/contracts/arkade-vhtlc.ts
|
|
1200
1522
|
import { VHTLC, VHTLCContractHandler } from "@arkade-os/sdk";
|
|
1523
|
+
import {
|
|
1524
|
+
ARKADE_HTLC_SCRIPT_VERSION_LEGACY,
|
|
1525
|
+
ARKADE_HTLC_SCRIPT_VERSION_STRICT,
|
|
1526
|
+
StrictVhtlcScript
|
|
1527
|
+
} from "@lendasat/lendaswap-sdk-pure";
|
|
1201
1528
|
import { ripemd160 as ripemd1602 } from "@noble/hashes/legacy.js";
|
|
1202
1529
|
import { hex as hex5 } from "@scure/base";
|
|
1203
1530
|
function strip0x(value) {
|
|
@@ -1224,7 +1551,18 @@ function buildArkadeVhtlcRef(input) {
|
|
|
1224
1551
|
input.unilateralRefundWithoutReceiverDelay
|
|
1225
1552
|
)
|
|
1226
1553
|
};
|
|
1227
|
-
const vhtlc =
|
|
1554
|
+
const vhtlc = (() => {
|
|
1555
|
+
switch (input.arkadeHtlcScriptVersion ?? ARKADE_HTLC_SCRIPT_VERSION_LEGACY) {
|
|
1556
|
+
case ARKADE_HTLC_SCRIPT_VERSION_LEGACY:
|
|
1557
|
+
return new VHTLC.Script(params);
|
|
1558
|
+
case ARKADE_HTLC_SCRIPT_VERSION_STRICT:
|
|
1559
|
+
return new StrictVhtlcScript(params);
|
|
1560
|
+
default:
|
|
1561
|
+
throw new Error(
|
|
1562
|
+
`Unsupported Arkade HTLC script version: ${input.arkadeHtlcScriptVersion}`
|
|
1563
|
+
);
|
|
1564
|
+
}
|
|
1565
|
+
})();
|
|
1228
1566
|
return {
|
|
1229
1567
|
ledger: "arkade",
|
|
1230
1568
|
script: hex5.encode(vhtlc.pkScript),
|
|
@@ -1237,6 +1575,18 @@ function buildArkadeVhtlcRef(input) {
|
|
|
1237
1575
|
|
|
1238
1576
|
// src/tracker/from-swap.ts
|
|
1239
1577
|
var ms = (seconds2) => seconds2 * 1e3;
|
|
1578
|
+
var MAINNET_LOCKED_TOKEN = {
|
|
1579
|
+
1: "0x18084fba666a33d37592fa2633fd49a74dd93a88",
|
|
1580
|
+
// tBTC
|
|
1581
|
+
42161: "0x6c84a8f1c29108F47a79964b5Fe888D4f4D0dE40",
|
|
1582
|
+
// tBTC
|
|
1583
|
+
137: "0x1BFD67037B42Cf73acF2047067bd4F2C47D9BfD6"
|
|
1584
|
+
// WBTC
|
|
1585
|
+
};
|
|
1586
|
+
function lockedTokenFallback(chainId, network) {
|
|
1587
|
+
const net = network?.toLowerCase();
|
|
1588
|
+
return net === "bitcoin" || net === "mainnet" ? MAINNET_LOCKED_TOKEN[chainId] : void 0;
|
|
1589
|
+
}
|
|
1240
1590
|
var ensure0x = (value) => value.startsWith("0x") ? value : `0x${value}`;
|
|
1241
1591
|
var strip0x2 = (value) => value.startsWith("0x") ? value.slice(2) : value;
|
|
1242
1592
|
function arkadeLeg(r, address, expectedSats) {
|
|
@@ -1250,10 +1600,12 @@ function arkadeLeg(r, address, expectedSats) {
|
|
|
1250
1600
|
unilateralClaimDelay: r.unilateral_claim_delay,
|
|
1251
1601
|
unilateralRefundDelay: r.unilateral_refund_delay,
|
|
1252
1602
|
unilateralRefundWithoutReceiverDelay: r.unilateral_refund_without_receiver_delay,
|
|
1253
|
-
expectedSats
|
|
1603
|
+
expectedSats,
|
|
1604
|
+
arkadeHtlcScriptVersion: r.arkade_htlc_script_version
|
|
1254
1605
|
});
|
|
1255
1606
|
}
|
|
1256
1607
|
function evmLeg(args) {
|
|
1608
|
+
const createdAtMs = args.createdAt ? Date.parse(args.createdAt) : Number.NaN;
|
|
1257
1609
|
return {
|
|
1258
1610
|
ledger: "evm",
|
|
1259
1611
|
chainId: args.chainId,
|
|
@@ -1261,15 +1613,20 @@ function evmLeg(args) {
|
|
|
1261
1613
|
preimageHash: ensure0x(args.hashLock),
|
|
1262
1614
|
claimAddress: ensure0x(args.claimAddress),
|
|
1263
1615
|
expectedAmount: BigInt(args.expectedSats),
|
|
1264
|
-
expectedToken: args.token ? ensure0x(args.token) : void 0
|
|
1616
|
+
expectedToken: args.token ? ensure0x(args.token) : void 0,
|
|
1617
|
+
sender: args.sender ? ensure0x(args.sender) : void 0,
|
|
1618
|
+
timelockSec: args.timelockSec,
|
|
1619
|
+
createdAtMs: Number.isNaN(createdAtMs) ? void 0 : createdAtMs
|
|
1265
1620
|
};
|
|
1266
1621
|
}
|
|
1267
|
-
|
|
1622
|
+
var CLIENT_FUNDING_MIN_CONFIRMATIONS = 1;
|
|
1623
|
+
function bitcoinLeg(address, hashLock, expectedSats, minConfirmations) {
|
|
1268
1624
|
return {
|
|
1269
1625
|
ledger: "bitcoin",
|
|
1270
1626
|
address,
|
|
1271
1627
|
preimageHash: strip0x2(hashLock),
|
|
1272
|
-
expectedSats
|
|
1628
|
+
expectedSats,
|
|
1629
|
+
minConfirmations
|
|
1273
1630
|
};
|
|
1274
1631
|
}
|
|
1275
1632
|
function swapToTracked(stored) {
|
|
@@ -1287,7 +1644,11 @@ function swapToTracked(stored) {
|
|
|
1287
1644
|
claimAddress: r.client_evm_address,
|
|
1288
1645
|
// the client claims the server's EVM HTLC
|
|
1289
1646
|
expectedSats: r.evm_expected_sats,
|
|
1290
|
-
token: r.wbtc_address
|
|
1647
|
+
token: r.wbtc_address,
|
|
1648
|
+
sender: r.server_evm_address,
|
|
1649
|
+
// the server funded it
|
|
1650
|
+
timelockSec: r.evm_refund_locktime,
|
|
1651
|
+
createdAt: r.created_at
|
|
1291
1652
|
}),
|
|
1292
1653
|
clientRefundLocktime: ms(r.vhtlc_refund_locktime),
|
|
1293
1654
|
serverRefundLocktime: ms(r.evm_refund_locktime)
|
|
@@ -1302,8 +1663,14 @@ function swapToTracked(stored) {
|
|
|
1302
1663
|
hashLock: r.hash_lock,
|
|
1303
1664
|
claimAddress: r.server_evm_address,
|
|
1304
1665
|
// the server claims the client's EVM HTLC
|
|
1305
|
-
expectedSats: r.evm_expected_sats
|
|
1306
|
-
// evm_to_arkade doesn't expose the locked token —
|
|
1666
|
+
expectedSats: r.evm_expected_sats,
|
|
1667
|
+
// evm_to_arkade doesn't expose the locked token — fall back to the
|
|
1668
|
+
// per-chain mainnet constant so the isActive tuple is complete.
|
|
1669
|
+
token: lockedTokenFallback(r.evm_chain_id, r.network),
|
|
1670
|
+
sender: r.client_evm_address,
|
|
1671
|
+
// the client funded it
|
|
1672
|
+
timelockSec: r.evm_refund_locktime,
|
|
1673
|
+
createdAt: r.created_at
|
|
1307
1674
|
}),
|
|
1308
1675
|
serverHtlc: arkadeLeg(r, r.btc_vhtlc_address, Number(r.target_amount)),
|
|
1309
1676
|
clientRefundLocktime: ms(r.evm_refund_locktime),
|
|
@@ -1316,7 +1683,8 @@ function swapToTracked(stored) {
|
|
|
1316
1683
|
clientHtlc: bitcoinLeg(
|
|
1317
1684
|
r.btc_htlc_address,
|
|
1318
1685
|
r.evm_hash_lock,
|
|
1319
|
-
Number(r.source_amount)
|
|
1686
|
+
Number(r.source_amount),
|
|
1687
|
+
CLIENT_FUNDING_MIN_CONFIRMATIONS
|
|
1320
1688
|
),
|
|
1321
1689
|
serverHtlc: evmLeg({
|
|
1322
1690
|
chainId: r.evm_chain_id,
|
|
@@ -1325,7 +1693,11 @@ function swapToTracked(stored) {
|
|
|
1325
1693
|
claimAddress: r.client_evm_address,
|
|
1326
1694
|
// the client claims the server's EVM HTLC
|
|
1327
1695
|
expectedSats: r.evm_expected_sats,
|
|
1328
|
-
token: r.wbtc_address
|
|
1696
|
+
token: r.wbtc_address,
|
|
1697
|
+
sender: r.server_evm_address,
|
|
1698
|
+
// the server funded it
|
|
1699
|
+
timelockSec: r.evm_refund_locktime,
|
|
1700
|
+
createdAt: r.created_at
|
|
1329
1701
|
}),
|
|
1330
1702
|
clientRefundLocktime: ms(r.btc_refund_locktime),
|
|
1331
1703
|
serverRefundLocktime: ms(r.evm_refund_locktime)
|
|
@@ -1341,7 +1713,11 @@ function swapToTracked(stored) {
|
|
|
1341
1713
|
claimAddress: r.server_evm_address,
|
|
1342
1714
|
// the server claims the client's EVM HTLC
|
|
1343
1715
|
expectedSats: r.evm_expected_sats,
|
|
1344
|
-
token: r.wbtc_address
|
|
1716
|
+
token: r.wbtc_address,
|
|
1717
|
+
sender: r.client_evm_address,
|
|
1718
|
+
// the client funded it
|
|
1719
|
+
timelockSec: r.evm_refund_locktime,
|
|
1720
|
+
createdAt: r.created_at
|
|
1345
1721
|
}),
|
|
1346
1722
|
serverHtlc: bitcoinLeg(
|
|
1347
1723
|
r.btc_htlc_address,
|
|
@@ -1361,7 +1737,8 @@ function swapToTracked(stored) {
|
|
|
1361
1737
|
clientHtlc: bitcoinLeg(
|
|
1362
1738
|
r.btc_htlc_address,
|
|
1363
1739
|
r.hash_lock,
|
|
1364
|
-
Number(r.source_amount)
|
|
1740
|
+
Number(r.source_amount),
|
|
1741
|
+
CLIENT_FUNDING_MIN_CONFIRMATIONS
|
|
1365
1742
|
),
|
|
1366
1743
|
serverHtlc: buildArkadeVhtlcRef({
|
|
1367
1744
|
senderPk: r.server_vhtlc_pk,
|
|
@@ -1375,7 +1752,8 @@ function swapToTracked(stored) {
|
|
|
1375
1752
|
unilateralClaimDelay: r.unilateral_claim_delay,
|
|
1376
1753
|
unilateralRefundDelay: r.unilateral_refund_delay,
|
|
1377
1754
|
unilateralRefundWithoutReceiverDelay: r.unilateral_refund_without_receiver_delay,
|
|
1378
|
-
expectedSats: Number(r.target_amount)
|
|
1755
|
+
expectedSats: Number(r.target_amount),
|
|
1756
|
+
arkadeHtlcScriptVersion: r.arkade_htlc_script_version
|
|
1379
1757
|
}),
|
|
1380
1758
|
clientRefundLocktime: ms(r.btc_refund_locktime),
|
|
1381
1759
|
serverRefundLocktime: ms(r.vhtlc_refund_locktime)
|
|
@@ -1409,8 +1787,14 @@ function swapToTracked(stored) {
|
|
|
1409
1787
|
hashLock: r.hash_lock,
|
|
1410
1788
|
claimAddress: r.server_evm_address,
|
|
1411
1789
|
// the server claims the client's leg
|
|
1412
|
-
expectedSats: r.evm_expected_sats
|
|
1413
|
-
// evm_to_lightning doesn't expose the locked token —
|
|
1790
|
+
expectedSats: r.evm_expected_sats,
|
|
1791
|
+
// evm_to_lightning doesn't expose the locked token — fall back to the
|
|
1792
|
+
// per-chain mainnet constant so the isActive tuple is complete.
|
|
1793
|
+
token: lockedTokenFallback(r.evm_chain_id, r.network),
|
|
1794
|
+
sender: r.client_evm_address,
|
|
1795
|
+
// the client funded it
|
|
1796
|
+
timelockSec: r.evm_refund_locktime,
|
|
1797
|
+
createdAt: r.created_at
|
|
1414
1798
|
}),
|
|
1415
1799
|
clientRefundLocktime: ms(r.evm_refund_locktime),
|
|
1416
1800
|
serverRefundLocktime: 0
|
|
@@ -1443,7 +1827,11 @@ function swapToTracked(stored) {
|
|
|
1443
1827
|
claimAddress: r.client_evm_address,
|
|
1444
1828
|
// the client claims the server's leg
|
|
1445
1829
|
expectedSats: r.evm_expected_sats,
|
|
1446
|
-
token: r.wbtc_address
|
|
1830
|
+
token: r.wbtc_address,
|
|
1831
|
+
sender: r.server_evm_address,
|
|
1832
|
+
// the server funded it
|
|
1833
|
+
timelockSec: r.evm_refund_locktime,
|
|
1834
|
+
createdAt: r.created_at
|
|
1447
1835
|
}),
|
|
1448
1836
|
clientRefundLocktime: 0,
|
|
1449
1837
|
// no on-chain client leg
|
|
@@ -1462,11 +1850,14 @@ var SwapTracker = class {
|
|
|
1462
1850
|
#lastActions = /* @__PURE__ */ new Map();
|
|
1463
1851
|
#subscribers = /* @__PURE__ */ new Set();
|
|
1464
1852
|
#refreshIntervalMs;
|
|
1853
|
+
#atRiskReconcileIntervalMs;
|
|
1854
|
+
#lastAtRiskReconcileAt = 0;
|
|
1465
1855
|
#eventUnsubs = [];
|
|
1466
1856
|
#timer;
|
|
1467
1857
|
constructor(managers, options) {
|
|
1468
1858
|
this.#managers = managers;
|
|
1469
1859
|
this.#refreshIntervalMs = options?.refreshIntervalMs ?? 0;
|
|
1860
|
+
this.#atRiskReconcileIntervalMs = options?.atRiskReconcileIntervalMs ?? 6e4;
|
|
1470
1861
|
}
|
|
1471
1862
|
/**
|
|
1472
1863
|
* Whether every on-chain leg of `swap` is observable — i.e. the tracker can
|
|
@@ -1496,6 +1887,7 @@ var SwapTracker = class {
|
|
|
1496
1887
|
}
|
|
1497
1888
|
await this.#refreshManagers();
|
|
1498
1889
|
this.#recomputeAll();
|
|
1890
|
+
this.#lastAtRiskReconcileAt = Date.now();
|
|
1499
1891
|
if (this.#refreshIntervalMs > 0)
|
|
1500
1892
|
this.#timer = setInterval(
|
|
1501
1893
|
() => void this.#tick(),
|
|
@@ -1535,25 +1927,73 @@ var SwapTracker = class {
|
|
|
1535
1927
|
await Promise.all([...managers].map((manager) => manager.refresh()));
|
|
1536
1928
|
this.#recompute(swap);
|
|
1537
1929
|
}
|
|
1930
|
+
/**
|
|
1931
|
+
* The periodic tick: recompute every swap from extrapolated clocks (pure
|
|
1932
|
+
* local work), and — at most every `atRiskReconcileIntervalMs` — re-read the
|
|
1933
|
+
* chain for at-risk swaps. This is the trust boundary: the happy path rides
|
|
1934
|
+
* server hints, but once the client's funds sit in an HTLC, refund
|
|
1935
|
+
* availability (and the counterparty leg's state) is verified against the
|
|
1936
|
+
* chain on a cadence the server can't influence.
|
|
1937
|
+
*/
|
|
1538
1938
|
async #tick() {
|
|
1539
|
-
|
|
1939
|
+
const now = Date.now();
|
|
1940
|
+
if (now - this.#lastAtRiskReconcileAt >= this.#atRiskReconcileIntervalMs) {
|
|
1941
|
+
this.#lastAtRiskReconcileAt = now;
|
|
1942
|
+
const legs = [...this.#swaps.values()].filter((swap) => this.#isAtRisk(swap)).flatMap((swap) => legsOf(swap));
|
|
1943
|
+
await Promise.all(
|
|
1944
|
+
legs.map(
|
|
1945
|
+
(leg) => this.#managerFor(leg).reconcile(leg).catch((error) => {
|
|
1946
|
+
console.warn("SwapTracker: at-risk reconcile failed:", error);
|
|
1947
|
+
})
|
|
1948
|
+
)
|
|
1949
|
+
);
|
|
1950
|
+
}
|
|
1540
1951
|
this.#recomputeAll();
|
|
1541
1952
|
}
|
|
1542
1953
|
/**
|
|
1543
|
-
*
|
|
1544
|
-
*
|
|
1545
|
-
*
|
|
1546
|
-
*
|
|
1954
|
+
* Whether a swap needs chain-verified monitoring independent of the server:
|
|
1955
|
+
* its client leg holds (or held) funds on-chain, so knowing when a refund
|
|
1956
|
+
* unlocks — or that the counterparty resolved a leg — must not depend on a
|
|
1957
|
+
* hint arriving. An unfunded client leg (`absent`) is server-hint territory;
|
|
1958
|
+
* an unseeded one (`undefined`, e.g. the prime failed on a flaky RPC) counts
|
|
1959
|
+
* as at-risk so the tick heals it. A swap with no on-chain client leg
|
|
1960
|
+
* (paying via Lightning) is at-risk while tracked: once the invoice is paid
|
|
1961
|
+
* there is no on-chain trace of the client's exposure, and the server leg is
|
|
1962
|
+
* the only thing left to watch.
|
|
1963
|
+
*/
|
|
1964
|
+
#isAtRisk(swap) {
|
|
1965
|
+
if (!swap.clientHtlc) return true;
|
|
1966
|
+
const obs = this.#managerFor(swap.clientHtlc).getState(swap.clientHtlc);
|
|
1967
|
+
return obs !== "absent";
|
|
1968
|
+
}
|
|
1969
|
+
/**
|
|
1970
|
+
* Refresh the managers that have a registered leg — seeding their clocks and
|
|
1971
|
+
* reconciling observations (the on-demand full read backing startTracking's
|
|
1972
|
+
* prime and track()'s seed). Managers with nothing tracked are skipped:
|
|
1973
|
+
* refreshing them still hits their clock source (e.g. Arkade/Bitcoin MTP via
|
|
1974
|
+
* the API), so a swap that doesn't touch a ledger can't fail on that
|
|
1547
1975
|
* ledger's endpoint being down (the default client builds every manager).
|
|
1548
1976
|
*/
|
|
1549
1977
|
async #refreshManagers() {
|
|
1550
1978
|
const active = /* @__PURE__ */ new Set();
|
|
1551
1979
|
for (const swap of this.#swaps.values())
|
|
1552
1980
|
for (const leg of legsOf(swap)) active.add(leg.ledger);
|
|
1553
|
-
const
|
|
1554
|
-
|
|
1981
|
+
const byManager = /* @__PURE__ */ new Map();
|
|
1982
|
+
for (const ledger of active) {
|
|
1983
|
+
const manager = this.#managers.get(ledger);
|
|
1984
|
+
if (manager && !byManager.has(manager)) byManager.set(manager, ledger);
|
|
1985
|
+
}
|
|
1986
|
+
const ledgers = [...byManager.values()];
|
|
1987
|
+
const results = await Promise.allSettled(
|
|
1988
|
+
[...byManager.keys()].map((manager) => manager.refresh())
|
|
1555
1989
|
);
|
|
1556
|
-
|
|
1990
|
+
results.forEach((result, index) => {
|
|
1991
|
+
if (result.status === "rejected")
|
|
1992
|
+
console.warn(
|
|
1993
|
+
`SwapTracker: refreshing ${ledgers[index]} failed:`,
|
|
1994
|
+
result.reason
|
|
1995
|
+
);
|
|
1996
|
+
});
|
|
1557
1997
|
}
|
|
1558
1998
|
/** The swap ids currently tracked — e.g. to subscribe them to a hint feed. */
|
|
1559
1999
|
trackedSwapIds() {
|
|
@@ -1569,9 +2009,17 @@ var SwapTracker = class {
|
|
|
1569
2009
|
async applyHint(swapId, opts) {
|
|
1570
2010
|
const swap = this.#swaps.get(swapId);
|
|
1571
2011
|
if (!swap) return;
|
|
1572
|
-
|
|
1573
|
-
|
|
2012
|
+
const legs = legsOf(swap);
|
|
2013
|
+
const results = await Promise.allSettled(
|
|
2014
|
+
legs.map((leg) => this.#managerFor(leg).reconcile(leg))
|
|
1574
2015
|
);
|
|
2016
|
+
results.forEach((result, index) => {
|
|
2017
|
+
if (result.status === "rejected")
|
|
2018
|
+
console.warn(
|
|
2019
|
+
`SwapTracker: hint reconcile failed for ${legs[index].ledger}:`,
|
|
2020
|
+
result.reason
|
|
2021
|
+
);
|
|
2022
|
+
});
|
|
1575
2023
|
this.#recompute(swap, opts?.force ?? false);
|
|
1576
2024
|
}
|
|
1577
2025
|
/** Notify `cb` of the current action for each tracked swap, then on every change. */
|
|
@@ -1629,15 +2077,21 @@ var SwapTracker = class {
|
|
|
1629
2077
|
}
|
|
1630
2078
|
const status = deriveSwapStatus({ clientHtlc, serverHtlc });
|
|
1631
2079
|
if (status === void 0) return;
|
|
1632
|
-
const actions =
|
|
1633
|
-
|
|
1634
|
-
|
|
1635
|
-
|
|
1636
|
-
|
|
1637
|
-
|
|
1638
|
-
|
|
1639
|
-
|
|
1640
|
-
|
|
2080
|
+
const actions = {
|
|
2081
|
+
...deriveSwapActions({
|
|
2082
|
+
status,
|
|
2083
|
+
clientChainNow,
|
|
2084
|
+
serverChainNow,
|
|
2085
|
+
clientRefundLocktime: swap.clientRefundLocktime,
|
|
2086
|
+
serverRefundLocktime: swap.serverRefundLocktime,
|
|
2087
|
+
// Pay-on-Lightning swaps have no client-funded on-chain leg.
|
|
2088
|
+
clientFunds: swap.clientHtlc !== void 0
|
|
2089
|
+
}),
|
|
2090
|
+
// Expose the raw observations so consumers can render progress from
|
|
2091
|
+
// chain facts (which leg is funded/spent) even when the server is
|
|
2092
|
+
// stale/unreachable — without interpreting the SwapStatus enum.
|
|
2093
|
+
observations: { clientHtlc, serverHtlc }
|
|
2094
|
+
};
|
|
1641
2095
|
const previous = this.#lastActions.get(swap.swapId);
|
|
1642
2096
|
if (!force && previous && JSON.stringify(previous) === JSON.stringify(actions))
|
|
1643
2097
|
return;
|
|
@@ -1668,7 +2122,9 @@ var Client = class {
|
|
|
1668
2122
|
#tracker;
|
|
1669
2123
|
/** Hint-driven auto-claim worker; only built when `withAutoClaim` opted in. */
|
|
1670
2124
|
#worker;
|
|
1671
|
-
#
|
|
2125
|
+
#startPromise;
|
|
2126
|
+
/** Swap ids whose settled status was already re-fetched this session. */
|
|
2127
|
+
#settledSyncDone = /* @__PURE__ */ new Set();
|
|
1672
2128
|
constructor(...args) {
|
|
1673
2129
|
if (args[0] instanceof LegacyClient) {
|
|
1674
2130
|
this.#legacy = args[0];
|
|
@@ -2002,33 +2458,69 @@ var Client = class {
|
|
|
2002
2458
|
* Subscribe with {@link subscribeToActions}; release with
|
|
2003
2459
|
* {@link stopTracking}.
|
|
2004
2460
|
*/
|
|
2005
|
-
|
|
2461
|
+
startTracking() {
|
|
2006
2462
|
if (!this.#tracking.enabled)
|
|
2007
|
-
|
|
2008
|
-
|
|
2463
|
+
return Promise.reject(
|
|
2464
|
+
new Error(
|
|
2465
|
+
"tracking is disabled \u2014 remove .withoutTracking() to enable it"
|
|
2466
|
+
)
|
|
2009
2467
|
);
|
|
2010
|
-
|
|
2011
|
-
|
|
2468
|
+
this.#startPromise ??= this.#doStartTracking().catch((error) => {
|
|
2469
|
+
this.#startPromise = void 0;
|
|
2470
|
+
throw error;
|
|
2471
|
+
});
|
|
2472
|
+
return this.#startPromise;
|
|
2473
|
+
}
|
|
2474
|
+
async #doStartTracking() {
|
|
2012
2475
|
try {
|
|
2013
2476
|
const tracker = new SwapTracker(await this.#ensureManagers(), {
|
|
2014
2477
|
refreshIntervalMs: this.#tracking.refreshIntervalMs ?? 5e3
|
|
2015
2478
|
});
|
|
2016
2479
|
this.#tracker = tracker;
|
|
2017
2480
|
await tracker.startTracking(await this.#loadTrackedSwaps());
|
|
2481
|
+
tracker.subscribeToActions(
|
|
2482
|
+
(swapId, actions) => this.#syncSettledStatus(swapId, actions)
|
|
2483
|
+
);
|
|
2018
2484
|
this.#startWorker(tracker);
|
|
2019
2485
|
} catch (error) {
|
|
2020
2486
|
this.#worker?.stop();
|
|
2021
2487
|
this.#worker = void 0;
|
|
2022
2488
|
this.#tracker?.stop();
|
|
2023
2489
|
this.#tracker = void 0;
|
|
2024
|
-
this.#started = false;
|
|
2025
2490
|
throw error;
|
|
2026
2491
|
}
|
|
2027
2492
|
}
|
|
2493
|
+
/**
|
|
2494
|
+
* The chain says this swap is finished (`none`) — if the STORED status still
|
|
2495
|
+
* reads active, re-fetch and persist it so the next session's
|
|
2496
|
+
* {@link #loadTrackedSwaps} settled-filter catches it (instead of tracking
|
|
2497
|
+
* and scanning the swap once per session forever). Fire-and-forget with a
|
|
2498
|
+
* per-session dedupe: a failed fetch just means one more one-scan round next
|
|
2499
|
+
* session, which is exactly the current behavior. A swap whose stored status
|
|
2500
|
+
* is already settled costs nothing (no server call).
|
|
2501
|
+
*/
|
|
2502
|
+
#syncSettledStatus(swapId, actions) {
|
|
2503
|
+
if (actions.recommended !== "none") return;
|
|
2504
|
+
if (this.#settledSyncDone.has(swapId)) return;
|
|
2505
|
+
this.#settledSyncDone.add(swapId);
|
|
2506
|
+
void (async () => {
|
|
2507
|
+
const stored = (await this.listAllSwaps()).find(
|
|
2508
|
+
(s) => s.response.id === swapId
|
|
2509
|
+
);
|
|
2510
|
+
if (!stored || SETTLED_STORED_STATUSES.has(stored.response.status))
|
|
2511
|
+
return;
|
|
2512
|
+
await this.getSwap(swapId, { updateStorage: true });
|
|
2513
|
+
})().catch((error) => {
|
|
2514
|
+
console.warn(
|
|
2515
|
+
`Client: refreshing settled status of swap ${swapId} failed:`,
|
|
2516
|
+
error
|
|
2517
|
+
);
|
|
2518
|
+
});
|
|
2519
|
+
}
|
|
2028
2520
|
/** Every stored swap whose ledgers are observable, mapped to a {@link TrackedSwap}. */
|
|
2029
2521
|
async #loadTrackedSwaps() {
|
|
2030
2522
|
const swaps = await this.listAllSwaps();
|
|
2031
|
-
return swaps.map(swapToTracked).filter((s) => s !== void 0);
|
|
2523
|
+
return swaps.filter((s) => !SETTLED_STORED_STATUSES.has(s.response.status)).map(swapToTracked).filter((s) => s !== void 0);
|
|
2032
2524
|
}
|
|
2033
2525
|
/**
|
|
2034
2526
|
* Run a create, then fold the new swap into tracking.
|
|
@@ -2090,27 +2582,29 @@ var Client = class {
|
|
|
2090
2582
|
}
|
|
2091
2583
|
}
|
|
2092
2584
|
/**
|
|
2093
|
-
*
|
|
2094
|
-
*
|
|
2095
|
-
*
|
|
2096
|
-
*
|
|
2097
|
-
*
|
|
2585
|
+
* Wire the server status WebSocket into a {@link SwapWorker} whenever a server
|
|
2586
|
+
* URL is configured: pushed status transitions trigger a targeted chain
|
|
2587
|
+
* re-verify (`applyHint`), which is what lets the chain pollers stay slow.
|
|
2588
|
+
* Auto-EXECUTION on top of that is opt-in: only with `withAutoClaim` does the
|
|
2589
|
+
* worker get an `execute` (claims) and an `onActionRequired` surface —
|
|
2590
|
+
* otherwise it runs observe-only, hints in, no spends.
|
|
2098
2591
|
*/
|
|
2099
2592
|
#startWorker(tracker) {
|
|
2593
|
+
const serverUrl = this.#tracking.serverUrl;
|
|
2594
|
+
if (!serverUrl) return;
|
|
2100
2595
|
const autoClaim = this.#tracking.autoClaim;
|
|
2101
|
-
|
|
2102
|
-
if (!this.baseUrl) return;
|
|
2103
|
-
const hintSource = new WsStatusSource({ serverUrl: this.baseUrl });
|
|
2596
|
+
const hintSource = new WsStatusSource({ serverUrl });
|
|
2104
2597
|
const worker = new SwapWorker({
|
|
2105
2598
|
tracker,
|
|
2106
2599
|
hintSource,
|
|
2107
|
-
execute: async (swapId, actionId) => {
|
|
2600
|
+
execute: autoClaim ? async (swapId, actionId) => {
|
|
2108
2601
|
if (actionId !== "claim")
|
|
2109
2602
|
throw new Error(`refusing to auto-run action '${actionId}'`);
|
|
2110
2603
|
const result = await this.claim(swapId);
|
|
2111
|
-
if (!result.success)
|
|
2112
|
-
|
|
2113
|
-
|
|
2604
|
+
if (!result.success)
|
|
2605
|
+
throw new Error(result.message ?? "claim failed");
|
|
2606
|
+
} : void 0,
|
|
2607
|
+
onActionRequired: autoClaim?.onActionRequired
|
|
2114
2608
|
});
|
|
2115
2609
|
this.#worker = worker;
|
|
2116
2610
|
worker.start();
|
|
@@ -2131,7 +2625,7 @@ var Client = class {
|
|
|
2131
2625
|
this.#worker = void 0;
|
|
2132
2626
|
this.#tracker?.stop();
|
|
2133
2627
|
this.#tracker = void 0;
|
|
2134
|
-
this.#
|
|
2628
|
+
this.#startPromise = void 0;
|
|
2135
2629
|
}
|
|
2136
2630
|
/**
|
|
2137
2631
|
* Resolve the per-ledger managers once — the explicit override if given, else
|
|
@@ -2146,7 +2640,7 @@ var Client = class {
|
|
|
2146
2640
|
return this.#managers;
|
|
2147
2641
|
}
|
|
2148
2642
|
const managers = /* @__PURE__ */ new Map();
|
|
2149
|
-
const { arkadeServerUrl, evmRpcUrls, esploraUrl } = this.#tracking;
|
|
2643
|
+
const { arkadeServerUrl, evmRpcUrls, esploraUrl, bitcoinMinConfirmations } = this.#tracking;
|
|
2150
2644
|
const chainTime = async () => (await this.getMtp()).mtp * 1e3;
|
|
2151
2645
|
const arkadeUrl = arkadeServerUrl ?? defaultArkadeServerUrl("bitcoin");
|
|
2152
2646
|
if (arkadeUrl) {
|
|
@@ -2162,7 +2656,8 @@ var Client = class {
|
|
|
2162
2656
|
"bitcoin",
|
|
2163
2657
|
await BitcoinContractManager.create({
|
|
2164
2658
|
esploraUrl: esploraUrl ? [esploraUrl] : DEFAULT_ESPLORA_URLS,
|
|
2165
|
-
chainTime
|
|
2659
|
+
chainTime,
|
|
2660
|
+
minConfirmations: bitcoinMinConfirmations
|
|
2166
2661
|
})
|
|
2167
2662
|
);
|
|
2168
2663
|
this.#managers = managers;
|
|
@@ -2174,8 +2669,10 @@ var ClientBuilder = class {
|
|
|
2174
2669
|
// Observe-mode tracking is on by default; the built client auto-builds its
|
|
2175
2670
|
// managers from config unless disabled or given an explicit override.
|
|
2176
2671
|
#trackingEnabled = true;
|
|
2672
|
+
#serverUrl;
|
|
2177
2673
|
#arkadeServerUrl;
|
|
2178
2674
|
#esploraUrl;
|
|
2675
|
+
#bitcoinMinConfirmations;
|
|
2179
2676
|
#evmRpcUrls;
|
|
2180
2677
|
#managers;
|
|
2181
2678
|
#autoClaim;
|
|
@@ -2216,6 +2713,7 @@ var ClientBuilder = class {
|
|
|
2216
2713
|
}
|
|
2217
2714
|
withBaseUrl(...args) {
|
|
2218
2715
|
this.#inner.withBaseUrl(...args);
|
|
2716
|
+
this.#serverUrl = args[0];
|
|
2219
2717
|
return this;
|
|
2220
2718
|
}
|
|
2221
2719
|
withReferralCode(...args) {
|
|
@@ -2239,6 +2737,18 @@ var ClientBuilder = class {
|
|
|
2239
2737
|
this.#esploraUrl = url;
|
|
2240
2738
|
return this;
|
|
2241
2739
|
}
|
|
2740
|
+
/**
|
|
2741
|
+
* Confirmations a Bitcoin funding tx needs before it observes as `confirmed`
|
|
2742
|
+
* (gating e.g. the evm→bitcoin claim). Default `0`: claim as soon as the
|
|
2743
|
+
* funding hits the mempool, which TRUSTS the funder not to double-spend it
|
|
2744
|
+
* (claiming publishes the preimage, so a funder that then replaced its
|
|
2745
|
+
* funding could take both legs). Set `1` (or more) for a block-depth policy
|
|
2746
|
+
* that doesn't rely on the funder's good behaviour.
|
|
2747
|
+
*/
|
|
2748
|
+
withBitcoinMinConfirmations(minConfirmations) {
|
|
2749
|
+
this.#bitcoinMinConfirmations = minConfirmations;
|
|
2750
|
+
return this;
|
|
2751
|
+
}
|
|
2242
2752
|
/**
|
|
2243
2753
|
* Override the Ark server URL. Optional for tracking — it otherwise defaults to
|
|
2244
2754
|
* the mainnet server; set this to track a non-mainnet (dev/signet) deployment.
|
|
@@ -2279,14 +2789,24 @@ var ClientBuilder = class {
|
|
|
2279
2789
|
async build() {
|
|
2280
2790
|
return new Client(await this.#inner.build(), {
|
|
2281
2791
|
enabled: this.#trackingEnabled,
|
|
2792
|
+
serverUrl: this.#serverUrl,
|
|
2282
2793
|
managers: this.#managers,
|
|
2283
2794
|
arkadeServerUrl: this.#arkadeServerUrl,
|
|
2284
2795
|
esploraUrl: this.#esploraUrl,
|
|
2796
|
+
bitcoinMinConfirmations: this.#bitcoinMinConfirmations,
|
|
2285
2797
|
evmRpcUrls: this.#evmRpcUrls,
|
|
2286
2798
|
autoClaim: this.#autoClaim
|
|
2287
2799
|
});
|
|
2288
2800
|
}
|
|
2289
2801
|
};
|
|
2802
|
+
var SETTLED_STORED_STATUSES = /* @__PURE__ */ new Set([
|
|
2803
|
+
"serverredeemed",
|
|
2804
|
+
"clientrefunded",
|
|
2805
|
+
"clientrefundedserverfunded",
|
|
2806
|
+
"clientrefundedserverrefunded",
|
|
2807
|
+
"clientredeemedandclientrefunded",
|
|
2808
|
+
"expired"
|
|
2809
|
+
]);
|
|
2290
2810
|
function createdSwapId(result) {
|
|
2291
2811
|
const id = result?.response?.id;
|
|
2292
2812
|
return typeof id === "string" ? id : void 0;
|
|
@@ -2312,6 +2832,7 @@ export {
|
|
|
2312
2832
|
evmReaderFromClient,
|
|
2313
2833
|
htlcFactsFromEsploraTxs,
|
|
2314
2834
|
htlcKey,
|
|
2835
|
+
htlcQueryKey,
|
|
2315
2836
|
swapToTracked
|
|
2316
2837
|
};
|
|
2317
2838
|
//# sourceMappingURL=index.js.map
|