@arkade-os/boltz-swap 0.3.22 → 0.3.24

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.cjs CHANGED
@@ -2762,6 +2762,10 @@ function createForfeitTx(input, forfeitOutputScript, connector) {
2762
2762
  var import_legacy = require("@noble/hashes/legacy.js");
2763
2763
  var import_btc_signer4 = require("@scure/btc-signer");
2764
2764
  var import_payment3 = require("@scure/btc-signer/payment.js");
2765
+ var toBip68RelativeTimelock = (value) => ({
2766
+ type: value < 512 ? "blocks" : "seconds",
2767
+ value: BigInt(value)
2768
+ });
2765
2769
  var createVHTLCScript = (args) => {
2766
2770
  const {
2767
2771
  network,
@@ -2769,7 +2773,7 @@ var createVHTLCScript = (args) => {
2769
2773
  receiverPubkey,
2770
2774
  senderPubkey,
2771
2775
  serverPubkey,
2772
- timeoutBlockHeights
2776
+ timeoutBlockHeights: vhtlcTimeouts
2773
2777
  } = args;
2774
2778
  const receiverXOnlyPublicKey = normalizeToXOnlyKey(
2775
2779
  import_base8.hex.decode(receiverPubkey),
@@ -2783,27 +2787,21 @@ var createVHTLCScript = (args) => {
2783
2787
  import_base8.hex.decode(serverPubkey),
2784
2788
  "server"
2785
2789
  );
2786
- const delayType = (num) => num < 512 ? "blocks" : "seconds";
2787
2790
  const vhtlcScript = new import_sdk7.VHTLC.Script({
2788
2791
  preimageHash: (0, import_legacy.ripemd160)(preimageHash),
2789
2792
  sender: senderXOnlyPublicKey,
2790
2793
  receiver: receiverXOnlyPublicKey,
2791
2794
  server: serverXOnlyPublicKey,
2792
- refundLocktime: BigInt(timeoutBlockHeights.refund),
2793
- unilateralClaimDelay: {
2794
- type: delayType(timeoutBlockHeights.unilateralClaim),
2795
- value: BigInt(timeoutBlockHeights.unilateralClaim)
2796
- },
2797
- unilateralRefundDelay: {
2798
- type: delayType(timeoutBlockHeights.unilateralRefund),
2799
- value: BigInt(timeoutBlockHeights.unilateralRefund)
2800
- },
2801
- unilateralRefundWithoutReceiverDelay: {
2802
- type: delayType(
2803
- timeoutBlockHeights.unilateralRefundWithoutReceiver
2804
- ),
2805
- value: BigInt(timeoutBlockHeights.unilateralRefundWithoutReceiver)
2806
- }
2795
+ refundLocktime: BigInt(vhtlcTimeouts.refund),
2796
+ unilateralClaimDelay: toBip68RelativeTimelock(
2797
+ vhtlcTimeouts.unilateralClaim
2798
+ ),
2799
+ unilateralRefundDelay: toBip68RelativeTimelock(
2800
+ vhtlcTimeouts.unilateralRefund
2801
+ ),
2802
+ unilateralRefundWithoutReceiverDelay: toBip68RelativeTimelock(
2803
+ vhtlcTimeouts.unilateralRefundWithoutReceiver
2804
+ )
2807
2805
  });
2808
2806
  if (!vhtlcScript.claimScript)
2809
2807
  throw new Error("Failed to create VHTLC script");
@@ -3042,6 +3040,20 @@ function scriptFromTapLeafScript(leaf) {
3042
3040
  }
3043
3041
 
3044
3042
  // src/arkade-swaps.ts
3043
+ var dedupeVtxos = (vtxos) => [
3044
+ ...new Map(
3045
+ vtxos.map((vtxo) => [`${vtxo.txid}:${vtxo.vout}`, vtxo])
3046
+ ).values()
3047
+ ];
3048
+ var hasNonEmptyString = (value) => typeof value === "string" && value.length > 0;
3049
+ var canRecoverViaBoltz3of3 = (refundableVtxos, swap) => {
3050
+ const hasRequiredSwapMetadata = hasNonEmptyString(swap.id) && hasNonEmptyString(swap.request.refundPublicKey) && hasNonEmptyString(swap.response.address) && hasNonEmptyString(swap.response.claimPublicKey) && !!swap.response.timeoutBlockHeights;
3051
+ if (!hasRequiredSwapMetadata) return false;
3052
+ return refundableVtxos.some(
3053
+ (vtxo) => !vtxo.isSpent && !(0, import_sdk8.isRecoverable)(vtxo)
3054
+ );
3055
+ };
3056
+ var isSubmarineRefundLocktimeReached = (refundTimestamp) => Math.floor(Date.now() / 1e3) >= refundTimestamp;
3045
3057
  var CLAIM_VTXO_RETRY_ATTEMPTS = 3;
3046
3058
  var CLAIM_VTXO_RETRY_DELAY_MS = 500;
3047
3059
  var ArkadeSwaps = class _ArkadeSwaps {
@@ -3309,8 +3321,12 @@ var ArkadeSwaps = class _ArkadeSwaps {
3309
3321
  throw new Error(
3310
3322
  `Swap ${pendingSwap.id}: preimage is required to claim VHTLC`
3311
3323
  );
3312
- const { refundPublicKey, lockupAddress, timeoutBlockHeights } = pendingSwap.response;
3313
- if (!refundPublicKey || !lockupAddress || !timeoutBlockHeights)
3324
+ const {
3325
+ refundPublicKey,
3326
+ lockupAddress,
3327
+ timeoutBlockHeights: vhtlcTimeouts
3328
+ } = pendingSwap.response;
3329
+ if (!refundPublicKey || !lockupAddress || !vhtlcTimeouts)
3314
3330
  throw new Error(
3315
3331
  `Swap ${pendingSwap.id}: incomplete reverse swap response`
3316
3332
  );
@@ -3338,7 +3354,7 @@ var ArkadeSwaps = class _ArkadeSwaps {
3338
3354
  receiverPubkey: import_base9.hex.encode(receiverXOnly),
3339
3355
  senderPubkey: import_base9.hex.encode(senderXOnly),
3340
3356
  serverPubkey: import_base9.hex.encode(serverXOnly),
3341
- timeoutBlockHeights
3357
+ timeoutBlockHeights: vhtlcTimeouts
3342
3358
  });
3343
3359
  if (!vhtlcScript.claimScript)
3344
3360
  throw new Error(
@@ -3566,84 +3582,184 @@ var ArkadeSwaps = class _ArkadeSwaps {
3566
3582
  return pendingSwap;
3567
3583
  }
3568
3584
  /**
3569
- * Refunds the VHTLC for a failed submarine swap, returning locked funds to the wallet.
3570
- * Uses multi-party signatures (user + Boltz + server) for non-recoverable VTXOs.
3571
- * @param pendingSwap - The submarine swap to refund.
3572
- * @throws {Error} If preimage hash is unavailable, VHTLC not found, or already spent.
3585
+ * Reconstruct a submarine swap's VHTLC script from stored data. This does
3586
+ * not query the indexer, so bulk scans can build every script first and
3587
+ * then use batched VTXO lookups.
3588
+ *
3589
+ * @throws {Error} If preimage hash is unavailable, the swap response is
3590
+ * incomplete, the script can't be built, or the reconstructed address
3591
+ * doesn't match the one Boltz returned.
3573
3592
  */
3574
- async refundVHTLC(pendingSwap) {
3575
- const preimageHash = pendingSwap.request.invoice ? getInvoicePaymentHash(pendingSwap.request.invoice) : pendingSwap.preimageHash;
3593
+ async buildSubmarineVHTLCContext(swap, arkInfo) {
3594
+ const preimageHash = swap.request.invoice ? getInvoicePaymentHash(swap.request.invoice) : swap.preimageHash;
3576
3595
  if (!preimageHash)
3577
3596
  throw new Error(
3578
- `Swap ${pendingSwap.id}: preimage hash is required to refund VHTLC`
3597
+ `Swap ${swap.id}: preimage hash is required to refund VHTLC`
3579
3598
  );
3580
- const arkInfo = await this.arkProvider.getInfo();
3581
- const address = await this.wallet.getAddress();
3582
- if (!address) throw new Error("Failed to get ark address from wallet");
3599
+ const resolvedArkInfo = arkInfo ?? await this.arkProvider.getInfo();
3583
3600
  const ourXOnlyPublicKey = normalizeToXOnlyKey(
3584
3601
  await this.wallet.identity.xOnlyPublicKey(),
3585
3602
  "our",
3586
- pendingSwap.id
3603
+ swap.id
3587
3604
  );
3588
3605
  const serverXOnlyPublicKey = normalizeToXOnlyKey(
3589
- import_base9.hex.decode(arkInfo.signerPubkey),
3606
+ import_base9.hex.decode(resolvedArkInfo.signerPubkey),
3590
3607
  "server",
3591
- pendingSwap.id
3608
+ swap.id
3592
3609
  );
3593
- const { claimPublicKey, timeoutBlockHeights } = pendingSwap.response;
3594
- if (!claimPublicKey || !timeoutBlockHeights)
3610
+ const { claimPublicKey, timeoutBlockHeights: vhtlcTimeouts } = swap.response;
3611
+ if (!claimPublicKey || !vhtlcTimeouts)
3595
3612
  throw new Error(
3596
- `Swap ${pendingSwap.id}: incomplete submarine swap response`
3613
+ `Swap ${swap.id}: incomplete submarine swap response`
3597
3614
  );
3598
3615
  const boltzXOnlyPublicKey = normalizeToXOnlyKey(
3599
3616
  import_base9.hex.decode(claimPublicKey),
3600
3617
  "boltz",
3601
- pendingSwap.id
3618
+ swap.id
3602
3619
  );
3603
3620
  const { vhtlcScript, vhtlcAddress } = this.createVHTLCScript({
3604
- network: arkInfo.network,
3621
+ network: resolvedArkInfo.network,
3605
3622
  preimageHash: import_base9.hex.decode(preimageHash),
3606
3623
  receiverPubkey: import_base9.hex.encode(boltzXOnlyPublicKey),
3607
3624
  senderPubkey: import_base9.hex.encode(ourXOnlyPublicKey),
3608
3625
  serverPubkey: import_base9.hex.encode(serverXOnlyPublicKey),
3609
- timeoutBlockHeights
3626
+ timeoutBlockHeights: vhtlcTimeouts
3610
3627
  });
3611
3628
  if (!vhtlcScript.claimScript)
3612
3629
  throw new Error(
3613
- `Swap ${pendingSwap.id}: failed to create VHTLC script for submarine swap`
3630
+ `Swap ${swap.id}: failed to create VHTLC script for submarine swap`
3614
3631
  );
3615
- if (vhtlcAddress !== pendingSwap.response.address)
3632
+ if (vhtlcAddress !== swap.response.address)
3616
3633
  throw new Error(
3617
- `VHTLC address mismatch for swap ${pendingSwap.id}: expected ${pendingSwap.response.address}, got ${vhtlcAddress}`
3634
+ `VHTLC address mismatch for swap ${swap.id}: expected ${swap.response.address}, got ${vhtlcAddress}`
3618
3635
  );
3619
3636
  const vhtlcPkScriptHex = import_base9.hex.encode(vhtlcScript.pkScript);
3637
+ return {
3638
+ arkInfo: resolvedArkInfo,
3639
+ vhtlcScript,
3640
+ vhtlcAddress,
3641
+ vhtlcPkScriptHex,
3642
+ vhtlcTimeouts,
3643
+ ourXOnlyPublicKey,
3644
+ serverXOnlyPublicKey,
3645
+ boltzXOnlyPublicKey
3646
+ };
3647
+ }
3648
+ /**
3649
+ * Reconstruct a submarine swap's VHTLC script from stored data and look
3650
+ * up its VTXOs at the indexer. Side-effect free; shared by `refundVHTLC`
3651
+ * (spending path) and `inspectSubmarineRecovery` (diagnostic path).
3652
+ *
3653
+ * `refundableVtxos` merges spendable + recoverable indexer queries
3654
+ * (deduped by outpoint). When that set is empty, a third query
3655
+ * populates `diagnostic` so callers can distinguish "never funded",
3656
+ * "already spent", and "preconfirmed-only".
3657
+ */
3658
+ async lookupSubmarineVHTLC(swap, arkInfo) {
3659
+ const context = await this.buildSubmarineVHTLCContext(swap, arkInfo);
3620
3660
  const [spendableResult, recoverableResult] = await Promise.all([
3621
3661
  this.indexerProvider.getVtxos({
3622
- scripts: [vhtlcPkScriptHex],
3662
+ scripts: [context.vhtlcPkScriptHex],
3623
3663
  spendableOnly: true
3624
3664
  }),
3625
3665
  this.indexerProvider.getVtxos({
3626
- scripts: [vhtlcPkScriptHex],
3666
+ scripts: [context.vhtlcPkScriptHex],
3627
3667
  recoverableOnly: true
3628
3668
  })
3629
3669
  ]);
3630
- const refundableVtxos = [
3631
- ...new Map(
3632
- [...spendableResult.vtxos, ...recoverableResult.vtxos].map(
3633
- (vtxo) => [`${vtxo.txid}:${vtxo.vout}`, vtxo]
3634
- )
3635
- ).values()
3636
- ];
3670
+ const refundableVtxos = dedupeVtxos([
3671
+ ...spendableResult.vtxos,
3672
+ ...recoverableResult.vtxos
3673
+ ]);
3674
+ let diagnostic;
3637
3675
  if (refundableVtxos.length === 0) {
3638
3676
  const { vtxos: allVtxos } = await this.indexerProvider.getVtxos({
3639
- scripts: [vhtlcPkScriptHex]
3677
+ scripts: [context.vhtlcPkScriptHex]
3640
3678
  });
3641
- if (allVtxos.length === 0) {
3679
+ diagnostic = {
3680
+ totalVtxoCount: allVtxos.length,
3681
+ allSpent: allVtxos.length > 0 && allVtxos.every((vtxo) => vtxo.isSpent)
3682
+ };
3683
+ }
3684
+ return {
3685
+ ...context,
3686
+ refundableVtxos,
3687
+ diagnostic
3688
+ };
3689
+ }
3690
+ submarineRecoveryInfoFromLookup(swap, lookup) {
3691
+ const { refundableVtxos, diagnostic, vhtlcTimeouts } = lookup;
3692
+ if (refundableVtxos.length > 0) {
3693
+ const cltvSatisfied = isSubmarineRefundLocktimeReached(
3694
+ vhtlcTimeouts.refund
3695
+ );
3696
+ const amountSats = refundableVtxos.reduce(
3697
+ (sum, vtxo) => sum + Number(vtxo.value),
3698
+ 0
3699
+ );
3700
+ const isRecoverable2 = cltvSatisfied || canRecoverViaBoltz3of3(refundableVtxos, swap);
3701
+ return {
3702
+ swap,
3703
+ status: isRecoverable2 ? "recoverable" : "pre_cltv",
3704
+ vtxoCount: refundableVtxos.length,
3705
+ amountSats,
3706
+ refundLocktime: vhtlcTimeouts.refund
3707
+ };
3708
+ }
3709
+ if (!diagnostic || diagnostic.totalVtxoCount === 0) {
3710
+ return {
3711
+ swap,
3712
+ status: "none",
3713
+ vtxoCount: 0,
3714
+ amountSats: 0,
3715
+ refundLocktime: vhtlcTimeouts.refund
3716
+ };
3717
+ }
3718
+ if (diagnostic.allSpent) {
3719
+ return {
3720
+ swap,
3721
+ status: "already_spent",
3722
+ vtxoCount: 0,
3723
+ amountSats: 0,
3724
+ refundLocktime: vhtlcTimeouts.refund
3725
+ };
3726
+ }
3727
+ return {
3728
+ swap,
3729
+ status: "none",
3730
+ vtxoCount: 0,
3731
+ amountSats: 0,
3732
+ refundLocktime: vhtlcTimeouts.refund
3733
+ };
3734
+ }
3735
+ /**
3736
+ * Refunds the VHTLC for a failed submarine swap, returning locked funds to the wallet.
3737
+ * Uses multi-party signatures (user + Boltz + server) for non-recoverable VTXOs.
3738
+ * @param pendingSwap - The submarine swap to refund.
3739
+ * @returns Counts of VTXOs swept vs. deferred. A return value of `{ swept: 0, skipped: N }`
3740
+ * means the call was a no-op — callers should not treat it as a successful refund.
3741
+ * @throws {Error} If preimage hash is unavailable, VHTLC not found, or already spent.
3742
+ */
3743
+ async refundVHTLC(pendingSwap, cachedArkInfo) {
3744
+ const address = await this.wallet.getAddress();
3745
+ if (!address) throw new Error("Failed to get ark address from wallet");
3746
+ const {
3747
+ arkInfo,
3748
+ vhtlcScript,
3749
+ vhtlcTimeouts,
3750
+ ourXOnlyPublicKey,
3751
+ serverXOnlyPublicKey,
3752
+ boltzXOnlyPublicKey,
3753
+ refundableVtxos,
3754
+ diagnostic
3755
+ } = await this.lookupSubmarineVHTLC(pendingSwap, cachedArkInfo);
3756
+ if (refundableVtxos.length === 0) {
3757
+ if (!diagnostic || diagnostic.totalVtxoCount === 0) {
3642
3758
  throw new Error(
3643
3759
  `Swap ${pendingSwap.id}: VHTLC not found for address ${pendingSwap.response.address}`
3644
3760
  );
3645
3761
  }
3646
- if (allVtxos.every((vtxo) => vtxo.isSpent)) {
3762
+ if (diagnostic.allSpent) {
3647
3763
  throw new Error(
3648
3764
  `Swap ${pendingSwap.id}: VHTLC is already spent`
3649
3765
  );
@@ -3654,10 +3770,11 @@ var ArkadeSwaps = class _ArkadeSwaps {
3654
3770
  }
3655
3771
  const outputScript = import_sdk8.ArkAddress.decode(address).pkScript;
3656
3772
  const refundWithoutReceiverLeaf = vhtlcScript.refundWithoutReceiver();
3657
- const refundLocktime = BigInt(timeoutBlockHeights.refund);
3658
- const currentBlockHeight = await this.swapProvider.getChainHeight();
3659
- const cltvSatisfied = BigInt(currentBlockHeight) >= refundLocktime;
3773
+ const cltvSatisfied = isSubmarineRefundLocktimeReached(
3774
+ vhtlcTimeouts.refund
3775
+ );
3660
3776
  let boltzCallCount = 0;
3777
+ let sweptCount = 0;
3661
3778
  let skippedCount = 0;
3662
3779
  for (const vtxo of refundableVtxos) {
3663
3780
  const isRecoverableVtxo = (0, import_sdk8.isRecoverable)(vtxo);
@@ -3678,11 +3795,12 @@ var ArkadeSwaps = class _ArkadeSwaps {
3678
3795
  arkInfo,
3679
3796
  isRecoverableVtxo
3680
3797
  );
3798
+ sweptCount++;
3681
3799
  continue;
3682
3800
  }
3683
3801
  if (isRecoverableVtxo) {
3684
3802
  logger.error(
3685
- `Swap ${pendingSwap.id}: recoverable VTXO ${vtxo.txid}:${vtxo.vout} cannot be refunded yet \u2014 refundWithoutReceiver locktime has not passed (refundLocktime=${timeoutBlockHeights.refund}, currentBlockHeight=${currentBlockHeight}). Refund will be retried after locktime.`
3803
+ `Swap ${pendingSwap.id}: recoverable VTXO ${vtxo.txid}:${vtxo.vout} cannot be refunded yet \u2014 refundWithoutReceiver locktime has not passed (refundLocktime=${vhtlcTimeouts.refund}, currentTimestamp=${Math.floor(Date.now() / 1e3)}). Refund will be retried after locktime.`
3686
3804
  );
3687
3805
  skippedCount++;
3688
3806
  continue;
@@ -3711,14 +3829,14 @@ var ArkadeSwaps = class _ArkadeSwaps {
3711
3829
  )
3712
3830
  );
3713
3831
  boltzCallCount++;
3832
+ sweptCount++;
3714
3833
  } catch (error) {
3715
3834
  if (!(error instanceof BoltzRefundError)) {
3716
3835
  throw error;
3717
3836
  }
3718
- const tipNow = await this.swapProvider.getChainHeight();
3719
- if (BigInt(tipNow) < refundLocktime) {
3837
+ if (!isSubmarineRefundLocktimeReached(vhtlcTimeouts.refund)) {
3720
3838
  logger.error(
3721
- `Swap ${pendingSwap.id}: Boltz rejected VTXO outpoint and refundWithoutReceiver locktime has not passed yet (currentBlockHeight=${tipNow}, locktime=${timeoutBlockHeights.refund}). Refund will be retried after locktime.`
3839
+ `Swap ${pendingSwap.id}: Boltz rejected VTXO outpoint and refundWithoutReceiver locktime has not passed yet (currentTimestamp=${Math.floor(Date.now() / 1e3)}, locktime=${vhtlcTimeouts.refund}). Refund will be retried after locktime.`
3722
3840
  );
3723
3841
  skippedCount++;
3724
3842
  continue;
@@ -3738,16 +3856,218 @@ var ArkadeSwaps = class _ArkadeSwaps {
3738
3856
  arkInfo,
3739
3857
  false
3740
3858
  );
3859
+ sweptCount++;
3741
3860
  }
3742
3861
  }
3743
- const fullyRefunded = skippedCount === 0;
3744
- await updateSubmarineSwapStatus(
3745
- pendingSwap,
3746
- pendingSwap.status,
3747
- // Keep current status
3748
- this.savePendingSubmarineSwap.bind(this),
3749
- { refundable: true, refunded: fullyRefunded }
3862
+ if (!isSubmarineSuccessStatus(pendingSwap.status)) {
3863
+ const fullyRefunded = skippedCount === 0;
3864
+ await updateSubmarineSwapStatus(
3865
+ pendingSwap,
3866
+ pendingSwap.status,
3867
+ // Keep current status
3868
+ this.savePendingSubmarineSwap.bind(this),
3869
+ { refundable: true, refunded: fullyRefunded }
3870
+ );
3871
+ }
3872
+ return { swept: sweptCount, skipped: skippedCount };
3873
+ }
3874
+ /**
3875
+ * Inspect a submarine swap's lockup address for recoverable funds.
3876
+ *
3877
+ * Side-effect free. Returns a structured snapshot the UI can use to
3878
+ * decide whether to offer the user a recovery action — it will not
3879
+ * trigger any signing or persistence.
3880
+ *
3881
+ * Only `transaction.claimed` (success with possible stranded extras)
3882
+ * and refundable failure statuses are recovery candidates. Pending
3883
+ * statuses (`invoice.set`, `transaction.mempool`, …) are returned as
3884
+ * `invalid_swap`; this API is for recovery, not a generic VTXO probe.
3885
+ *
3886
+ * @param swap - The submarine swap to inspect.
3887
+ */
3888
+ async inspectSubmarineRecovery(swap) {
3889
+ if (!isSubmarineSuccessStatus(swap.status) && !isSubmarineRefundableStatus(swap.status)) {
3890
+ return {
3891
+ swap,
3892
+ status: "invalid_swap",
3893
+ vtxoCount: 0,
3894
+ amountSats: 0,
3895
+ refundLocktime: swap.response.timeoutBlockHeights?.refund,
3896
+ error: `Swap status ${swap.status} is not a recovery candidate`
3897
+ };
3898
+ }
3899
+ let lookup;
3900
+ try {
3901
+ lookup = await this.lookupSubmarineVHTLC(swap);
3902
+ } catch (err) {
3903
+ return {
3904
+ swap,
3905
+ status: "invalid_swap",
3906
+ vtxoCount: 0,
3907
+ amountSats: 0,
3908
+ refundLocktime: swap.response.timeoutBlockHeights?.refund,
3909
+ error: err instanceof Error ? err.message : String(err)
3910
+ };
3911
+ }
3912
+ return this.submarineRecoveryInfoFromLookup(swap, lookup);
3913
+ }
3914
+ /**
3915
+ * Scan all locally-known submarine swaps for recoverable VHTLC funds.
3916
+ *
3917
+ * Loads submarine swaps from the repository, filters to recovery
3918
+ * candidates (`transaction.claimed` plus refundable failure
3919
+ * statuses), reconstructs their scripts, and performs one batched
3920
+ * spendable query plus one batched recoverable query. Pending swaps are
3921
+ * skipped entirely — they appear in the local repository but cannot
3922
+ * be in a recovery state yet.
3923
+ *
3924
+ * Side-effect free: does not mutate the repository, does not sign,
3925
+ * and does not query Boltz swap status.
3926
+ */
3927
+ async scanRecoverableSubmarineSwaps() {
3928
+ const submarineSwaps = await this.swapRepository.getAllSwaps({
3929
+ type: "submarine"
3930
+ });
3931
+ const candidates = submarineSwaps.filter(
3932
+ (swap) => isSubmarineSuccessStatus(swap.status) || isSubmarineRefundableStatus(swap.status)
3750
3933
  );
3934
+ let arkInfo;
3935
+ let arkInfoError;
3936
+ if (candidates.length > 0) {
3937
+ try {
3938
+ arkInfo = await this.arkProvider.getInfo();
3939
+ } catch (err) {
3940
+ arkInfoError = err instanceof Error ? err.message : String(err);
3941
+ }
3942
+ }
3943
+ const prepared = await Promise.all(
3944
+ candidates.map(async (swap) => {
3945
+ if (arkInfoError) {
3946
+ return {
3947
+ swap,
3948
+ error: arkInfoError
3949
+ };
3950
+ }
3951
+ try {
3952
+ return {
3953
+ swap,
3954
+ context: await this.buildSubmarineVHTLCContext(
3955
+ swap,
3956
+ arkInfo
3957
+ )
3958
+ };
3959
+ } catch (err) {
3960
+ return {
3961
+ swap,
3962
+ error: err instanceof Error ? err.message : String(err)
3963
+ };
3964
+ }
3965
+ })
3966
+ );
3967
+ const valid = prepared.filter(
3968
+ (item) => "context" in item
3969
+ );
3970
+ const scripts = [
3971
+ ...new Set(valid.map(({ context }) => context.vhtlcPkScriptHex))
3972
+ ];
3973
+ const refundableByScript = /* @__PURE__ */ new Map();
3974
+ if (scripts.length > 0) {
3975
+ const [spendableResult, recoverableResult] = await Promise.all([
3976
+ this.indexerProvider.getVtxos({
3977
+ scripts,
3978
+ spendableOnly: true
3979
+ }),
3980
+ this.indexerProvider.getVtxos({
3981
+ scripts,
3982
+ recoverableOnly: true
3983
+ })
3984
+ ]);
3985
+ for (const vtxo of dedupeVtxos([
3986
+ ...spendableResult.vtxos,
3987
+ ...recoverableResult.vtxos
3988
+ ])) {
3989
+ const script = vtxo.script?.toLowerCase();
3990
+ if (!script) continue;
3991
+ const existing = refundableByScript.get(script) ?? [];
3992
+ existing.push(vtxo);
3993
+ refundableByScript.set(script, existing);
3994
+ }
3995
+ }
3996
+ return prepared.map((item) => {
3997
+ if ("error" in item) {
3998
+ return {
3999
+ swap: item.swap,
4000
+ status: "invalid_swap",
4001
+ vtxoCount: 0,
4002
+ amountSats: 0,
4003
+ refundLocktime: item.swap.response.timeoutBlockHeights?.refund,
4004
+ error: item.error
4005
+ };
4006
+ }
4007
+ const refundableVtxos = refundableByScript.get(
4008
+ item.context.vhtlcPkScriptHex.toLowerCase()
4009
+ ) ?? [];
4010
+ return this.submarineRecoveryInfoFromLookup(item.swap, {
4011
+ ...item.context,
4012
+ refundableVtxos
4013
+ });
4014
+ });
4015
+ }
4016
+ /**
4017
+ * Recover funds locked at a single submarine swap's VHTLC address.
4018
+ *
4019
+ * Thin wrapper around `refundVHTLC` for callers that have already
4020
+ * confirmed (e.g. via `inspectSubmarineRecovery`) that funds are
4021
+ * present. Centralises the spending logic in one place — flag-write
4022
+ * behavior matches `refundVHTLC` (no-op for `transaction.claimed`,
4023
+ * normal flag updates for failure statuses).
4024
+ */
4025
+ async recoverSubmarineFunds(swap, arkInfo) {
4026
+ return this.refundVHTLC(swap, arkInfo);
4027
+ }
4028
+ /**
4029
+ * Recover funds for a batch of submarine swaps.
4030
+ *
4031
+ * Each swap's recovery is independent — a failure on one swap does
4032
+ * not abort the rest, and the caller receives a per-swap result so
4033
+ * they can present partial outcomes in the UI. Recovery runs
4034
+ * sequentially to avoid hammering Boltz / the indexer with parallel
4035
+ * batch joins.
4036
+ */
4037
+ async recoverAllSubmarineFunds(swaps) {
4038
+ const results = [];
4039
+ let arkInfo;
4040
+ try {
4041
+ if (swaps.length > 0) {
4042
+ arkInfo = await this.arkProvider.getInfo();
4043
+ }
4044
+ } catch (err) {
4045
+ const error = err instanceof Error ? err.message : String(err);
4046
+ return swaps.map((swap) => ({
4047
+ swapId: swap.id,
4048
+ recovered: false,
4049
+ skipped: false,
4050
+ error
4051
+ }));
4052
+ }
4053
+ for (const swap of swaps) {
4054
+ try {
4055
+ const outcome = await this.recoverSubmarineFunds(swap, arkInfo);
4056
+ results.push({
4057
+ swapId: swap.id,
4058
+ recovered: outcome.swept > 0,
4059
+ skipped: outcome.skipped > 0
4060
+ });
4061
+ } catch (err) {
4062
+ results.push({
4063
+ swapId: swap.id,
4064
+ recovered: false,
4065
+ skipped: false,
4066
+ error: err instanceof Error ? err.message : String(err)
4067
+ });
4068
+ }
4069
+ }
4070
+ return results;
3751
4071
  }
3752
4072
  /**
3753
4073
  * Waits for a submarine swap's Lightning payment to settle.
@@ -4529,14 +4849,14 @@ var ArkadeSwaps = class _ArkadeSwaps {
4529
4849
  const serverPubkey = import_base9.hex.encode(
4530
4850
  normalizeToXOnlyKey(arkInfo.signerPubkey, "server")
4531
4851
  );
4532
- const timeoutBlockHeights = to === "ARK" ? swap.response.claimDetails.timeouts : swap.response.lockupDetails.timeouts;
4852
+ const vhtlcTimeouts = to === "ARK" ? swap.response.claimDetails.timeouts : swap.response.lockupDetails.timeouts;
4533
4853
  const { vhtlcAddress } = this.createVHTLCScript({
4534
4854
  network: arkInfo.network,
4535
4855
  preimageHash: import_base9.hex.decode(swap.request.preimageHash),
4536
4856
  receiverPubkey,
4537
4857
  senderPubkey,
4538
4858
  serverPubkey,
4539
- timeoutBlockHeights
4859
+ timeoutBlockHeights: vhtlcTimeouts
4540
4860
  });
4541
4861
  if (lockupAddress !== vhtlcAddress) {
4542
4862
  throw new SwapError({
@@ -4880,6 +5200,25 @@ var ArkadeSwaps = class _ArkadeSwaps {
4880
5200
  // src/serviceWorker/arkade-swaps-message-handler.ts
4881
5201
  var import_sdk9 = require("@arkade-os/sdk");
4882
5202
  var DEFAULT_MESSAGE_TAG = "ARKADE_SWAPS_UPDATER";
5203
+ var LONG_RUNNING_ARKADE_SWAPS_REQUEST_TYPES = /* @__PURE__ */ new Set([
5204
+ "SEND_LIGHTNING_PAYMENT",
5205
+ "CLAIM_VHTLC",
5206
+ "REFUND_VHTLC",
5207
+ "INSPECT_SUBMARINE_RECOVERY",
5208
+ "SCAN_RECOVERABLE_SUBMARINE_SWAPS",
5209
+ "RECOVER_SUBMARINE_FUNDS",
5210
+ "RECOVER_ALL_SUBMARINE_FUNDS",
5211
+ "WAIT_AND_CLAIM",
5212
+ "WAIT_FOR_SWAP_SETTLEMENT",
5213
+ "RESTORE_SWAPS",
5214
+ "WAIT_AND_CLAIM_CHAIN",
5215
+ "WAIT_AND_CLAIM_ARK",
5216
+ "WAIT_AND_CLAIM_BTC",
5217
+ "CLAIM_ARK",
5218
+ "CLAIM_BTC",
5219
+ "REFUND_ARK",
5220
+ "SM-WAIT_FOR_COMPLETION"
5221
+ ]);
4883
5222
  var ArkadeSwapsMessageHandler = class _ArkadeSwapsMessageHandler {
4884
5223
  constructor(swapRepository) {
4885
5224
  this.swapRepository = swapRepository;
@@ -4921,6 +5260,14 @@ var ArkadeSwapsMessageHandler = class _ArkadeSwapsMessageHandler {
4921
5260
  async tick(_now) {
4922
5261
  return [];
4923
5262
  }
5263
+ // Flows that surrender control to Boltz, the Ark server, or other
5264
+ // participants in a batch round: quiet gaps between protocol events can
5265
+ // easily exceed the bus-level messageTimeoutMs. Liveness is covered
5266
+ // out-of-band by the page-side PING / MESSAGE_BUS_NOT_INITIALIZED path
5267
+ // triggered by concurrent short requests (GET_FEES, GET_SWAP_STATUS, ...).
5268
+ isLongRunning(message) {
5269
+ return LONG_RUNNING_ARKADE_SWAPS_REQUEST_TYPES.has(message.type);
5270
+ }
4924
5271
  tagged(res) {
4925
5272
  return {
4926
5273
  ...res,
@@ -5002,9 +5349,54 @@ var ArkadeSwapsMessageHandler = class _ArkadeSwapsMessageHandler {
5002
5349
  case "CLAIM_VHTLC":
5003
5350
  await this.handler.claimVHTLC(message.payload);
5004
5351
  return this.tagged({ id, type: "VHTLC_CLAIMED" });
5005
- case "REFUND_VHTLC":
5006
- await this.handler.refundVHTLC(message.payload);
5007
- return this.tagged({ id, type: "VHTLC_REFUNDED" });
5352
+ case "REFUND_VHTLC": {
5353
+ const outcome = await this.handler.refundVHTLC(
5354
+ message.payload
5355
+ );
5356
+ return this.tagged({
5357
+ id,
5358
+ type: "VHTLC_REFUNDED",
5359
+ payload: outcome
5360
+ });
5361
+ }
5362
+ case "INSPECT_SUBMARINE_RECOVERY": {
5363
+ const info = await this.handler.inspectSubmarineRecovery(
5364
+ message.payload
5365
+ );
5366
+ return this.tagged({
5367
+ id,
5368
+ type: "SUBMARINE_RECOVERY_INSPECTED",
5369
+ payload: info
5370
+ });
5371
+ }
5372
+ case "SCAN_RECOVERABLE_SUBMARINE_SWAPS": {
5373
+ const infos = await this.handler.scanRecoverableSubmarineSwaps();
5374
+ return this.tagged({
5375
+ id,
5376
+ type: "RECOVERABLE_SUBMARINE_SWAPS_SCANNED",
5377
+ payload: infos
5378
+ });
5379
+ }
5380
+ case "RECOVER_SUBMARINE_FUNDS": {
5381
+ const outcome = await this.handler.recoverSubmarineFunds(
5382
+ message.payload
5383
+ );
5384
+ return this.tagged({
5385
+ id,
5386
+ type: "SUBMARINE_FUNDS_RECOVERED",
5387
+ payload: outcome
5388
+ });
5389
+ }
5390
+ case "RECOVER_ALL_SUBMARINE_FUNDS": {
5391
+ const results = await this.handler.recoverAllSubmarineFunds(
5392
+ message.payload
5393
+ );
5394
+ return this.tagged({
5395
+ id,
5396
+ type: "ALL_SUBMARINE_FUNDS_RECOVERED",
5397
+ payload: results
5398
+ });
5399
+ }
5008
5400
  case "WAIT_AND_CLAIM": {
5009
5401
  const res = await this.handler.waitAndClaim(
5010
5402
  message.payload
@@ -5352,6 +5744,8 @@ var import_sdk10 = require("@arkade-os/sdk");
5352
5744
  function isMessageBusNotInitializedError(error) {
5353
5745
  return error instanceof Error && error.message.includes(import_sdk10.MESSAGE_BUS_NOT_INITIALIZED);
5354
5746
  }
5747
+ var DEFAULT_MESSAGE_TIMEOUT_MS = 3e4;
5748
+ var NO_MESSAGE_TIMEOUT_MS = 0;
5355
5749
  var DEDUPABLE_REQUEST_TYPES = /* @__PURE__ */ new Set([
5356
5750
  "GET_FEES",
5357
5751
  "GET_LIMITS",
@@ -5627,12 +6021,48 @@ var ServiceWorkerArkadeSwaps = class _ServiceWorkerArkadeSwaps {
5627
6021
  });
5628
6022
  }
5629
6023
  async refundVHTLC(pendingSwap) {
5630
- await this.sendMessage({
6024
+ const res = await this.sendMessage({
5631
6025
  id: getRandomId(),
5632
6026
  tag: this.messageTag,
5633
6027
  type: "REFUND_VHTLC",
5634
6028
  payload: pendingSwap
5635
6029
  });
6030
+ return res.payload;
6031
+ }
6032
+ async inspectSubmarineRecovery(swap) {
6033
+ const res = await this.sendMessage({
6034
+ id: getRandomId(),
6035
+ tag: this.messageTag,
6036
+ type: "INSPECT_SUBMARINE_RECOVERY",
6037
+ payload: swap
6038
+ });
6039
+ return res.payload;
6040
+ }
6041
+ async scanRecoverableSubmarineSwaps() {
6042
+ const res = await this.sendMessage({
6043
+ id: getRandomId(),
6044
+ tag: this.messageTag,
6045
+ type: "SCAN_RECOVERABLE_SUBMARINE_SWAPS"
6046
+ });
6047
+ return res.payload;
6048
+ }
6049
+ async recoverSubmarineFunds(swap) {
6050
+ const res = await this.sendMessage({
6051
+ id: getRandomId(),
6052
+ tag: this.messageTag,
6053
+ type: "RECOVER_SUBMARINE_FUNDS",
6054
+ payload: swap
6055
+ });
6056
+ return res.payload;
6057
+ }
6058
+ async recoverAllSubmarineFunds(swaps) {
6059
+ const res = await this.sendMessage({
6060
+ id: getRandomId(),
6061
+ tag: this.messageTag,
6062
+ type: "RECOVER_ALL_SUBMARINE_FUNDS",
6063
+ payload: swaps
6064
+ });
6065
+ return res.payload;
5636
6066
  }
5637
6067
  async waitAndClaim(pendingSwap) {
5638
6068
  try {
@@ -5961,7 +6391,7 @@ var ServiceWorkerArkadeSwaps = class _ServiceWorkerArkadeSwaps {
5961
6391
  async [Symbol.asyncDispose]() {
5962
6392
  return this.dispose();
5963
6393
  }
5964
- sendMessageDirect(request) {
6394
+ sendMessageDirect(request, timeoutMs) {
5965
6395
  return new Promise((resolve, reject) => {
5966
6396
  const cleanup = () => {
5967
6397
  clearTimeout(timeoutId);
@@ -5970,14 +6400,14 @@ var ServiceWorkerArkadeSwaps = class _ServiceWorkerArkadeSwaps {
5970
6400
  messageHandler
5971
6401
  );
5972
6402
  };
5973
- const timeoutId = setTimeout(() => {
6403
+ const timeoutId = timeoutMs > 0 ? setTimeout(() => {
5974
6404
  cleanup();
5975
6405
  reject(
5976
6406
  new import_sdk10.ServiceWorkerTimeoutError(
5977
6407
  `Service worker message timed out (${request.type})`
5978
6408
  )
5979
6409
  );
5980
- }, 3e4);
6410
+ }, timeoutMs) : void 0;
5981
6411
  const messageHandler = (event) => {
5982
6412
  const response = event.data;
5983
6413
  if (!response || response.tag !== this.messageTag || response.id !== request.id) {
@@ -6052,10 +6482,13 @@ var ServiceWorkerArkadeSwaps = class _ServiceWorkerArkadeSwaps {
6052
6482
  await this.reinitialize();
6053
6483
  }
6054
6484
  }
6485
+ const timeoutMs = LONG_RUNNING_ARKADE_SWAPS_REQUEST_TYPES.has(
6486
+ request.type
6487
+ ) ? NO_MESSAGE_TIMEOUT_MS : DEFAULT_MESSAGE_TIMEOUT_MS;
6055
6488
  const maxRetries = 2;
6056
6489
  for (let attempt = 0; ; attempt++) {
6057
6490
  try {
6058
- return await this.sendMessageDirect(request);
6491
+ return await this.sendMessageDirect(request, timeoutMs);
6059
6492
  } catch (error) {
6060
6493
  if (!isMessageBusNotInitializedError(error) || attempt >= maxRetries) {
6061
6494
  throw error;
@@ -6076,7 +6509,10 @@ var ServiceWorkerArkadeSwaps = class _ServiceWorkerArkadeSwaps {
6076
6509
  id: getRandomId(),
6077
6510
  payload: this.initPayload
6078
6511
  };
6079
- await this.sendMessageDirect(initMessage);
6512
+ await this.sendMessageDirect(
6513
+ initMessage,
6514
+ DEFAULT_MESSAGE_TIMEOUT_MS
6515
+ );
6080
6516
  })().finally(() => {
6081
6517
  this.reinitPromise = null;
6082
6518
  });