@arkade-os/boltz-swap 0.3.33 → 0.3.34

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.
@@ -1,5 +1,5 @@
1
1
  import { IWallet, ArkProvider, IndexerProvider, ArkInfo, Identity, ArkTxInput, VHTLC } from '@arkade-os/sdk';
2
- import { q as BoltzSwapProvider, w as SwapManager, m as SwapRepository, p as ArkadeSwapsCreateConfig, A as ArkadeSwapsConfig, n as SwapManagerClient, C as CreateLightningInvoiceRequest, e as CreateLightningInvoiceResponse, b as BoltzReverseSwap, S as SendLightningPaymentRequest, f as SendLightningPaymentResponse, c as BoltzSubmarineSwap, g as SubmarineRefundOutcome, h as SubmarineRecoveryInfo, i as SubmarineRecoveryResult, k as ArkToBtcResponse, a as BoltzChainSwap, l as BtcToArkResponse, d as Chain, F as FeesResponse, j as ChainFeesResponse, L as LimitsResponse, G as GetSwapStatusResponse, B as BoltzSwap } from './types-CrKkVzBB.cjs';
2
+ import { r as BoltzSwapProvider, x as SwapManager, n as SwapRepository, q as ArkadeSwapsCreateConfig, A as ArkadeSwapsConfig, o as SwapManagerClient, C as CreateLightningInvoiceRequest, e as CreateLightningInvoiceResponse, b as BoltzReverseSwap, S as SendLightningPaymentRequest, f as SendLightningPaymentResponse, c as BoltzSubmarineSwap, g as SubmarineRefundOutcome, h as SubmarineRecoveryInfo, i as SubmarineRecoveryResult, k as ArkToBtcResponse, a as BoltzChainSwap, m as ChainArkRefundOutcome, l as BtcToArkResponse, d as Chain, F as FeesResponse, j as ChainFeesResponse, L as LimitsResponse, G as GetSwapStatusResponse, B as BoltzSwap } from './types--axEWA8c.js';
3
3
  import { TransactionOutput } from '@scure/btc-signer/psbt.js';
4
4
 
5
5
  /**
@@ -267,10 +267,19 @@ declare class ArkadeSwaps {
267
267
  */
268
268
  claimBtc(pendingSwap: BoltzChainSwap): Promise<void>;
269
269
  /**
270
- * When an ARK to BTC swap fails, refund sats on ARK chain by claiming the VHTLC.
270
+ * When an ARK to BTC swap fails, refund every unspent VTXO at the chain
271
+ * swap's ARK lockup address.
272
+ *
273
+ * Path selection per VTXO:
274
+ * - CLTV has elapsed → `refundWithoutReceiver` via `joinBatch` (no Boltz).
275
+ * - Pre-CLTV recoverable → skipped (Boltz can't co-sign swept-batch refund).
276
+ * - Pre-CLTV non-recoverable → cooperative 3-of-3 refund via Boltz.
277
+ *
271
278
  * @param pendingSwap - The pending chain swap to refund.
279
+ * @returns Counts of VTXOs swept vs. deferred. A `swept: 0` outcome means
280
+ * the call was a no-op — callers should retry after CLTV.
272
281
  */
273
- refundArk(pendingSwap: BoltzChainSwap): Promise<void>;
282
+ refundArk(pendingSwap: BoltzChainSwap): Promise<ChainArkRefundOutcome>;
274
283
  /**
275
284
  * Creates a chain swap from BTC to ARK.
276
285
  * @param args.feeSatsPerByte - Fee rate for BTC transactions (default: 1).
@@ -515,7 +524,7 @@ interface IArkadeSwaps extends AsyncDisposable {
515
524
  txid: string;
516
525
  }>;
517
526
  claimBtc(pendingSwap: BoltzChainSwap): Promise<void>;
518
- refundArk(pendingSwap: BoltzChainSwap): Promise<void>;
527
+ refundArk(pendingSwap: BoltzChainSwap): Promise<ChainArkRefundOutcome>;
519
528
  btcToArk(args: {
520
529
  feeSatsPerByte?: number;
521
530
  senderLockAmount?: number;
@@ -1,5 +1,5 @@
1
1
  import { IWallet, ArkProvider, IndexerProvider, ArkInfo, Identity, ArkTxInput, VHTLC } from '@arkade-os/sdk';
2
- import { q as BoltzSwapProvider, w as SwapManager, m as SwapRepository, p as ArkadeSwapsCreateConfig, A as ArkadeSwapsConfig, n as SwapManagerClient, C as CreateLightningInvoiceRequest, e as CreateLightningInvoiceResponse, b as BoltzReverseSwap, S as SendLightningPaymentRequest, f as SendLightningPaymentResponse, c as BoltzSubmarineSwap, g as SubmarineRefundOutcome, h as SubmarineRecoveryInfo, i as SubmarineRecoveryResult, k as ArkToBtcResponse, a as BoltzChainSwap, l as BtcToArkResponse, d as Chain, F as FeesResponse, j as ChainFeesResponse, L as LimitsResponse, G as GetSwapStatusResponse, B as BoltzSwap } from './types-CrKkVzBB.js';
2
+ import { r as BoltzSwapProvider, x as SwapManager, n as SwapRepository, q as ArkadeSwapsCreateConfig, A as ArkadeSwapsConfig, o as SwapManagerClient, C as CreateLightningInvoiceRequest, e as CreateLightningInvoiceResponse, b as BoltzReverseSwap, S as SendLightningPaymentRequest, f as SendLightningPaymentResponse, c as BoltzSubmarineSwap, g as SubmarineRefundOutcome, h as SubmarineRecoveryInfo, i as SubmarineRecoveryResult, k as ArkToBtcResponse, a as BoltzChainSwap, m as ChainArkRefundOutcome, l as BtcToArkResponse, d as Chain, F as FeesResponse, j as ChainFeesResponse, L as LimitsResponse, G as GetSwapStatusResponse, B as BoltzSwap } from './types--axEWA8c.cjs';
3
3
  import { TransactionOutput } from '@scure/btc-signer/psbt.js';
4
4
 
5
5
  /**
@@ -267,10 +267,19 @@ declare class ArkadeSwaps {
267
267
  */
268
268
  claimBtc(pendingSwap: BoltzChainSwap): Promise<void>;
269
269
  /**
270
- * When an ARK to BTC swap fails, refund sats on ARK chain by claiming the VHTLC.
270
+ * When an ARK to BTC swap fails, refund every unspent VTXO at the chain
271
+ * swap's ARK lockup address.
272
+ *
273
+ * Path selection per VTXO:
274
+ * - CLTV has elapsed → `refundWithoutReceiver` via `joinBatch` (no Boltz).
275
+ * - Pre-CLTV recoverable → skipped (Boltz can't co-sign swept-batch refund).
276
+ * - Pre-CLTV non-recoverable → cooperative 3-of-3 refund via Boltz.
277
+ *
271
278
  * @param pendingSwap - The pending chain swap to refund.
279
+ * @returns Counts of VTXOs swept vs. deferred. A `swept: 0` outcome means
280
+ * the call was a no-op — callers should retry after CLTV.
272
281
  */
273
- refundArk(pendingSwap: BoltzChainSwap): Promise<void>;
282
+ refundArk(pendingSwap: BoltzChainSwap): Promise<ChainArkRefundOutcome>;
274
283
  /**
275
284
  * Creates a chain swap from BTC to ARK.
276
285
  * @param args.feeSatsPerByte - Fee rate for BTC transactions (default: 1).
@@ -515,7 +524,7 @@ interface IArkadeSwaps extends AsyncDisposable {
515
524
  txid: string;
516
525
  }>;
517
526
  claimBtc(pendingSwap: BoltzChainSwap): Promise<void>;
518
- refundArk(pendingSwap: BoltzChainSwap): Promise<void>;
527
+ refundArk(pendingSwap: BoltzChainSwap): Promise<ChainArkRefundOutcome>;
519
528
  btcToArk(args: {
520
529
  feeSatsPerByte?: number;
521
530
  senderLockAmount?: number;
@@ -7,7 +7,7 @@ import {
7
7
  isSubmarineFinalStatus,
8
8
  isSubmarineSwapRefundable,
9
9
  logger
10
- } from "./chunk-SJ5SYSMK.js";
10
+ } from "./chunk-TDBUZE4N.js";
11
11
 
12
12
  // src/expo/swapsPollProcessor.ts
13
13
  var SWAP_POLL_TASK_TYPE = "swap-poll";
@@ -128,6 +128,8 @@ var QuoteRejectedError = class _QuoteRejectedError extends SwapError {
128
128
  return `Boltz quote ${options.quotedAmount} is below acceptable floor ${options.floor}`;
129
129
  case "non_positive":
130
130
  return `Boltz quote ${options.quotedAmount} is not positive`;
131
+ case "non_safe_integer":
132
+ return `Boltz quote ${options.quotedAmount} is not a safe positive satoshi integer`;
131
133
  case "no_baseline":
132
134
  return "Cannot accept quote: no minAcceptableAmount and no stored pending swap";
133
135
  }
@@ -181,6 +183,7 @@ var QuoteRejectedError = class _QuoteRejectedError extends SwapError {
181
183
  message
182
184
  });
183
185
  case "non_positive":
186
+ case "non_safe_integer":
184
187
  if (quotedAmount === null) return null;
185
188
  return new _QuoteRejectedError({
186
189
  reason,
@@ -196,6 +199,7 @@ var QUOTE_REJECTION_TRANSPORT_PREFIX = "QUOTE_REJECTED::";
196
199
  var QUOTE_REJECTION_REASONS = /* @__PURE__ */ new Set([
197
200
  "below_floor",
198
201
  "non_positive",
202
+ "non_safe_integer",
199
203
  "no_baseline"
200
204
  ]);
201
205
  var BoltzRefundError = class extends Error {
@@ -1041,6 +1045,13 @@ var SwapManager = class _SwapManager {
1041
1045
  * enough that a real "swap unknown to this provider" surfaces quickly.
1042
1046
  */
1043
1047
  static NOT_FOUND_THRESHOLD = 10;
1048
+ /**
1049
+ * Delay between re-attempts of a chain refund that left VTXOs deferred
1050
+ * (e.g. pre-CLTV recoverable VTXO, or Boltz 3-of-3 rejected before CLTV
1051
+ * has elapsed). Boltz won't send another status update once the swap
1052
+ * is `swap.expired`, so the manager owns the local retry cadence.
1053
+ */
1054
+ static REFUND_RETRY_DELAY_MS = 6e4;
1044
1055
  swapProvider;
1045
1056
  config;
1046
1057
  // Event listeners storage (supports multiple listeners per event)
@@ -1059,6 +1070,11 @@ var SwapManager = class _SwapManager {
1059
1070
  reconnectTimer = null;
1060
1071
  initialPollTimer = null;
1061
1072
  pollRetryTimers = /* @__PURE__ */ new Map();
1073
+ // Per-swap retry timers for chain refunds that left work undone
1074
+ // (refundArk returned `skipped > 0`). The swap is held in
1075
+ // `monitoredSwaps` past its terminal Boltz status until the local
1076
+ // refund completes or the manager stops.
1077
+ refundRetryTimers = /* @__PURE__ */ new Map();
1062
1078
  // Per-swap counter of consecutive `SwapNotFoundError` responses from
1063
1079
  // `getSwapStatus`. Reset on any successful poll. Once a swap reaches
1064
1080
  // `NOT_FOUND_THRESHOLD` consecutive 404s the safety net trips and the
@@ -1255,6 +1271,10 @@ var SwapManager = class _SwapManager {
1255
1271
  clearTimeout(timer);
1256
1272
  }
1257
1273
  this.pollRetryTimers.clear();
1274
+ for (const timer of this.refundRetryTimers.values()) {
1275
+ clearTimeout(timer);
1276
+ }
1277
+ this.refundRetryTimers.clear();
1258
1278
  this.notFoundCounts.clear();
1259
1279
  }
1260
1280
  /**
@@ -1311,6 +1331,11 @@ var SwapManager = class _SwapManager {
1311
1331
  clearTimeout(retryTimer);
1312
1332
  this.pollRetryTimers.delete(swapId);
1313
1333
  }
1334
+ const refundRetryTimer = this.refundRetryTimers.get(swapId);
1335
+ if (refundRetryTimer) {
1336
+ clearTimeout(refundRetryTimer);
1337
+ this.refundRetryTimers.delete(swapId);
1338
+ }
1314
1339
  this.notFoundCounts.delete(swapId);
1315
1340
  logger.log(`Removed swap ${swapId} from monitoring`);
1316
1341
  }
@@ -1582,15 +1607,57 @@ var SwapManager = class _SwapManager {
1582
1607
  await this.executeAutonomousAction(swap);
1583
1608
  }
1584
1609
  if (this.isFinalStatus(swap)) {
1585
- this.monitoredSwaps.delete(swap.id);
1586
- this.swapSubscriptions.delete(swap.id);
1587
- const retryTimer = this.pollRetryTimers.get(swap.id);
1588
- if (retryTimer) {
1589
- clearTimeout(retryTimer);
1590
- this.pollRetryTimers.delete(swap.id);
1610
+ if (this.refundRetryTimers.has(swap.id)) {
1611
+ return;
1591
1612
  }
1592
- this.swapCompletedListeners.forEach((listener) => listener(swap));
1613
+ this.finalizeMonitoredSwap(swap);
1614
+ }
1615
+ }
1616
+ /**
1617
+ * Drop a swap from monitoring and emit the terminal completion event.
1618
+ * Shared between the on-status-update finalization path and the
1619
+ * refund-retry finalization path (used when a previously-deferred
1620
+ * chain refund has finished its remaining work).
1621
+ */
1622
+ finalizeMonitoredSwap(swap) {
1623
+ if (!this.monitoredSwaps.has(swap.id)) return;
1624
+ this.monitoredSwaps.delete(swap.id);
1625
+ this.swapSubscriptions.delete(swap.id);
1626
+ const retryTimer = this.pollRetryTimers.get(swap.id);
1627
+ if (retryTimer) {
1628
+ clearTimeout(retryTimer);
1629
+ this.pollRetryTimers.delete(swap.id);
1630
+ }
1631
+ const refundRetry = this.refundRetryTimers.get(swap.id);
1632
+ if (refundRetry) {
1633
+ clearTimeout(refundRetry);
1634
+ this.refundRetryTimers.delete(swap.id);
1593
1635
  }
1636
+ this.swapCompletedListeners.forEach((listener) => listener(swap));
1637
+ }
1638
+ /**
1639
+ * Schedule another `executeAutonomousAction` run for a chain swap whose
1640
+ * refund left VTXOs deferred. After the retry completes, if no further
1641
+ * deferral was reported, finalize monitoring cleanup.
1642
+ */
1643
+ scheduleRefundRetry(swap, delayMs) {
1644
+ const existing = this.refundRetryTimers.get(swap.id);
1645
+ if (existing) clearTimeout(existing);
1646
+ this.refundRetryTimers.set(
1647
+ swap.id,
1648
+ setTimeout(async () => {
1649
+ this.refundRetryTimers.delete(swap.id);
1650
+ if (!this.isRunning) return;
1651
+ if (!this.monitoredSwaps.has(swap.id)) return;
1652
+ try {
1653
+ await this.executeAutonomousAction(swap);
1654
+ } finally {
1655
+ if (!this.refundRetryTimers.has(swap.id) && this.isFinalStatus(swap)) {
1656
+ this.finalizeMonitoredSwap(swap);
1657
+ }
1658
+ }
1659
+ }, delayMs)
1660
+ );
1594
1661
  }
1595
1662
  /**
1596
1663
  * Execute autonomous action based on swap status
@@ -1645,10 +1712,27 @@ var SwapManager = class _SwapManager {
1645
1712
  } else if (isChainRefundableStatus(swap.status)) {
1646
1713
  if (swap.request.from === "ARK") {
1647
1714
  logger.log(`Auto-refunding ARK chain swap ${swap.id}`);
1648
- await this.executeRefundArkAction(swap);
1649
- this.actionExecutedListeners.forEach(
1650
- (listener) => listener(swap, "refundArk")
1651
- );
1715
+ try {
1716
+ const outcome = await this.executeRefundArkAction(swap);
1717
+ if (outcome && outcome.skipped > 0) {
1718
+ logger.log(
1719
+ `Chain swap ${swap.id}: ${outcome.skipped} VTXO(s) deferred \u2014 scheduling refund retry`
1720
+ );
1721
+ this.scheduleRefundRetry(swap, _SwapManager.REFUND_RETRY_DELAY_MS);
1722
+ }
1723
+ this.actionExecutedListeners.forEach(
1724
+ (listener) => listener(swap, "refundArk")
1725
+ );
1726
+ } catch (error) {
1727
+ logger.error(
1728
+ `Auto-refunding ARK chain swap ${swap.id} failed; scheduling retry`,
1729
+ error
1730
+ );
1731
+ this.swapFailedListeners.forEach(
1732
+ (listener) => listener(swap, error)
1733
+ );
1734
+ this.scheduleRefundRetry(swap, _SwapManager.REFUND_RETRY_DELAY_MS);
1735
+ }
1652
1736
  }
1653
1737
  if (swap.request.from === "BTC") {
1654
1738
  logger.warn(
@@ -1729,7 +1813,7 @@ var SwapManager = class _SwapManager {
1729
1813
  logger.error("refundArk callback not set");
1730
1814
  return;
1731
1815
  }
1732
- await this.refundArkCallback(swap);
1816
+ return this.refundArkCallback(swap);
1733
1817
  }
1734
1818
  /**
1735
1819
  * Execute sign server claim action for chain swap.
@@ -1829,9 +1913,7 @@ var SwapManager = class _SwapManager {
1829
1913
  */
1830
1914
  async pollAllSwaps() {
1831
1915
  if (this.monitoredSwaps.size === 0) return;
1832
- const pollPromises = Array.from(this.monitoredSwaps.values()).map(
1833
- (swap) => this.pollSingleSwap(swap)
1834
- );
1916
+ const pollPromises = Array.from(this.monitoredSwaps.values()).filter((swap) => !this.refundRetryTimers.has(swap.id)).map((swap) => this.pollSingleSwap(swap));
1835
1917
  await Promise.allSettled(pollPromises);
1836
1918
  }
1837
1919
  async pollSingleSwap(swap) {
@@ -1884,6 +1966,7 @@ var SwapManager = class _SwapManager {
1884
1966
  * Boltz endpoint).
1885
1967
  */
1886
1968
  async handleSwapNotFound(swap) {
1969
+ if (this.refundRetryTimers.has(swap.id)) return;
1887
1970
  const count = (this.notFoundCounts.get(swap.id) ?? 0) + 1;
1888
1971
  this.notFoundCounts.set(swap.id, count);
1889
1972
  logger.warn(
@@ -1904,6 +1987,7 @@ var SwapManager = class _SwapManager {
1904
1987
  * 404s without recovering anything.
1905
1988
  */
1906
1989
  async markSwapAsUnknownToProvider(swap) {
1990
+ if (this.refundRetryTimers.has(swap.id)) return;
1907
1991
  if (!this.monitoredSwaps.has(swap.id)) {
1908
1992
  this.notFoundCounts.delete(swap.id);
1909
1993
  return;
@@ -1916,6 +2000,11 @@ var SwapManager = class _SwapManager {
1916
2000
  clearTimeout(retryTimer);
1917
2001
  this.pollRetryTimers.delete(swap.id);
1918
2002
  }
2003
+ const refundRetryTimer = this.refundRetryTimers.get(swap.id);
2004
+ if (refundRetryTimer) {
2005
+ clearTimeout(refundRetryTimer);
2006
+ this.refundRetryTimers.delete(swap.id);
2007
+ }
1919
2008
  this.notFoundCounts.delete(swap.id);
1920
2009
  this.swapUpdateListeners.forEach((listener) => listener(swap, oldStatus));
1921
2010
  const subscribers = this.swapSubscriptions.get(swap.id);
@@ -3065,7 +3154,7 @@ var ArkadeSwaps = class _ArkadeSwaps {
3065
3154
  await this.claimBtc(swap);
3066
3155
  },
3067
3156
  refundArk: async (swap) => {
3068
- await this.refundArk(swap);
3157
+ return this.refundArk(swap);
3069
3158
  },
3070
3159
  signServerClaim: async (swap) => {
3071
3160
  await this.signCooperativeClaimForServer(swap);
@@ -3278,51 +3367,67 @@ var ArkadeSwaps = class _ArkadeSwaps {
3278
3367
  throw new Error(
3279
3368
  `Swap ${pendingSwap.id}: VHTLC address mismatch. Expected ${lockupAddress}, got ${vhtlcAddress}`
3280
3369
  );
3281
- let vtxo;
3370
+ let unspentVtxos = [];
3371
+ let rawVtxos = [];
3282
3372
  for (let attempt = 1; attempt <= CLAIM_VTXO_RETRY_ATTEMPTS; attempt++) {
3283
- const { vtxos } = await this.indexerProvider.getVtxos({
3373
+ const result = await this.indexerProvider.getVtxos({
3284
3374
  scripts: [hex8.encode(vhtlcScript.pkScript)]
3285
3375
  });
3286
- if (vtxos.length > 0) {
3287
- vtxo = vtxos[0];
3376
+ rawVtxos = result.vtxos;
3377
+ unspentVtxos = result.vtxos.filter((vtxo) => !vtxo.isSpent);
3378
+ if (unspentVtxos.length > 0) {
3288
3379
  break;
3289
3380
  }
3290
3381
  if (attempt < CLAIM_VTXO_RETRY_ATTEMPTS) {
3291
3382
  await new Promise((resolve) => setTimeout(resolve, CLAIM_VTXO_RETRY_DELAY_MS));
3292
3383
  }
3293
3384
  }
3294
- if (!vtxo) {
3295
- throw new Error(`Swap ${pendingSwap.id}: no spendable virtual coins found`);
3296
- }
3297
- if (vtxo.isSpent) {
3385
+ if (unspentVtxos.length === 0) {
3386
+ if (rawVtxos.length === 0) {
3387
+ throw new Error(`Swap ${pendingSwap.id}: no spendable virtual coins found`);
3388
+ }
3298
3389
  throw new Error(`Swap ${pendingSwap.id}: VHTLC is already spent`);
3299
3390
  }
3300
- const input = {
3301
- ...vtxo,
3302
- tapLeafScript: vhtlcScript.claim(),
3303
- tapTree: vhtlcScript.encode()
3304
- };
3305
- const output = {
3306
- amount: BigInt(vtxo.value),
3307
- script: ArkAddress2.decode(address).pkScript
3308
- };
3309
3391
  const vhtlcIdentity = claimVHTLCIdentity(this.wallet.identity, preimage);
3310
- let finalStatus;
3311
- if (isRecoverable(vtxo)) {
3312
- await this.joinBatch(vhtlcIdentity, input, output, arkInfo);
3313
- finalStatus = "transaction.claimed";
3314
- } else {
3315
- await claimVHTLCwithOffchainTx(
3316
- vhtlcIdentity,
3317
- vhtlcScript,
3318
- serverXOnly,
3319
- input,
3320
- output,
3321
- arkInfo,
3322
- this.arkProvider
3392
+ const outputScript = ArkAddress2.decode(address).pkScript;
3393
+ const claimErrors = [];
3394
+ let usedOffchainClaim = false;
3395
+ for (const vtxo of unspentVtxos) {
3396
+ const input = {
3397
+ ...vtxo,
3398
+ tapLeafScript: vhtlcScript.claim(),
3399
+ tapTree: vhtlcScript.encode()
3400
+ };
3401
+ const output = {
3402
+ amount: BigInt(vtxo.value),
3403
+ script: outputScript
3404
+ };
3405
+ try {
3406
+ if (isRecoverable(vtxo)) {
3407
+ await this.joinBatch(vhtlcIdentity, input, output, arkInfo);
3408
+ } else {
3409
+ await claimVHTLCwithOffchainTx(
3410
+ vhtlcIdentity,
3411
+ vhtlcScript,
3412
+ serverXOnly,
3413
+ input,
3414
+ output,
3415
+ arkInfo,
3416
+ this.arkProvider
3417
+ );
3418
+ usedOffchainClaim = true;
3419
+ }
3420
+ } catch (error) {
3421
+ claimErrors.push({ vtxo, error });
3422
+ }
3423
+ }
3424
+ if (claimErrors.length > 0) {
3425
+ const details = claimErrors.map(({ vtxo, error }) => `${vtxo.txid}:${vtxo.vout} (${error.message})`).join("; ");
3426
+ throw new Error(
3427
+ `Swap ${pendingSwap.id}: failed to claim ${claimErrors.length}/${unspentVtxos.length} VTXOs: ${details}`
3323
3428
  );
3324
- finalStatus = (await this.getSwapStatus(pendingSwap.id)).status;
3325
3429
  }
3430
+ const finalStatus = usedOffchainClaim ? (await this.getSwapStatus(pendingSwap.id)).status : "transaction.claimed";
3326
3431
  await updateReverseSwapStatus(
3327
3432
  pendingSwap,
3328
3433
  finalStatus,
@@ -3697,6 +3802,7 @@ var ArkadeSwaps = class _ArkadeSwaps {
3697
3802
  if (boltzCallCount > 0) {
3698
3803
  await new Promise((r) => setTimeout(r, 2e3));
3699
3804
  }
3805
+ boltzCallCount++;
3700
3806
  await refundVHTLCwithOffchainTx(
3701
3807
  pendingSwap.id,
3702
3808
  this.wallet.identity,
@@ -3709,7 +3815,6 @@ var ArkadeSwaps = class _ArkadeSwaps {
3709
3815
  arkInfo,
3710
3816
  this.swapProvider.refundSubmarineSwap.bind(this.swapProvider)
3711
3817
  );
3712
- boltzCallCount++;
3713
3818
  sweptCount++;
3714
3819
  } catch (error) {
3715
3820
  if (!(error instanceof BoltzRefundError)) {
@@ -4207,8 +4312,17 @@ var ArkadeSwaps = class _ArkadeSwaps {
4207
4312
  await this.swapProvider.postBtcTransaction(claimTx.hex);
4208
4313
  }
4209
4314
  /**
4210
- * When an ARK to BTC swap fails, refund sats on ARK chain by claiming the VHTLC.
4315
+ * When an ARK to BTC swap fails, refund every unspent VTXO at the chain
4316
+ * swap's ARK lockup address.
4317
+ *
4318
+ * Path selection per VTXO:
4319
+ * - CLTV has elapsed → `refundWithoutReceiver` via `joinBatch` (no Boltz).
4320
+ * - Pre-CLTV recoverable → skipped (Boltz can't co-sign swept-batch refund).
4321
+ * - Pre-CLTV non-recoverable → cooperative 3-of-3 refund via Boltz.
4322
+ *
4211
4323
  * @param pendingSwap - The pending chain swap to refund.
4324
+ * @returns Counts of VTXOs swept vs. deferred. A `swept: 0` outcome means
4325
+ * the call was a no-op — callers should retry after CLTV.
4212
4326
  */
4213
4327
  async refundArk(pendingSwap) {
4214
4328
  if (!pendingSwap.response.lockupDetails.serverPublicKey)
@@ -4232,21 +4346,6 @@ var ArkadeSwaps = class _ArkadeSwaps {
4232
4346
  "boltz",
4233
4347
  pendingSwap.id
4234
4348
  );
4235
- const vhtlcPkScript = ArkAddress2.decode(
4236
- pendingSwap.response.lockupDetails.lockupAddress
4237
- ).pkScript;
4238
- const { vtxos } = await this.indexerProvider.getVtxos({
4239
- scripts: [hex8.encode(vhtlcPkScript)]
4240
- });
4241
- if (vtxos.length === 0) {
4242
- throw new Error(
4243
- `Swap ${pendingSwap.id}: VHTLC not found for address ${pendingSwap.response.lockupDetails.lockupAddress}`
4244
- );
4245
- }
4246
- const vtxo = vtxos[0];
4247
- if (vtxo.isSpent) {
4248
- throw new Error(`Swap ${pendingSwap.id}: VHTLC is already spent`);
4249
- }
4250
4349
  const { vhtlcAddress, vhtlcScript } = this.createVHTLCScript({
4251
4350
  network: arkInfo.network,
4252
4351
  preimageHash: hex8.decode(pendingSwap.request.preimageHash),
@@ -4262,37 +4361,105 @@ var ArkadeSwaps = class _ArkadeSwaps {
4262
4361
  message: "Unable to claim: invalid VHTLC address"
4263
4362
  });
4264
4363
  }
4265
- const isRecoverableVtxo = isRecoverable(vtxo);
4266
- const input = {
4267
- ...vtxo,
4268
- tapLeafScript: isRecoverableVtxo ? vhtlcScript.refundWithoutReceiver() : vhtlcScript.refund(),
4269
- tapTree: vhtlcScript.encode()
4270
- };
4271
- const output = {
4272
- amount: BigInt(vtxo.value),
4273
- script: ArkAddress2.decode(address).pkScript
4274
- };
4275
- if (isRecoverableVtxo) {
4276
- await this.joinBatch(this.wallet.identity, input, output, arkInfo);
4277
- } else {
4278
- await refundVHTLCwithOffchainTx(
4279
- pendingSwap.id,
4280
- this.wallet.identity,
4281
- this.arkProvider,
4282
- boltzXOnlyPublicKey,
4283
- ourXOnlyPublicKey,
4284
- serverXOnlyPublicKey,
4285
- input,
4286
- output,
4287
- arkInfo,
4288
- this.swapProvider.refundChainSwap.bind(this.swapProvider)
4364
+ const { vtxos } = await this.indexerProvider.getVtxos({
4365
+ scripts: [hex8.encode(vhtlcScript.pkScript)]
4366
+ });
4367
+ if (vtxos.length === 0) {
4368
+ throw new Error(
4369
+ `Swap ${pendingSwap.id}: VHTLC not found for address ${pendingSwap.response.lockupDetails.lockupAddress}`
4289
4370
  );
4290
4371
  }
4372
+ const unspentVtxos = vtxos.filter((vtxo) => !vtxo.isSpent);
4373
+ if (unspentVtxos.length === 0) {
4374
+ throw new Error(`Swap ${pendingSwap.id}: VHTLC is already spent`);
4375
+ }
4376
+ const outputScript = ArkAddress2.decode(address).pkScript;
4377
+ const refundWithoutReceiverLeaf = vhtlcScript.refundWithoutReceiver();
4378
+ const refundLocktime = pendingSwap.response.lockupDetails.timeouts.refund;
4379
+ let boltzCallCount = 0;
4380
+ let sweptCount = 0;
4381
+ let skippedCount = 0;
4382
+ for (const vtxo of unspentVtxos) {
4383
+ const isRecoverableVtxo = isRecoverable(vtxo);
4384
+ const output = {
4385
+ amount: BigInt(vtxo.value),
4386
+ script: outputScript
4387
+ };
4388
+ if (isSubmarineRefundLocktimeReached(refundLocktime)) {
4389
+ const input2 = {
4390
+ ...vtxo,
4391
+ tapLeafScript: refundWithoutReceiverLeaf,
4392
+ tapTree: vhtlcScript.encode()
4393
+ };
4394
+ await this.joinBatch(
4395
+ this.wallet.identity,
4396
+ input2,
4397
+ output,
4398
+ arkInfo,
4399
+ isRecoverableVtxo
4400
+ );
4401
+ sweptCount++;
4402
+ continue;
4403
+ }
4404
+ if (isRecoverableVtxo) {
4405
+ logger.error(
4406
+ `Swap ${pendingSwap.id}: recoverable VTXO ${vtxo.txid}:${vtxo.vout} cannot be refunded yet \u2014 refundWithoutReceiver locktime has not passed (refundLocktime=${refundLocktime}, currentTimestamp=${Math.floor(Date.now() / 1e3)}). Refund will be retried after locktime.`
4407
+ );
4408
+ skippedCount++;
4409
+ continue;
4410
+ }
4411
+ const input = {
4412
+ ...vtxo,
4413
+ tapLeafScript: vhtlcScript.refund(),
4414
+ tapTree: vhtlcScript.encode()
4415
+ };
4416
+ try {
4417
+ if (boltzCallCount > 0) {
4418
+ await new Promise((r) => setTimeout(r, 2e3));
4419
+ }
4420
+ boltzCallCount++;
4421
+ await refundVHTLCwithOffchainTx(
4422
+ pendingSwap.id,
4423
+ this.wallet.identity,
4424
+ this.arkProvider,
4425
+ boltzXOnlyPublicKey,
4426
+ ourXOnlyPublicKey,
4427
+ serverXOnlyPublicKey,
4428
+ input,
4429
+ output,
4430
+ arkInfo,
4431
+ this.swapProvider.refundChainSwap.bind(this.swapProvider)
4432
+ );
4433
+ sweptCount++;
4434
+ } catch (error) {
4435
+ if (!(error instanceof BoltzRefundError)) {
4436
+ throw error;
4437
+ }
4438
+ if (!isSubmarineRefundLocktimeReached(refundLocktime)) {
4439
+ logger.error(
4440
+ `Swap ${pendingSwap.id}: Boltz rejected VTXO outpoint and refundWithoutReceiver locktime has not passed yet (currentTimestamp=${Math.floor(Date.now() / 1e3)}, locktime=${refundLocktime}). Refund will be retried after locktime.`
4441
+ );
4442
+ skippedCount++;
4443
+ continue;
4444
+ }
4445
+ logger.warn(
4446
+ `Swap ${pendingSwap.id}: Boltz rejected VTXO outpoint, falling back to refundWithoutReceiver via joinBatch`
4447
+ );
4448
+ const fallbackInput = {
4449
+ ...vtxo,
4450
+ tapLeafScript: refundWithoutReceiverLeaf,
4451
+ tapTree: vhtlcScript.encode()
4452
+ };
4453
+ await this.joinBatch(this.wallet.identity, fallbackInput, output, arkInfo, false);
4454
+ sweptCount++;
4455
+ }
4456
+ }
4291
4457
  const finalStatus = await this.getSwapStatus(pendingSwap.id);
4292
4458
  await this.savePendingChainSwap({
4293
4459
  ...pendingSwap,
4294
4460
  status: finalStatus.status
4295
4461
  });
4462
+ return { swept: sweptCount, skipped: skippedCount };
4296
4463
  }
4297
4464
  // =========================================================================
4298
4465
  // Chain swaps: BTC -> ARK
@@ -4713,7 +4880,13 @@ var ArkadeSwaps = class _ArkadeSwaps {
4713
4880
  this.validateQuoteOptions(options);
4714
4881
  const floor = await this.resolveQuoteFloor(swapId, options);
4715
4882
  const slippageBps = options?.maxSlippageBps ?? 0;
4716
- return Math.floor(floor - floor * slippageBps / 1e4);
4883
+ const effectiveFloor = Math.floor(floor - floor * slippageBps / 1e4);
4884
+ if (effectiveFloor < 1) {
4885
+ throw new TypeError(
4886
+ `Invalid quote configuration: maxSlippageBps=${slippageBps} reduces floor ${floor} below 1 sat`
4887
+ );
4888
+ }
4889
+ return effectiveFloor;
4717
4890
  }
4718
4891
  async resolveQuoteFloor(swapId, options) {
4719
4892
  if (options?.minAcceptableAmount !== void 0) {
@@ -4749,7 +4922,13 @@ var ArkadeSwaps = class _ArkadeSwaps {
4749
4922
  }
4750
4923
  }
4751
4924
  validateQuote(amount, effectiveFloor) {
4752
- if (!(amount > 0)) {
4925
+ if (!Number.isSafeInteger(amount)) {
4926
+ throw new QuoteRejectedError({
4927
+ reason: "non_safe_integer",
4928
+ quotedAmount: amount
4929
+ });
4930
+ }
4931
+ if (amount <= 0) {
4753
4932
  throw new QuoteRejectedError({
4754
4933
  reason: "non_positive",
4755
4934
  quotedAmount: amount