@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.
@@ -118,7 +118,7 @@ var init_errors = __esm({
118
118
  });
119
119
 
120
120
  // src/boltz-swap-provider.ts
121
- var import_sdk, import_base, isSubmarineFinalStatus, isSubmarineRefundableStatus, isReverseFinalStatus, isReverseClaimableStatus, isChainClaimableStatus, isChainFinalStatus, isChainRefundableStatus, isChainSignableStatus, isPendingReverseSwap, isPendingSubmarineSwap, isPendingChainSwap, isSubmarineSwapRefundable, isTimeoutBlockHeights, isGetReverseSwapTxIdResponse, isGetSwapStatusResponse, isGetSubmarinePairsResponse, isGetReversePairsResponse, isCreateSubmarineSwapResponse, isGetSwapPreimageResponse, isCreateReverseSwapResponse, isRefundSubmarineSwapResponse, isRefundChainSwapResponse, isGetChainPairsResponse, isSwapTree, isChainSwapDetailsResponse, isCreateChainSwapResponse, isGetChainClaimDetailsResponse, isPostChainClaimDetailsResponse, isGetChainQuoteResponse, isPostChainQuoteResponse, isPostBtcTransactionResponse, isLeaf, isTree, isDetails, isRestoredChainSwap, isRestoredSubmarineSwap, isRestoredReverseSwap, isCreateSwapsRestoreResponse, BASE_URLS, BoltzSwapProvider;
121
+ var import_sdk, import_base, isSubmarineFinalStatus, isSubmarineRefundableStatus, isSubmarineSuccessStatus, isReverseFinalStatus, isReverseClaimableStatus, isChainClaimableStatus, isChainFinalStatus, isChainRefundableStatus, isChainSignableStatus, isPendingReverseSwap, isPendingSubmarineSwap, isPendingChainSwap, isSubmarineSwapRefundable, isTimeoutBlockHeights, isGetReverseSwapTxIdResponse, isGetSwapStatusResponse, isGetSubmarinePairsResponse, isGetReversePairsResponse, isCreateSubmarineSwapResponse, isGetSwapPreimageResponse, isCreateReverseSwapResponse, isRefundSubmarineSwapResponse, isRefundChainSwapResponse, isGetChainPairsResponse, isSwapTree, isChainSwapDetailsResponse, isCreateChainSwapResponse, isGetChainClaimDetailsResponse, isPostChainClaimDetailsResponse, isGetChainQuoteResponse, isPostChainQuoteResponse, isPostBtcTransactionResponse, isLeaf, isTree, isDetails, isRestoredChainSwap, isRestoredSubmarineSwap, isRestoredReverseSwap, isCreateSwapsRestoreResponse, BASE_URLS, BoltzSwapProvider;
122
122
  var init_boltz_swap_provider = __esm({
123
123
  "src/boltz-swap-provider.ts"() {
124
124
  "use strict";
@@ -139,6 +139,9 @@ var init_boltz_swap_provider = __esm({
139
139
  "swap.expired"
140
140
  ].includes(status);
141
141
  };
142
+ isSubmarineSuccessStatus = (status) => {
143
+ return status === "transaction.claimed";
144
+ };
142
145
  isReverseFinalStatus = (status) => {
143
146
  return [
144
147
  "transaction.refunded",
@@ -2672,7 +2675,7 @@ var init_batch = __esm({
2672
2675
  function scriptFromTapLeafScript(leaf) {
2673
2676
  return leaf[1].subarray(0, leaf[1].length - 1);
2674
2677
  }
2675
- var import_sdk7, import_base8, import_legacy, import_btc_signer4, import_payment3, createVHTLCScript, joinBatch, claimVHTLCwithOffchainTx, refundVHTLCwithOffchainTx;
2678
+ var import_sdk7, import_base8, import_legacy, import_btc_signer4, import_payment3, toBip68RelativeTimelock, createVHTLCScript, joinBatch, claimVHTLCwithOffchainTx, refundVHTLCwithOffchainTx;
2676
2679
  var init_vhtlc = __esm({
2677
2680
  "src/utils/vhtlc.ts"() {
2678
2681
  "use strict";
@@ -2685,6 +2688,10 @@ var init_vhtlc = __esm({
2685
2688
  import_btc_signer4 = require("@scure/btc-signer");
2686
2689
  import_payment3 = require("@scure/btc-signer/payment.js");
2687
2690
  init_signatures();
2691
+ toBip68RelativeTimelock = (value) => ({
2692
+ type: value < 512 ? "blocks" : "seconds",
2693
+ value: BigInt(value)
2694
+ });
2688
2695
  createVHTLCScript = (args) => {
2689
2696
  const {
2690
2697
  network,
@@ -2692,7 +2699,7 @@ var init_vhtlc = __esm({
2692
2699
  receiverPubkey,
2693
2700
  senderPubkey,
2694
2701
  serverPubkey,
2695
- timeoutBlockHeights
2702
+ timeoutBlockHeights: vhtlcTimeouts
2696
2703
  } = args;
2697
2704
  const receiverXOnlyPublicKey = normalizeToXOnlyKey(
2698
2705
  import_base8.hex.decode(receiverPubkey),
@@ -2706,27 +2713,21 @@ var init_vhtlc = __esm({
2706
2713
  import_base8.hex.decode(serverPubkey),
2707
2714
  "server"
2708
2715
  );
2709
- const delayType = (num) => num < 512 ? "blocks" : "seconds";
2710
2716
  const vhtlcScript = new import_sdk7.VHTLC.Script({
2711
2717
  preimageHash: (0, import_legacy.ripemd160)(preimageHash),
2712
2718
  sender: senderXOnlyPublicKey,
2713
2719
  receiver: receiverXOnlyPublicKey,
2714
2720
  server: serverXOnlyPublicKey,
2715
- refundLocktime: BigInt(timeoutBlockHeights.refund),
2716
- unilateralClaimDelay: {
2717
- type: delayType(timeoutBlockHeights.unilateralClaim),
2718
- value: BigInt(timeoutBlockHeights.unilateralClaim)
2719
- },
2720
- unilateralRefundDelay: {
2721
- type: delayType(timeoutBlockHeights.unilateralRefund),
2722
- value: BigInt(timeoutBlockHeights.unilateralRefund)
2723
- },
2724
- unilateralRefundWithoutReceiverDelay: {
2725
- type: delayType(
2726
- timeoutBlockHeights.unilateralRefundWithoutReceiver
2727
- ),
2728
- value: BigInt(timeoutBlockHeights.unilateralRefundWithoutReceiver)
2729
- }
2721
+ refundLocktime: BigInt(vhtlcTimeouts.refund),
2722
+ unilateralClaimDelay: toBip68RelativeTimelock(
2723
+ vhtlcTimeouts.unilateralClaim
2724
+ ),
2725
+ unilateralRefundDelay: toBip68RelativeTimelock(
2726
+ vhtlcTimeouts.unilateralRefund
2727
+ ),
2728
+ unilateralRefundWithoutReceiverDelay: toBip68RelativeTimelock(
2729
+ vhtlcTimeouts.unilateralRefundWithoutReceiver
2730
+ )
2730
2731
  });
2731
2732
  if (!vhtlcScript.claimScript)
2732
2733
  throw new Error("Failed to create VHTLC script");
@@ -2964,7 +2965,7 @@ var init_vhtlc = __esm({
2964
2965
  });
2965
2966
 
2966
2967
  // src/arkade-swaps.ts
2967
- var import_sdk8, import_sha23, import_base9, import_secp256k13, import_utils3, import_btc_signer5, import_utils4, CLAIM_VTXO_RETRY_ATTEMPTS, CLAIM_VTXO_RETRY_DELAY_MS, ArkadeSwaps;
2968
+ var import_sdk8, import_sha23, import_base9, import_secp256k13, import_utils3, import_btc_signer5, import_utils4, dedupeVtxos, hasNonEmptyString, canRecoverViaBoltz3of3, isSubmarineRefundLocktimeReached, CLAIM_VTXO_RETRY_ATTEMPTS, CLAIM_VTXO_RETRY_DELAY_MS, ArkadeSwaps;
2968
2969
  var init_arkade_swaps = __esm({
2969
2970
  "src/arkade-swaps.ts"() {
2970
2971
  "use strict";
@@ -2988,6 +2989,20 @@ var init_arkade_swaps = __esm({
2988
2989
  init_swap_repository();
2989
2990
  init_identity();
2990
2991
  init_vhtlc();
2992
+ dedupeVtxos = (vtxos) => [
2993
+ ...new Map(
2994
+ vtxos.map((vtxo) => [`${vtxo.txid}:${vtxo.vout}`, vtxo])
2995
+ ).values()
2996
+ ];
2997
+ hasNonEmptyString = (value) => typeof value === "string" && value.length > 0;
2998
+ canRecoverViaBoltz3of3 = (refundableVtxos, swap) => {
2999
+ const hasRequiredSwapMetadata = hasNonEmptyString(swap.id) && hasNonEmptyString(swap.request.refundPublicKey) && hasNonEmptyString(swap.response.address) && hasNonEmptyString(swap.response.claimPublicKey) && !!swap.response.timeoutBlockHeights;
3000
+ if (!hasRequiredSwapMetadata) return false;
3001
+ return refundableVtxos.some(
3002
+ (vtxo) => !vtxo.isSpent && !(0, import_sdk8.isRecoverable)(vtxo)
3003
+ );
3004
+ };
3005
+ isSubmarineRefundLocktimeReached = (refundTimestamp) => Math.floor(Date.now() / 1e3) >= refundTimestamp;
2991
3006
  CLAIM_VTXO_RETRY_ATTEMPTS = 3;
2992
3007
  CLAIM_VTXO_RETRY_DELAY_MS = 500;
2993
3008
  ArkadeSwaps = class _ArkadeSwaps {
@@ -3255,8 +3270,12 @@ var init_arkade_swaps = __esm({
3255
3270
  throw new Error(
3256
3271
  `Swap ${pendingSwap.id}: preimage is required to claim VHTLC`
3257
3272
  );
3258
- const { refundPublicKey, lockupAddress, timeoutBlockHeights } = pendingSwap.response;
3259
- if (!refundPublicKey || !lockupAddress || !timeoutBlockHeights)
3273
+ const {
3274
+ refundPublicKey,
3275
+ lockupAddress,
3276
+ timeoutBlockHeights: vhtlcTimeouts
3277
+ } = pendingSwap.response;
3278
+ if (!refundPublicKey || !lockupAddress || !vhtlcTimeouts)
3260
3279
  throw new Error(
3261
3280
  `Swap ${pendingSwap.id}: incomplete reverse swap response`
3262
3281
  );
@@ -3284,7 +3303,7 @@ var init_arkade_swaps = __esm({
3284
3303
  receiverPubkey: import_base9.hex.encode(receiverXOnly),
3285
3304
  senderPubkey: import_base9.hex.encode(senderXOnly),
3286
3305
  serverPubkey: import_base9.hex.encode(serverXOnly),
3287
- timeoutBlockHeights
3306
+ timeoutBlockHeights: vhtlcTimeouts
3288
3307
  });
3289
3308
  if (!vhtlcScript.claimScript)
3290
3309
  throw new Error(
@@ -3512,84 +3531,184 @@ var init_arkade_swaps = __esm({
3512
3531
  return pendingSwap;
3513
3532
  }
3514
3533
  /**
3515
- * Refunds the VHTLC for a failed submarine swap, returning locked funds to the wallet.
3516
- * Uses multi-party signatures (user + Boltz + server) for non-recoverable VTXOs.
3517
- * @param pendingSwap - The submarine swap to refund.
3518
- * @throws {Error} If preimage hash is unavailable, VHTLC not found, or already spent.
3534
+ * Reconstruct a submarine swap's VHTLC script from stored data. This does
3535
+ * not query the indexer, so bulk scans can build every script first and
3536
+ * then use batched VTXO lookups.
3537
+ *
3538
+ * @throws {Error} If preimage hash is unavailable, the swap response is
3539
+ * incomplete, the script can't be built, or the reconstructed address
3540
+ * doesn't match the one Boltz returned.
3519
3541
  */
3520
- async refundVHTLC(pendingSwap) {
3521
- const preimageHash = pendingSwap.request.invoice ? getInvoicePaymentHash(pendingSwap.request.invoice) : pendingSwap.preimageHash;
3542
+ async buildSubmarineVHTLCContext(swap, arkInfo) {
3543
+ const preimageHash = swap.request.invoice ? getInvoicePaymentHash(swap.request.invoice) : swap.preimageHash;
3522
3544
  if (!preimageHash)
3523
3545
  throw new Error(
3524
- `Swap ${pendingSwap.id}: preimage hash is required to refund VHTLC`
3546
+ `Swap ${swap.id}: preimage hash is required to refund VHTLC`
3525
3547
  );
3526
- const arkInfo = await this.arkProvider.getInfo();
3527
- const address = await this.wallet.getAddress();
3528
- if (!address) throw new Error("Failed to get ark address from wallet");
3548
+ const resolvedArkInfo = arkInfo ?? await this.arkProvider.getInfo();
3529
3549
  const ourXOnlyPublicKey = normalizeToXOnlyKey(
3530
3550
  await this.wallet.identity.xOnlyPublicKey(),
3531
3551
  "our",
3532
- pendingSwap.id
3552
+ swap.id
3533
3553
  );
3534
3554
  const serverXOnlyPublicKey = normalizeToXOnlyKey(
3535
- import_base9.hex.decode(arkInfo.signerPubkey),
3555
+ import_base9.hex.decode(resolvedArkInfo.signerPubkey),
3536
3556
  "server",
3537
- pendingSwap.id
3557
+ swap.id
3538
3558
  );
3539
- const { claimPublicKey, timeoutBlockHeights } = pendingSwap.response;
3540
- if (!claimPublicKey || !timeoutBlockHeights)
3559
+ const { claimPublicKey, timeoutBlockHeights: vhtlcTimeouts } = swap.response;
3560
+ if (!claimPublicKey || !vhtlcTimeouts)
3541
3561
  throw new Error(
3542
- `Swap ${pendingSwap.id}: incomplete submarine swap response`
3562
+ `Swap ${swap.id}: incomplete submarine swap response`
3543
3563
  );
3544
3564
  const boltzXOnlyPublicKey = normalizeToXOnlyKey(
3545
3565
  import_base9.hex.decode(claimPublicKey),
3546
3566
  "boltz",
3547
- pendingSwap.id
3567
+ swap.id
3548
3568
  );
3549
3569
  const { vhtlcScript, vhtlcAddress } = this.createVHTLCScript({
3550
- network: arkInfo.network,
3570
+ network: resolvedArkInfo.network,
3551
3571
  preimageHash: import_base9.hex.decode(preimageHash),
3552
3572
  receiverPubkey: import_base9.hex.encode(boltzXOnlyPublicKey),
3553
3573
  senderPubkey: import_base9.hex.encode(ourXOnlyPublicKey),
3554
3574
  serverPubkey: import_base9.hex.encode(serverXOnlyPublicKey),
3555
- timeoutBlockHeights
3575
+ timeoutBlockHeights: vhtlcTimeouts
3556
3576
  });
3557
3577
  if (!vhtlcScript.claimScript)
3558
3578
  throw new Error(
3559
- `Swap ${pendingSwap.id}: failed to create VHTLC script for submarine swap`
3579
+ `Swap ${swap.id}: failed to create VHTLC script for submarine swap`
3560
3580
  );
3561
- if (vhtlcAddress !== pendingSwap.response.address)
3581
+ if (vhtlcAddress !== swap.response.address)
3562
3582
  throw new Error(
3563
- `VHTLC address mismatch for swap ${pendingSwap.id}: expected ${pendingSwap.response.address}, got ${vhtlcAddress}`
3583
+ `VHTLC address mismatch for swap ${swap.id}: expected ${swap.response.address}, got ${vhtlcAddress}`
3564
3584
  );
3565
3585
  const vhtlcPkScriptHex = import_base9.hex.encode(vhtlcScript.pkScript);
3586
+ return {
3587
+ arkInfo: resolvedArkInfo,
3588
+ vhtlcScript,
3589
+ vhtlcAddress,
3590
+ vhtlcPkScriptHex,
3591
+ vhtlcTimeouts,
3592
+ ourXOnlyPublicKey,
3593
+ serverXOnlyPublicKey,
3594
+ boltzXOnlyPublicKey
3595
+ };
3596
+ }
3597
+ /**
3598
+ * Reconstruct a submarine swap's VHTLC script from stored data and look
3599
+ * up its VTXOs at the indexer. Side-effect free; shared by `refundVHTLC`
3600
+ * (spending path) and `inspectSubmarineRecovery` (diagnostic path).
3601
+ *
3602
+ * `refundableVtxos` merges spendable + recoverable indexer queries
3603
+ * (deduped by outpoint). When that set is empty, a third query
3604
+ * populates `diagnostic` so callers can distinguish "never funded",
3605
+ * "already spent", and "preconfirmed-only".
3606
+ */
3607
+ async lookupSubmarineVHTLC(swap, arkInfo) {
3608
+ const context = await this.buildSubmarineVHTLCContext(swap, arkInfo);
3566
3609
  const [spendableResult, recoverableResult] = await Promise.all([
3567
3610
  this.indexerProvider.getVtxos({
3568
- scripts: [vhtlcPkScriptHex],
3611
+ scripts: [context.vhtlcPkScriptHex],
3569
3612
  spendableOnly: true
3570
3613
  }),
3571
3614
  this.indexerProvider.getVtxos({
3572
- scripts: [vhtlcPkScriptHex],
3615
+ scripts: [context.vhtlcPkScriptHex],
3573
3616
  recoverableOnly: true
3574
3617
  })
3575
3618
  ]);
3576
- const refundableVtxos = [
3577
- ...new Map(
3578
- [...spendableResult.vtxos, ...recoverableResult.vtxos].map(
3579
- (vtxo) => [`${vtxo.txid}:${vtxo.vout}`, vtxo]
3580
- )
3581
- ).values()
3582
- ];
3619
+ const refundableVtxos = dedupeVtxos([
3620
+ ...spendableResult.vtxos,
3621
+ ...recoverableResult.vtxos
3622
+ ]);
3623
+ let diagnostic;
3583
3624
  if (refundableVtxos.length === 0) {
3584
3625
  const { vtxos: allVtxos } = await this.indexerProvider.getVtxos({
3585
- scripts: [vhtlcPkScriptHex]
3626
+ scripts: [context.vhtlcPkScriptHex]
3586
3627
  });
3587
- if (allVtxos.length === 0) {
3628
+ diagnostic = {
3629
+ totalVtxoCount: allVtxos.length,
3630
+ allSpent: allVtxos.length > 0 && allVtxos.every((vtxo) => vtxo.isSpent)
3631
+ };
3632
+ }
3633
+ return {
3634
+ ...context,
3635
+ refundableVtxos,
3636
+ diagnostic
3637
+ };
3638
+ }
3639
+ submarineRecoveryInfoFromLookup(swap, lookup) {
3640
+ const { refundableVtxos, diagnostic, vhtlcTimeouts } = lookup;
3641
+ if (refundableVtxos.length > 0) {
3642
+ const cltvSatisfied = isSubmarineRefundLocktimeReached(
3643
+ vhtlcTimeouts.refund
3644
+ );
3645
+ const amountSats = refundableVtxos.reduce(
3646
+ (sum, vtxo) => sum + Number(vtxo.value),
3647
+ 0
3648
+ );
3649
+ const isRecoverable2 = cltvSatisfied || canRecoverViaBoltz3of3(refundableVtxos, swap);
3650
+ return {
3651
+ swap,
3652
+ status: isRecoverable2 ? "recoverable" : "pre_cltv",
3653
+ vtxoCount: refundableVtxos.length,
3654
+ amountSats,
3655
+ refundLocktime: vhtlcTimeouts.refund
3656
+ };
3657
+ }
3658
+ if (!diagnostic || diagnostic.totalVtxoCount === 0) {
3659
+ return {
3660
+ swap,
3661
+ status: "none",
3662
+ vtxoCount: 0,
3663
+ amountSats: 0,
3664
+ refundLocktime: vhtlcTimeouts.refund
3665
+ };
3666
+ }
3667
+ if (diagnostic.allSpent) {
3668
+ return {
3669
+ swap,
3670
+ status: "already_spent",
3671
+ vtxoCount: 0,
3672
+ amountSats: 0,
3673
+ refundLocktime: vhtlcTimeouts.refund
3674
+ };
3675
+ }
3676
+ return {
3677
+ swap,
3678
+ status: "none",
3679
+ vtxoCount: 0,
3680
+ amountSats: 0,
3681
+ refundLocktime: vhtlcTimeouts.refund
3682
+ };
3683
+ }
3684
+ /**
3685
+ * Refunds the VHTLC for a failed submarine swap, returning locked funds to the wallet.
3686
+ * Uses multi-party signatures (user + Boltz + server) for non-recoverable VTXOs.
3687
+ * @param pendingSwap - The submarine swap to refund.
3688
+ * @returns Counts of VTXOs swept vs. deferred. A return value of `{ swept: 0, skipped: N }`
3689
+ * means the call was a no-op — callers should not treat it as a successful refund.
3690
+ * @throws {Error} If preimage hash is unavailable, VHTLC not found, or already spent.
3691
+ */
3692
+ async refundVHTLC(pendingSwap, cachedArkInfo) {
3693
+ const address = await this.wallet.getAddress();
3694
+ if (!address) throw new Error("Failed to get ark address from wallet");
3695
+ const {
3696
+ arkInfo,
3697
+ vhtlcScript,
3698
+ vhtlcTimeouts,
3699
+ ourXOnlyPublicKey,
3700
+ serverXOnlyPublicKey,
3701
+ boltzXOnlyPublicKey,
3702
+ refundableVtxos,
3703
+ diagnostic
3704
+ } = await this.lookupSubmarineVHTLC(pendingSwap, cachedArkInfo);
3705
+ if (refundableVtxos.length === 0) {
3706
+ if (!diagnostic || diagnostic.totalVtxoCount === 0) {
3588
3707
  throw new Error(
3589
3708
  `Swap ${pendingSwap.id}: VHTLC not found for address ${pendingSwap.response.address}`
3590
3709
  );
3591
3710
  }
3592
- if (allVtxos.every((vtxo) => vtxo.isSpent)) {
3711
+ if (diagnostic.allSpent) {
3593
3712
  throw new Error(
3594
3713
  `Swap ${pendingSwap.id}: VHTLC is already spent`
3595
3714
  );
@@ -3600,10 +3719,11 @@ var init_arkade_swaps = __esm({
3600
3719
  }
3601
3720
  const outputScript = import_sdk8.ArkAddress.decode(address).pkScript;
3602
3721
  const refundWithoutReceiverLeaf = vhtlcScript.refundWithoutReceiver();
3603
- const refundLocktime = BigInt(timeoutBlockHeights.refund);
3604
- const currentBlockHeight = await this.swapProvider.getChainHeight();
3605
- const cltvSatisfied = BigInt(currentBlockHeight) >= refundLocktime;
3722
+ const cltvSatisfied = isSubmarineRefundLocktimeReached(
3723
+ vhtlcTimeouts.refund
3724
+ );
3606
3725
  let boltzCallCount = 0;
3726
+ let sweptCount = 0;
3607
3727
  let skippedCount = 0;
3608
3728
  for (const vtxo of refundableVtxos) {
3609
3729
  const isRecoverableVtxo = (0, import_sdk8.isRecoverable)(vtxo);
@@ -3624,11 +3744,12 @@ var init_arkade_swaps = __esm({
3624
3744
  arkInfo,
3625
3745
  isRecoverableVtxo
3626
3746
  );
3747
+ sweptCount++;
3627
3748
  continue;
3628
3749
  }
3629
3750
  if (isRecoverableVtxo) {
3630
3751
  logger.error(
3631
- `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.`
3752
+ `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.`
3632
3753
  );
3633
3754
  skippedCount++;
3634
3755
  continue;
@@ -3657,14 +3778,14 @@ var init_arkade_swaps = __esm({
3657
3778
  )
3658
3779
  );
3659
3780
  boltzCallCount++;
3781
+ sweptCount++;
3660
3782
  } catch (error) {
3661
3783
  if (!(error instanceof BoltzRefundError)) {
3662
3784
  throw error;
3663
3785
  }
3664
- const tipNow = await this.swapProvider.getChainHeight();
3665
- if (BigInt(tipNow) < refundLocktime) {
3786
+ if (!isSubmarineRefundLocktimeReached(vhtlcTimeouts.refund)) {
3666
3787
  logger.error(
3667
- `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.`
3788
+ `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.`
3668
3789
  );
3669
3790
  skippedCount++;
3670
3791
  continue;
@@ -3684,16 +3805,218 @@ var init_arkade_swaps = __esm({
3684
3805
  arkInfo,
3685
3806
  false
3686
3807
  );
3808
+ sweptCount++;
3687
3809
  }
3688
3810
  }
3689
- const fullyRefunded = skippedCount === 0;
3690
- await updateSubmarineSwapStatus(
3691
- pendingSwap,
3692
- pendingSwap.status,
3693
- // Keep current status
3694
- this.savePendingSubmarineSwap.bind(this),
3695
- { refundable: true, refunded: fullyRefunded }
3811
+ if (!isSubmarineSuccessStatus(pendingSwap.status)) {
3812
+ const fullyRefunded = skippedCount === 0;
3813
+ await updateSubmarineSwapStatus(
3814
+ pendingSwap,
3815
+ pendingSwap.status,
3816
+ // Keep current status
3817
+ this.savePendingSubmarineSwap.bind(this),
3818
+ { refundable: true, refunded: fullyRefunded }
3819
+ );
3820
+ }
3821
+ return { swept: sweptCount, skipped: skippedCount };
3822
+ }
3823
+ /**
3824
+ * Inspect a submarine swap's lockup address for recoverable funds.
3825
+ *
3826
+ * Side-effect free. Returns a structured snapshot the UI can use to
3827
+ * decide whether to offer the user a recovery action — it will not
3828
+ * trigger any signing or persistence.
3829
+ *
3830
+ * Only `transaction.claimed` (success with possible stranded extras)
3831
+ * and refundable failure statuses are recovery candidates. Pending
3832
+ * statuses (`invoice.set`, `transaction.mempool`, …) are returned as
3833
+ * `invalid_swap`; this API is for recovery, not a generic VTXO probe.
3834
+ *
3835
+ * @param swap - The submarine swap to inspect.
3836
+ */
3837
+ async inspectSubmarineRecovery(swap) {
3838
+ if (!isSubmarineSuccessStatus(swap.status) && !isSubmarineRefundableStatus(swap.status)) {
3839
+ return {
3840
+ swap,
3841
+ status: "invalid_swap",
3842
+ vtxoCount: 0,
3843
+ amountSats: 0,
3844
+ refundLocktime: swap.response.timeoutBlockHeights?.refund,
3845
+ error: `Swap status ${swap.status} is not a recovery candidate`
3846
+ };
3847
+ }
3848
+ let lookup;
3849
+ try {
3850
+ lookup = await this.lookupSubmarineVHTLC(swap);
3851
+ } catch (err) {
3852
+ return {
3853
+ swap,
3854
+ status: "invalid_swap",
3855
+ vtxoCount: 0,
3856
+ amountSats: 0,
3857
+ refundLocktime: swap.response.timeoutBlockHeights?.refund,
3858
+ error: err instanceof Error ? err.message : String(err)
3859
+ };
3860
+ }
3861
+ return this.submarineRecoveryInfoFromLookup(swap, lookup);
3862
+ }
3863
+ /**
3864
+ * Scan all locally-known submarine swaps for recoverable VHTLC funds.
3865
+ *
3866
+ * Loads submarine swaps from the repository, filters to recovery
3867
+ * candidates (`transaction.claimed` plus refundable failure
3868
+ * statuses), reconstructs their scripts, and performs one batched
3869
+ * spendable query plus one batched recoverable query. Pending swaps are
3870
+ * skipped entirely — they appear in the local repository but cannot
3871
+ * be in a recovery state yet.
3872
+ *
3873
+ * Side-effect free: does not mutate the repository, does not sign,
3874
+ * and does not query Boltz swap status.
3875
+ */
3876
+ async scanRecoverableSubmarineSwaps() {
3877
+ const submarineSwaps = await this.swapRepository.getAllSwaps({
3878
+ type: "submarine"
3879
+ });
3880
+ const candidates = submarineSwaps.filter(
3881
+ (swap) => isSubmarineSuccessStatus(swap.status) || isSubmarineRefundableStatus(swap.status)
3882
+ );
3883
+ let arkInfo;
3884
+ let arkInfoError;
3885
+ if (candidates.length > 0) {
3886
+ try {
3887
+ arkInfo = await this.arkProvider.getInfo();
3888
+ } catch (err) {
3889
+ arkInfoError = err instanceof Error ? err.message : String(err);
3890
+ }
3891
+ }
3892
+ const prepared = await Promise.all(
3893
+ candidates.map(async (swap) => {
3894
+ if (arkInfoError) {
3895
+ return {
3896
+ swap,
3897
+ error: arkInfoError
3898
+ };
3899
+ }
3900
+ try {
3901
+ return {
3902
+ swap,
3903
+ context: await this.buildSubmarineVHTLCContext(
3904
+ swap,
3905
+ arkInfo
3906
+ )
3907
+ };
3908
+ } catch (err) {
3909
+ return {
3910
+ swap,
3911
+ error: err instanceof Error ? err.message : String(err)
3912
+ };
3913
+ }
3914
+ })
3915
+ );
3916
+ const valid = prepared.filter(
3917
+ (item) => "context" in item
3696
3918
  );
3919
+ const scripts = [
3920
+ ...new Set(valid.map(({ context }) => context.vhtlcPkScriptHex))
3921
+ ];
3922
+ const refundableByScript = /* @__PURE__ */ new Map();
3923
+ if (scripts.length > 0) {
3924
+ const [spendableResult, recoverableResult] = await Promise.all([
3925
+ this.indexerProvider.getVtxos({
3926
+ scripts,
3927
+ spendableOnly: true
3928
+ }),
3929
+ this.indexerProvider.getVtxos({
3930
+ scripts,
3931
+ recoverableOnly: true
3932
+ })
3933
+ ]);
3934
+ for (const vtxo of dedupeVtxos([
3935
+ ...spendableResult.vtxos,
3936
+ ...recoverableResult.vtxos
3937
+ ])) {
3938
+ const script = vtxo.script?.toLowerCase();
3939
+ if (!script) continue;
3940
+ const existing = refundableByScript.get(script) ?? [];
3941
+ existing.push(vtxo);
3942
+ refundableByScript.set(script, existing);
3943
+ }
3944
+ }
3945
+ return prepared.map((item) => {
3946
+ if ("error" in item) {
3947
+ return {
3948
+ swap: item.swap,
3949
+ status: "invalid_swap",
3950
+ vtxoCount: 0,
3951
+ amountSats: 0,
3952
+ refundLocktime: item.swap.response.timeoutBlockHeights?.refund,
3953
+ error: item.error
3954
+ };
3955
+ }
3956
+ const refundableVtxos = refundableByScript.get(
3957
+ item.context.vhtlcPkScriptHex.toLowerCase()
3958
+ ) ?? [];
3959
+ return this.submarineRecoveryInfoFromLookup(item.swap, {
3960
+ ...item.context,
3961
+ refundableVtxos
3962
+ });
3963
+ });
3964
+ }
3965
+ /**
3966
+ * Recover funds locked at a single submarine swap's VHTLC address.
3967
+ *
3968
+ * Thin wrapper around `refundVHTLC` for callers that have already
3969
+ * confirmed (e.g. via `inspectSubmarineRecovery`) that funds are
3970
+ * present. Centralises the spending logic in one place — flag-write
3971
+ * behavior matches `refundVHTLC` (no-op for `transaction.claimed`,
3972
+ * normal flag updates for failure statuses).
3973
+ */
3974
+ async recoverSubmarineFunds(swap, arkInfo) {
3975
+ return this.refundVHTLC(swap, arkInfo);
3976
+ }
3977
+ /**
3978
+ * Recover funds for a batch of submarine swaps.
3979
+ *
3980
+ * Each swap's recovery is independent — a failure on one swap does
3981
+ * not abort the rest, and the caller receives a per-swap result so
3982
+ * they can present partial outcomes in the UI. Recovery runs
3983
+ * sequentially to avoid hammering Boltz / the indexer with parallel
3984
+ * batch joins.
3985
+ */
3986
+ async recoverAllSubmarineFunds(swaps) {
3987
+ const results = [];
3988
+ let arkInfo;
3989
+ try {
3990
+ if (swaps.length > 0) {
3991
+ arkInfo = await this.arkProvider.getInfo();
3992
+ }
3993
+ } catch (err) {
3994
+ const error = err instanceof Error ? err.message : String(err);
3995
+ return swaps.map((swap) => ({
3996
+ swapId: swap.id,
3997
+ recovered: false,
3998
+ skipped: false,
3999
+ error
4000
+ }));
4001
+ }
4002
+ for (const swap of swaps) {
4003
+ try {
4004
+ const outcome = await this.recoverSubmarineFunds(swap, arkInfo);
4005
+ results.push({
4006
+ swapId: swap.id,
4007
+ recovered: outcome.swept > 0,
4008
+ skipped: outcome.skipped > 0
4009
+ });
4010
+ } catch (err) {
4011
+ results.push({
4012
+ swapId: swap.id,
4013
+ recovered: false,
4014
+ skipped: false,
4015
+ error: err instanceof Error ? err.message : String(err)
4016
+ });
4017
+ }
4018
+ }
4019
+ return results;
3697
4020
  }
3698
4021
  /**
3699
4022
  * Waits for a submarine swap's Lightning payment to settle.
@@ -4475,14 +4798,14 @@ var init_arkade_swaps = __esm({
4475
4798
  const serverPubkey = import_base9.hex.encode(
4476
4799
  normalizeToXOnlyKey(arkInfo.signerPubkey, "server")
4477
4800
  );
4478
- const timeoutBlockHeights = to === "ARK" ? swap.response.claimDetails.timeouts : swap.response.lockupDetails.timeouts;
4801
+ const vhtlcTimeouts = to === "ARK" ? swap.response.claimDetails.timeouts : swap.response.lockupDetails.timeouts;
4479
4802
  const { vhtlcAddress } = this.createVHTLCScript({
4480
4803
  network: arkInfo.network,
4481
4804
  preimageHash: import_base9.hex.decode(swap.request.preimageHash),
4482
4805
  receiverPubkey,
4483
4806
  senderPubkey,
4484
4807
  serverPubkey,
4485
- timeoutBlockHeights
4808
+ timeoutBlockHeights: vhtlcTimeouts
4486
4809
  });
4487
4810
  if (lockupAddress !== vhtlcAddress) {
4488
4811
  throw new SwapError({
@@ -5262,6 +5585,18 @@ var ExpoArkadeSwaps = class _ExpoArkadeSwaps {
5262
5585
  refundVHTLC(pendingSwap) {
5263
5586
  return this.inner.refundVHTLC(pendingSwap);
5264
5587
  }
5588
+ inspectSubmarineRecovery(swap) {
5589
+ return this.inner.inspectSubmarineRecovery(swap);
5590
+ }
5591
+ scanRecoverableSubmarineSwaps() {
5592
+ return this.inner.scanRecoverableSubmarineSwaps();
5593
+ }
5594
+ recoverSubmarineFunds(swap) {
5595
+ return this.inner.recoverSubmarineFunds(swap);
5596
+ }
5597
+ recoverAllSubmarineFunds(swaps) {
5598
+ return this.inner.recoverAllSubmarineFunds(swaps);
5599
+ }
5265
5600
  waitAndClaim(pendingSwap) {
5266
5601
  return this.inner.waitAndClaim(pendingSwap);
5267
5602
  }