@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.
@@ -2702,6 +2702,10 @@ function createForfeitTx(input, forfeitOutputScript, connector) {
2702
2702
  import { ripemd160 } from "@noble/hashes/legacy.js";
2703
2703
  import { Address, OutScript } from "@scure/btc-signer";
2704
2704
  import { tapLeafHash as tapLeafHash3 } from "@scure/btc-signer/payment.js";
2705
+ var toBip68RelativeTimelock = (value) => ({
2706
+ type: value < 512 ? "blocks" : "seconds",
2707
+ value: BigInt(value)
2708
+ });
2705
2709
  var createVHTLCScript = (args) => {
2706
2710
  const {
2707
2711
  network,
@@ -2709,7 +2713,7 @@ var createVHTLCScript = (args) => {
2709
2713
  receiverPubkey,
2710
2714
  senderPubkey,
2711
2715
  serverPubkey,
2712
- timeoutBlockHeights
2716
+ timeoutBlockHeights: vhtlcTimeouts
2713
2717
  } = args;
2714
2718
  const receiverXOnlyPublicKey = normalizeToXOnlyKey(
2715
2719
  hex7.decode(receiverPubkey),
@@ -2723,27 +2727,21 @@ var createVHTLCScript = (args) => {
2723
2727
  hex7.decode(serverPubkey),
2724
2728
  "server"
2725
2729
  );
2726
- const delayType = (num) => num < 512 ? "blocks" : "seconds";
2727
2730
  const vhtlcScript = new VHTLC.Script({
2728
2731
  preimageHash: ripemd160(preimageHash),
2729
2732
  sender: senderXOnlyPublicKey,
2730
2733
  receiver: receiverXOnlyPublicKey,
2731
2734
  server: serverXOnlyPublicKey,
2732
- refundLocktime: BigInt(timeoutBlockHeights.refund),
2733
- unilateralClaimDelay: {
2734
- type: delayType(timeoutBlockHeights.unilateralClaim),
2735
- value: BigInt(timeoutBlockHeights.unilateralClaim)
2736
- },
2737
- unilateralRefundDelay: {
2738
- type: delayType(timeoutBlockHeights.unilateralRefund),
2739
- value: BigInt(timeoutBlockHeights.unilateralRefund)
2740
- },
2741
- unilateralRefundWithoutReceiverDelay: {
2742
- type: delayType(
2743
- timeoutBlockHeights.unilateralRefundWithoutReceiver
2744
- ),
2745
- value: BigInt(timeoutBlockHeights.unilateralRefundWithoutReceiver)
2746
- }
2735
+ refundLocktime: BigInt(vhtlcTimeouts.refund),
2736
+ unilateralClaimDelay: toBip68RelativeTimelock(
2737
+ vhtlcTimeouts.unilateralClaim
2738
+ ),
2739
+ unilateralRefundDelay: toBip68RelativeTimelock(
2740
+ vhtlcTimeouts.unilateralRefund
2741
+ ),
2742
+ unilateralRefundWithoutReceiverDelay: toBip68RelativeTimelock(
2743
+ vhtlcTimeouts.unilateralRefundWithoutReceiver
2744
+ )
2747
2745
  });
2748
2746
  if (!vhtlcScript.claimScript)
2749
2747
  throw new Error("Failed to create VHTLC script");
@@ -2982,6 +2980,20 @@ function scriptFromTapLeafScript(leaf) {
2982
2980
  }
2983
2981
 
2984
2982
  // src/arkade-swaps.ts
2983
+ var dedupeVtxos = (vtxos) => [
2984
+ ...new Map(
2985
+ vtxos.map((vtxo) => [`${vtxo.txid}:${vtxo.vout}`, vtxo])
2986
+ ).values()
2987
+ ];
2988
+ var hasNonEmptyString = (value) => typeof value === "string" && value.length > 0;
2989
+ var canRecoverViaBoltz3of3 = (refundableVtxos, swap) => {
2990
+ const hasRequiredSwapMetadata = hasNonEmptyString(swap.id) && hasNonEmptyString(swap.request.refundPublicKey) && hasNonEmptyString(swap.response.address) && hasNonEmptyString(swap.response.claimPublicKey) && !!swap.response.timeoutBlockHeights;
2991
+ if (!hasRequiredSwapMetadata) return false;
2992
+ return refundableVtxos.some(
2993
+ (vtxo) => !vtxo.isSpent && !isRecoverable(vtxo)
2994
+ );
2995
+ };
2996
+ var isSubmarineRefundLocktimeReached = (refundTimestamp) => Math.floor(Date.now() / 1e3) >= refundTimestamp;
2985
2997
  var CLAIM_VTXO_RETRY_ATTEMPTS = 3;
2986
2998
  var CLAIM_VTXO_RETRY_DELAY_MS = 500;
2987
2999
  var ArkadeSwaps = class _ArkadeSwaps {
@@ -3249,8 +3261,12 @@ var ArkadeSwaps = class _ArkadeSwaps {
3249
3261
  throw new Error(
3250
3262
  `Swap ${pendingSwap.id}: preimage is required to claim VHTLC`
3251
3263
  );
3252
- const { refundPublicKey, lockupAddress, timeoutBlockHeights } = pendingSwap.response;
3253
- if (!refundPublicKey || !lockupAddress || !timeoutBlockHeights)
3264
+ const {
3265
+ refundPublicKey,
3266
+ lockupAddress,
3267
+ timeoutBlockHeights: vhtlcTimeouts
3268
+ } = pendingSwap.response;
3269
+ if (!refundPublicKey || !lockupAddress || !vhtlcTimeouts)
3254
3270
  throw new Error(
3255
3271
  `Swap ${pendingSwap.id}: incomplete reverse swap response`
3256
3272
  );
@@ -3278,7 +3294,7 @@ var ArkadeSwaps = class _ArkadeSwaps {
3278
3294
  receiverPubkey: hex8.encode(receiverXOnly),
3279
3295
  senderPubkey: hex8.encode(senderXOnly),
3280
3296
  serverPubkey: hex8.encode(serverXOnly),
3281
- timeoutBlockHeights
3297
+ timeoutBlockHeights: vhtlcTimeouts
3282
3298
  });
3283
3299
  if (!vhtlcScript.claimScript)
3284
3300
  throw new Error(
@@ -3506,84 +3522,184 @@ var ArkadeSwaps = class _ArkadeSwaps {
3506
3522
  return pendingSwap;
3507
3523
  }
3508
3524
  /**
3509
- * Refunds the VHTLC for a failed submarine swap, returning locked funds to the wallet.
3510
- * Uses multi-party signatures (user + Boltz + server) for non-recoverable VTXOs.
3511
- * @param pendingSwap - The submarine swap to refund.
3512
- * @throws {Error} If preimage hash is unavailable, VHTLC not found, or already spent.
3525
+ * Reconstruct a submarine swap's VHTLC script from stored data. This does
3526
+ * not query the indexer, so bulk scans can build every script first and
3527
+ * then use batched VTXO lookups.
3528
+ *
3529
+ * @throws {Error} If preimage hash is unavailable, the swap response is
3530
+ * incomplete, the script can't be built, or the reconstructed address
3531
+ * doesn't match the one Boltz returned.
3513
3532
  */
3514
- async refundVHTLC(pendingSwap) {
3515
- const preimageHash = pendingSwap.request.invoice ? getInvoicePaymentHash(pendingSwap.request.invoice) : pendingSwap.preimageHash;
3533
+ async buildSubmarineVHTLCContext(swap, arkInfo) {
3534
+ const preimageHash = swap.request.invoice ? getInvoicePaymentHash(swap.request.invoice) : swap.preimageHash;
3516
3535
  if (!preimageHash)
3517
3536
  throw new Error(
3518
- `Swap ${pendingSwap.id}: preimage hash is required to refund VHTLC`
3537
+ `Swap ${swap.id}: preimage hash is required to refund VHTLC`
3519
3538
  );
3520
- const arkInfo = await this.arkProvider.getInfo();
3521
- const address = await this.wallet.getAddress();
3522
- if (!address) throw new Error("Failed to get ark address from wallet");
3539
+ const resolvedArkInfo = arkInfo ?? await this.arkProvider.getInfo();
3523
3540
  const ourXOnlyPublicKey = normalizeToXOnlyKey(
3524
3541
  await this.wallet.identity.xOnlyPublicKey(),
3525
3542
  "our",
3526
- pendingSwap.id
3543
+ swap.id
3527
3544
  );
3528
3545
  const serverXOnlyPublicKey = normalizeToXOnlyKey(
3529
- hex8.decode(arkInfo.signerPubkey),
3546
+ hex8.decode(resolvedArkInfo.signerPubkey),
3530
3547
  "server",
3531
- pendingSwap.id
3548
+ swap.id
3532
3549
  );
3533
- const { claimPublicKey, timeoutBlockHeights } = pendingSwap.response;
3534
- if (!claimPublicKey || !timeoutBlockHeights)
3550
+ const { claimPublicKey, timeoutBlockHeights: vhtlcTimeouts } = swap.response;
3551
+ if (!claimPublicKey || !vhtlcTimeouts)
3535
3552
  throw new Error(
3536
- `Swap ${pendingSwap.id}: incomplete submarine swap response`
3553
+ `Swap ${swap.id}: incomplete submarine swap response`
3537
3554
  );
3538
3555
  const boltzXOnlyPublicKey = normalizeToXOnlyKey(
3539
3556
  hex8.decode(claimPublicKey),
3540
3557
  "boltz",
3541
- pendingSwap.id
3558
+ swap.id
3542
3559
  );
3543
3560
  const { vhtlcScript, vhtlcAddress } = this.createVHTLCScript({
3544
- network: arkInfo.network,
3561
+ network: resolvedArkInfo.network,
3545
3562
  preimageHash: hex8.decode(preimageHash),
3546
3563
  receiverPubkey: hex8.encode(boltzXOnlyPublicKey),
3547
3564
  senderPubkey: hex8.encode(ourXOnlyPublicKey),
3548
3565
  serverPubkey: hex8.encode(serverXOnlyPublicKey),
3549
- timeoutBlockHeights
3566
+ timeoutBlockHeights: vhtlcTimeouts
3550
3567
  });
3551
3568
  if (!vhtlcScript.claimScript)
3552
3569
  throw new Error(
3553
- `Swap ${pendingSwap.id}: failed to create VHTLC script for submarine swap`
3570
+ `Swap ${swap.id}: failed to create VHTLC script for submarine swap`
3554
3571
  );
3555
- if (vhtlcAddress !== pendingSwap.response.address)
3572
+ if (vhtlcAddress !== swap.response.address)
3556
3573
  throw new Error(
3557
- `VHTLC address mismatch for swap ${pendingSwap.id}: expected ${pendingSwap.response.address}, got ${vhtlcAddress}`
3574
+ `VHTLC address mismatch for swap ${swap.id}: expected ${swap.response.address}, got ${vhtlcAddress}`
3558
3575
  );
3559
3576
  const vhtlcPkScriptHex = hex8.encode(vhtlcScript.pkScript);
3577
+ return {
3578
+ arkInfo: resolvedArkInfo,
3579
+ vhtlcScript,
3580
+ vhtlcAddress,
3581
+ vhtlcPkScriptHex,
3582
+ vhtlcTimeouts,
3583
+ ourXOnlyPublicKey,
3584
+ serverXOnlyPublicKey,
3585
+ boltzXOnlyPublicKey
3586
+ };
3587
+ }
3588
+ /**
3589
+ * Reconstruct a submarine swap's VHTLC script from stored data and look
3590
+ * up its VTXOs at the indexer. Side-effect free; shared by `refundVHTLC`
3591
+ * (spending path) and `inspectSubmarineRecovery` (diagnostic path).
3592
+ *
3593
+ * `refundableVtxos` merges spendable + recoverable indexer queries
3594
+ * (deduped by outpoint). When that set is empty, a third query
3595
+ * populates `diagnostic` so callers can distinguish "never funded",
3596
+ * "already spent", and "preconfirmed-only".
3597
+ */
3598
+ async lookupSubmarineVHTLC(swap, arkInfo) {
3599
+ const context = await this.buildSubmarineVHTLCContext(swap, arkInfo);
3560
3600
  const [spendableResult, recoverableResult] = await Promise.all([
3561
3601
  this.indexerProvider.getVtxos({
3562
- scripts: [vhtlcPkScriptHex],
3602
+ scripts: [context.vhtlcPkScriptHex],
3563
3603
  spendableOnly: true
3564
3604
  }),
3565
3605
  this.indexerProvider.getVtxos({
3566
- scripts: [vhtlcPkScriptHex],
3606
+ scripts: [context.vhtlcPkScriptHex],
3567
3607
  recoverableOnly: true
3568
3608
  })
3569
3609
  ]);
3570
- const refundableVtxos = [
3571
- ...new Map(
3572
- [...spendableResult.vtxos, ...recoverableResult.vtxos].map(
3573
- (vtxo) => [`${vtxo.txid}:${vtxo.vout}`, vtxo]
3574
- )
3575
- ).values()
3576
- ];
3610
+ const refundableVtxos = dedupeVtxos([
3611
+ ...spendableResult.vtxos,
3612
+ ...recoverableResult.vtxos
3613
+ ]);
3614
+ let diagnostic;
3577
3615
  if (refundableVtxos.length === 0) {
3578
3616
  const { vtxos: allVtxos } = await this.indexerProvider.getVtxos({
3579
- scripts: [vhtlcPkScriptHex]
3617
+ scripts: [context.vhtlcPkScriptHex]
3580
3618
  });
3581
- if (allVtxos.length === 0) {
3619
+ diagnostic = {
3620
+ totalVtxoCount: allVtxos.length,
3621
+ allSpent: allVtxos.length > 0 && allVtxos.every((vtxo) => vtxo.isSpent)
3622
+ };
3623
+ }
3624
+ return {
3625
+ ...context,
3626
+ refundableVtxos,
3627
+ diagnostic
3628
+ };
3629
+ }
3630
+ submarineRecoveryInfoFromLookup(swap, lookup) {
3631
+ const { refundableVtxos, diagnostic, vhtlcTimeouts } = lookup;
3632
+ if (refundableVtxos.length > 0) {
3633
+ const cltvSatisfied = isSubmarineRefundLocktimeReached(
3634
+ vhtlcTimeouts.refund
3635
+ );
3636
+ const amountSats = refundableVtxos.reduce(
3637
+ (sum, vtxo) => sum + Number(vtxo.value),
3638
+ 0
3639
+ );
3640
+ const isRecoverable2 = cltvSatisfied || canRecoverViaBoltz3of3(refundableVtxos, swap);
3641
+ return {
3642
+ swap,
3643
+ status: isRecoverable2 ? "recoverable" : "pre_cltv",
3644
+ vtxoCount: refundableVtxos.length,
3645
+ amountSats,
3646
+ refundLocktime: vhtlcTimeouts.refund
3647
+ };
3648
+ }
3649
+ if (!diagnostic || diagnostic.totalVtxoCount === 0) {
3650
+ return {
3651
+ swap,
3652
+ status: "none",
3653
+ vtxoCount: 0,
3654
+ amountSats: 0,
3655
+ refundLocktime: vhtlcTimeouts.refund
3656
+ };
3657
+ }
3658
+ if (diagnostic.allSpent) {
3659
+ return {
3660
+ swap,
3661
+ status: "already_spent",
3662
+ vtxoCount: 0,
3663
+ amountSats: 0,
3664
+ refundLocktime: vhtlcTimeouts.refund
3665
+ };
3666
+ }
3667
+ return {
3668
+ swap,
3669
+ status: "none",
3670
+ vtxoCount: 0,
3671
+ amountSats: 0,
3672
+ refundLocktime: vhtlcTimeouts.refund
3673
+ };
3674
+ }
3675
+ /**
3676
+ * Refunds the VHTLC for a failed submarine swap, returning locked funds to the wallet.
3677
+ * Uses multi-party signatures (user + Boltz + server) for non-recoverable VTXOs.
3678
+ * @param pendingSwap - The submarine swap to refund.
3679
+ * @returns Counts of VTXOs swept vs. deferred. A return value of `{ swept: 0, skipped: N }`
3680
+ * means the call was a no-op — callers should not treat it as a successful refund.
3681
+ * @throws {Error} If preimage hash is unavailable, VHTLC not found, or already spent.
3682
+ */
3683
+ async refundVHTLC(pendingSwap, cachedArkInfo) {
3684
+ const address = await this.wallet.getAddress();
3685
+ if (!address) throw new Error("Failed to get ark address from wallet");
3686
+ const {
3687
+ arkInfo,
3688
+ vhtlcScript,
3689
+ vhtlcTimeouts,
3690
+ ourXOnlyPublicKey,
3691
+ serverXOnlyPublicKey,
3692
+ boltzXOnlyPublicKey,
3693
+ refundableVtxos,
3694
+ diagnostic
3695
+ } = await this.lookupSubmarineVHTLC(pendingSwap, cachedArkInfo);
3696
+ if (refundableVtxos.length === 0) {
3697
+ if (!diagnostic || diagnostic.totalVtxoCount === 0) {
3582
3698
  throw new Error(
3583
3699
  `Swap ${pendingSwap.id}: VHTLC not found for address ${pendingSwap.response.address}`
3584
3700
  );
3585
3701
  }
3586
- if (allVtxos.every((vtxo) => vtxo.isSpent)) {
3702
+ if (diagnostic.allSpent) {
3587
3703
  throw new Error(
3588
3704
  `Swap ${pendingSwap.id}: VHTLC is already spent`
3589
3705
  );
@@ -3594,10 +3710,11 @@ var ArkadeSwaps = class _ArkadeSwaps {
3594
3710
  }
3595
3711
  const outputScript = ArkAddress2.decode(address).pkScript;
3596
3712
  const refundWithoutReceiverLeaf = vhtlcScript.refundWithoutReceiver();
3597
- const refundLocktime = BigInt(timeoutBlockHeights.refund);
3598
- const currentBlockHeight = await this.swapProvider.getChainHeight();
3599
- const cltvSatisfied = BigInt(currentBlockHeight) >= refundLocktime;
3713
+ const cltvSatisfied = isSubmarineRefundLocktimeReached(
3714
+ vhtlcTimeouts.refund
3715
+ );
3600
3716
  let boltzCallCount = 0;
3717
+ let sweptCount = 0;
3601
3718
  let skippedCount = 0;
3602
3719
  for (const vtxo of refundableVtxos) {
3603
3720
  const isRecoverableVtxo = isRecoverable(vtxo);
@@ -3618,11 +3735,12 @@ var ArkadeSwaps = class _ArkadeSwaps {
3618
3735
  arkInfo,
3619
3736
  isRecoverableVtxo
3620
3737
  );
3738
+ sweptCount++;
3621
3739
  continue;
3622
3740
  }
3623
3741
  if (isRecoverableVtxo) {
3624
3742
  logger.error(
3625
- `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.`
3743
+ `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.`
3626
3744
  );
3627
3745
  skippedCount++;
3628
3746
  continue;
@@ -3651,14 +3769,14 @@ var ArkadeSwaps = class _ArkadeSwaps {
3651
3769
  )
3652
3770
  );
3653
3771
  boltzCallCount++;
3772
+ sweptCount++;
3654
3773
  } catch (error) {
3655
3774
  if (!(error instanceof BoltzRefundError)) {
3656
3775
  throw error;
3657
3776
  }
3658
- const tipNow = await this.swapProvider.getChainHeight();
3659
- if (BigInt(tipNow) < refundLocktime) {
3777
+ if (!isSubmarineRefundLocktimeReached(vhtlcTimeouts.refund)) {
3660
3778
  logger.error(
3661
- `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.`
3779
+ `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.`
3662
3780
  );
3663
3781
  skippedCount++;
3664
3782
  continue;
@@ -3678,16 +3796,218 @@ var ArkadeSwaps = class _ArkadeSwaps {
3678
3796
  arkInfo,
3679
3797
  false
3680
3798
  );
3799
+ sweptCount++;
3681
3800
  }
3682
3801
  }
3683
- const fullyRefunded = skippedCount === 0;
3684
- await updateSubmarineSwapStatus(
3685
- pendingSwap,
3686
- pendingSwap.status,
3687
- // Keep current status
3688
- this.savePendingSubmarineSwap.bind(this),
3689
- { refundable: true, refunded: fullyRefunded }
3802
+ if (!isSubmarineSuccessStatus(pendingSwap.status)) {
3803
+ const fullyRefunded = skippedCount === 0;
3804
+ await updateSubmarineSwapStatus(
3805
+ pendingSwap,
3806
+ pendingSwap.status,
3807
+ // Keep current status
3808
+ this.savePendingSubmarineSwap.bind(this),
3809
+ { refundable: true, refunded: fullyRefunded }
3810
+ );
3811
+ }
3812
+ return { swept: sweptCount, skipped: skippedCount };
3813
+ }
3814
+ /**
3815
+ * Inspect a submarine swap's lockup address for recoverable funds.
3816
+ *
3817
+ * Side-effect free. Returns a structured snapshot the UI can use to
3818
+ * decide whether to offer the user a recovery action — it will not
3819
+ * trigger any signing or persistence.
3820
+ *
3821
+ * Only `transaction.claimed` (success with possible stranded extras)
3822
+ * and refundable failure statuses are recovery candidates. Pending
3823
+ * statuses (`invoice.set`, `transaction.mempool`, …) are returned as
3824
+ * `invalid_swap`; this API is for recovery, not a generic VTXO probe.
3825
+ *
3826
+ * @param swap - The submarine swap to inspect.
3827
+ */
3828
+ async inspectSubmarineRecovery(swap) {
3829
+ if (!isSubmarineSuccessStatus(swap.status) && !isSubmarineRefundableStatus(swap.status)) {
3830
+ return {
3831
+ swap,
3832
+ status: "invalid_swap",
3833
+ vtxoCount: 0,
3834
+ amountSats: 0,
3835
+ refundLocktime: swap.response.timeoutBlockHeights?.refund,
3836
+ error: `Swap status ${swap.status} is not a recovery candidate`
3837
+ };
3838
+ }
3839
+ let lookup;
3840
+ try {
3841
+ lookup = await this.lookupSubmarineVHTLC(swap);
3842
+ } catch (err) {
3843
+ return {
3844
+ swap,
3845
+ status: "invalid_swap",
3846
+ vtxoCount: 0,
3847
+ amountSats: 0,
3848
+ refundLocktime: swap.response.timeoutBlockHeights?.refund,
3849
+ error: err instanceof Error ? err.message : String(err)
3850
+ };
3851
+ }
3852
+ return this.submarineRecoveryInfoFromLookup(swap, lookup);
3853
+ }
3854
+ /**
3855
+ * Scan all locally-known submarine swaps for recoverable VHTLC funds.
3856
+ *
3857
+ * Loads submarine swaps from the repository, filters to recovery
3858
+ * candidates (`transaction.claimed` plus refundable failure
3859
+ * statuses), reconstructs their scripts, and performs one batched
3860
+ * spendable query plus one batched recoverable query. Pending swaps are
3861
+ * skipped entirely — they appear in the local repository but cannot
3862
+ * be in a recovery state yet.
3863
+ *
3864
+ * Side-effect free: does not mutate the repository, does not sign,
3865
+ * and does not query Boltz swap status.
3866
+ */
3867
+ async scanRecoverableSubmarineSwaps() {
3868
+ const submarineSwaps = await this.swapRepository.getAllSwaps({
3869
+ type: "submarine"
3870
+ });
3871
+ const candidates = submarineSwaps.filter(
3872
+ (swap) => isSubmarineSuccessStatus(swap.status) || isSubmarineRefundableStatus(swap.status)
3690
3873
  );
3874
+ let arkInfo;
3875
+ let arkInfoError;
3876
+ if (candidates.length > 0) {
3877
+ try {
3878
+ arkInfo = await this.arkProvider.getInfo();
3879
+ } catch (err) {
3880
+ arkInfoError = err instanceof Error ? err.message : String(err);
3881
+ }
3882
+ }
3883
+ const prepared = await Promise.all(
3884
+ candidates.map(async (swap) => {
3885
+ if (arkInfoError) {
3886
+ return {
3887
+ swap,
3888
+ error: arkInfoError
3889
+ };
3890
+ }
3891
+ try {
3892
+ return {
3893
+ swap,
3894
+ context: await this.buildSubmarineVHTLCContext(
3895
+ swap,
3896
+ arkInfo
3897
+ )
3898
+ };
3899
+ } catch (err) {
3900
+ return {
3901
+ swap,
3902
+ error: err instanceof Error ? err.message : String(err)
3903
+ };
3904
+ }
3905
+ })
3906
+ );
3907
+ const valid = prepared.filter(
3908
+ (item) => "context" in item
3909
+ );
3910
+ const scripts = [
3911
+ ...new Set(valid.map(({ context }) => context.vhtlcPkScriptHex))
3912
+ ];
3913
+ const refundableByScript = /* @__PURE__ */ new Map();
3914
+ if (scripts.length > 0) {
3915
+ const [spendableResult, recoverableResult] = await Promise.all([
3916
+ this.indexerProvider.getVtxos({
3917
+ scripts,
3918
+ spendableOnly: true
3919
+ }),
3920
+ this.indexerProvider.getVtxos({
3921
+ scripts,
3922
+ recoverableOnly: true
3923
+ })
3924
+ ]);
3925
+ for (const vtxo of dedupeVtxos([
3926
+ ...spendableResult.vtxos,
3927
+ ...recoverableResult.vtxos
3928
+ ])) {
3929
+ const script = vtxo.script?.toLowerCase();
3930
+ if (!script) continue;
3931
+ const existing = refundableByScript.get(script) ?? [];
3932
+ existing.push(vtxo);
3933
+ refundableByScript.set(script, existing);
3934
+ }
3935
+ }
3936
+ return prepared.map((item) => {
3937
+ if ("error" in item) {
3938
+ return {
3939
+ swap: item.swap,
3940
+ status: "invalid_swap",
3941
+ vtxoCount: 0,
3942
+ amountSats: 0,
3943
+ refundLocktime: item.swap.response.timeoutBlockHeights?.refund,
3944
+ error: item.error
3945
+ };
3946
+ }
3947
+ const refundableVtxos = refundableByScript.get(
3948
+ item.context.vhtlcPkScriptHex.toLowerCase()
3949
+ ) ?? [];
3950
+ return this.submarineRecoveryInfoFromLookup(item.swap, {
3951
+ ...item.context,
3952
+ refundableVtxos
3953
+ });
3954
+ });
3955
+ }
3956
+ /**
3957
+ * Recover funds locked at a single submarine swap's VHTLC address.
3958
+ *
3959
+ * Thin wrapper around `refundVHTLC` for callers that have already
3960
+ * confirmed (e.g. via `inspectSubmarineRecovery`) that funds are
3961
+ * present. Centralises the spending logic in one place — flag-write
3962
+ * behavior matches `refundVHTLC` (no-op for `transaction.claimed`,
3963
+ * normal flag updates for failure statuses).
3964
+ */
3965
+ async recoverSubmarineFunds(swap, arkInfo) {
3966
+ return this.refundVHTLC(swap, arkInfo);
3967
+ }
3968
+ /**
3969
+ * Recover funds for a batch of submarine swaps.
3970
+ *
3971
+ * Each swap's recovery is independent — a failure on one swap does
3972
+ * not abort the rest, and the caller receives a per-swap result so
3973
+ * they can present partial outcomes in the UI. Recovery runs
3974
+ * sequentially to avoid hammering Boltz / the indexer with parallel
3975
+ * batch joins.
3976
+ */
3977
+ async recoverAllSubmarineFunds(swaps) {
3978
+ const results = [];
3979
+ let arkInfo;
3980
+ try {
3981
+ if (swaps.length > 0) {
3982
+ arkInfo = await this.arkProvider.getInfo();
3983
+ }
3984
+ } catch (err) {
3985
+ const error = err instanceof Error ? err.message : String(err);
3986
+ return swaps.map((swap) => ({
3987
+ swapId: swap.id,
3988
+ recovered: false,
3989
+ skipped: false,
3990
+ error
3991
+ }));
3992
+ }
3993
+ for (const swap of swaps) {
3994
+ try {
3995
+ const outcome = await this.recoverSubmarineFunds(swap, arkInfo);
3996
+ results.push({
3997
+ swapId: swap.id,
3998
+ recovered: outcome.swept > 0,
3999
+ skipped: outcome.skipped > 0
4000
+ });
4001
+ } catch (err) {
4002
+ results.push({
4003
+ swapId: swap.id,
4004
+ recovered: false,
4005
+ skipped: false,
4006
+ error: err instanceof Error ? err.message : String(err)
4007
+ });
4008
+ }
4009
+ }
4010
+ return results;
3691
4011
  }
3692
4012
  /**
3693
4013
  * Waits for a submarine swap's Lightning payment to settle.
@@ -4469,14 +4789,14 @@ var ArkadeSwaps = class _ArkadeSwaps {
4469
4789
  const serverPubkey = hex8.encode(
4470
4790
  normalizeToXOnlyKey(arkInfo.signerPubkey, "server")
4471
4791
  );
4472
- const timeoutBlockHeights = to === "ARK" ? swap.response.claimDetails.timeouts : swap.response.lockupDetails.timeouts;
4792
+ const vhtlcTimeouts = to === "ARK" ? swap.response.claimDetails.timeouts : swap.response.lockupDetails.timeouts;
4473
4793
  const { vhtlcAddress } = this.createVHTLCScript({
4474
4794
  network: arkInfo.network,
4475
4795
  preimageHash: hex8.decode(swap.request.preimageHash),
4476
4796
  receiverPubkey,
4477
4797
  senderPubkey,
4478
4798
  serverPubkey,
4479
- timeoutBlockHeights
4799
+ timeoutBlockHeights: vhtlcTimeouts
4480
4800
  });
4481
4801
  if (lockupAddress !== vhtlcAddress) {
4482
4802
  throw new SwapError({