@arkade-os/boltz-swap 0.3.33 → 0.3.35

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.
@@ -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);
@@ -2581,6 +2670,20 @@ function extractInvoiceAmount(amountSats, fees) {
2581
2670
  if (miner >= amountSats) return 0;
2582
2671
  return Math.ceil((amountSats - miner) / (1 - percentage / 100));
2583
2672
  }
2673
+ function resolveVhtlcTimeouts(tree, timeoutBlockHeights) {
2674
+ const resolved = timeoutBlockHeights ?? {
2675
+ refund: extractTimeLockFromLeafOutput(tree.refundWithoutBoltzLeaf?.output ?? ""),
2676
+ unilateralClaim: extractTimeLockFromLeafOutput(tree.unilateralClaimLeaf?.output ?? ""),
2677
+ unilateralRefund: extractTimeLockFromLeafOutput(tree.unilateralRefundLeaf?.output ?? ""),
2678
+ unilateralRefundWithoutReceiver: extractTimeLockFromLeafOutput(
2679
+ tree.unilateralRefundWithoutBoltzLeaf?.output ?? ""
2680
+ )
2681
+ };
2682
+ if (!resolved.refund || !resolved.unilateralClaim || !resolved.unilateralRefund || !resolved.unilateralRefundWithoutReceiver) {
2683
+ return void 0;
2684
+ }
2685
+ return resolved;
2686
+ }
2584
2687
 
2585
2688
  // src/utils/identity.ts
2586
2689
  import { ConditionWitness, setArkPsbtField, Transaction as Transaction3 } from "@arkade-os/sdk";
@@ -3065,7 +3168,7 @@ var ArkadeSwaps = class _ArkadeSwaps {
3065
3168
  await this.claimBtc(swap);
3066
3169
  },
3067
3170
  refundArk: async (swap) => {
3068
- await this.refundArk(swap);
3171
+ return this.refundArk(swap);
3069
3172
  },
3070
3173
  signServerClaim: async (swap) => {
3071
3174
  await this.signCooperativeClaimForServer(swap);
@@ -3278,51 +3381,67 @@ var ArkadeSwaps = class _ArkadeSwaps {
3278
3381
  throw new Error(
3279
3382
  `Swap ${pendingSwap.id}: VHTLC address mismatch. Expected ${lockupAddress}, got ${vhtlcAddress}`
3280
3383
  );
3281
- let vtxo;
3384
+ let unspentVtxos = [];
3385
+ let rawVtxos = [];
3282
3386
  for (let attempt = 1; attempt <= CLAIM_VTXO_RETRY_ATTEMPTS; attempt++) {
3283
- const { vtxos } = await this.indexerProvider.getVtxos({
3387
+ const result = await this.indexerProvider.getVtxos({
3284
3388
  scripts: [hex8.encode(vhtlcScript.pkScript)]
3285
3389
  });
3286
- if (vtxos.length > 0) {
3287
- vtxo = vtxos[0];
3390
+ rawVtxos = result.vtxos;
3391
+ unspentVtxos = result.vtxos.filter((vtxo) => !vtxo.isSpent);
3392
+ if (unspentVtxos.length > 0) {
3288
3393
  break;
3289
3394
  }
3290
3395
  if (attempt < CLAIM_VTXO_RETRY_ATTEMPTS) {
3291
3396
  await new Promise((resolve) => setTimeout(resolve, CLAIM_VTXO_RETRY_DELAY_MS));
3292
3397
  }
3293
3398
  }
3294
- if (!vtxo) {
3295
- throw new Error(`Swap ${pendingSwap.id}: no spendable virtual coins found`);
3296
- }
3297
- if (vtxo.isSpent) {
3399
+ if (unspentVtxos.length === 0) {
3400
+ if (rawVtxos.length === 0) {
3401
+ throw new Error(`Swap ${pendingSwap.id}: no spendable virtual coins found`);
3402
+ }
3298
3403
  throw new Error(`Swap ${pendingSwap.id}: VHTLC is already spent`);
3299
3404
  }
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
3405
  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
3406
+ const outputScript = ArkAddress2.decode(address).pkScript;
3407
+ const claimErrors = [];
3408
+ let usedOffchainClaim = false;
3409
+ for (const vtxo of unspentVtxos) {
3410
+ const input = {
3411
+ ...vtxo,
3412
+ tapLeafScript: vhtlcScript.claim(),
3413
+ tapTree: vhtlcScript.encode()
3414
+ };
3415
+ const output = {
3416
+ amount: BigInt(vtxo.value),
3417
+ script: outputScript
3418
+ };
3419
+ try {
3420
+ if (isRecoverable(vtxo)) {
3421
+ await this.joinBatch(vhtlcIdentity, input, output, arkInfo);
3422
+ } else {
3423
+ await claimVHTLCwithOffchainTx(
3424
+ vhtlcIdentity,
3425
+ vhtlcScript,
3426
+ serverXOnly,
3427
+ input,
3428
+ output,
3429
+ arkInfo,
3430
+ this.arkProvider
3431
+ );
3432
+ usedOffchainClaim = true;
3433
+ }
3434
+ } catch (error) {
3435
+ claimErrors.push({ vtxo, error });
3436
+ }
3437
+ }
3438
+ if (claimErrors.length > 0) {
3439
+ const details = claimErrors.map(({ vtxo, error }) => `${vtxo.txid}:${vtxo.vout} (${error.message})`).join("; ");
3440
+ throw new Error(
3441
+ `Swap ${pendingSwap.id}: failed to claim ${claimErrors.length}/${unspentVtxos.length} VTXOs: ${details}`
3323
3442
  );
3324
- finalStatus = (await this.getSwapStatus(pendingSwap.id)).status;
3325
3443
  }
3444
+ const finalStatus = usedOffchainClaim ? (await this.getSwapStatus(pendingSwap.id)).status : "transaction.claimed";
3326
3445
  await updateReverseSwapStatus(
3327
3446
  pendingSwap,
3328
3447
  finalStatus,
@@ -3697,6 +3816,7 @@ var ArkadeSwaps = class _ArkadeSwaps {
3697
3816
  if (boltzCallCount > 0) {
3698
3817
  await new Promise((r) => setTimeout(r, 2e3));
3699
3818
  }
3819
+ boltzCallCount++;
3700
3820
  await refundVHTLCwithOffchainTx(
3701
3821
  pendingSwap.id,
3702
3822
  this.wallet.identity,
@@ -3709,7 +3829,6 @@ var ArkadeSwaps = class _ArkadeSwaps {
3709
3829
  arkInfo,
3710
3830
  this.swapProvider.refundSubmarineSwap.bind(this.swapProvider)
3711
3831
  );
3712
- boltzCallCount++;
3713
3832
  sweptCount++;
3714
3833
  } catch (error) {
3715
3834
  if (!(error instanceof BoltzRefundError)) {
@@ -4207,8 +4326,17 @@ var ArkadeSwaps = class _ArkadeSwaps {
4207
4326
  await this.swapProvider.postBtcTransaction(claimTx.hex);
4208
4327
  }
4209
4328
  /**
4210
- * When an ARK to BTC swap fails, refund sats on ARK chain by claiming the VHTLC.
4329
+ * When an ARK to BTC swap fails, refund every unspent VTXO at the chain
4330
+ * swap's ARK lockup address.
4331
+ *
4332
+ * Path selection per VTXO:
4333
+ * - CLTV has elapsed → `refundWithoutReceiver` via `joinBatch` (no Boltz).
4334
+ * - Pre-CLTV recoverable → skipped (Boltz can't co-sign swept-batch refund).
4335
+ * - Pre-CLTV non-recoverable → cooperative 3-of-3 refund via Boltz.
4336
+ *
4211
4337
  * @param pendingSwap - The pending chain swap to refund.
4338
+ * @returns Counts of VTXOs swept vs. deferred. A `swept: 0` outcome means
4339
+ * the call was a no-op — callers should retry after CLTV.
4212
4340
  */
4213
4341
  async refundArk(pendingSwap) {
4214
4342
  if (!pendingSwap.response.lockupDetails.serverPublicKey)
@@ -4232,21 +4360,6 @@ var ArkadeSwaps = class _ArkadeSwaps {
4232
4360
  "boltz",
4233
4361
  pendingSwap.id
4234
4362
  );
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
4363
  const { vhtlcAddress, vhtlcScript } = this.createVHTLCScript({
4251
4364
  network: arkInfo.network,
4252
4365
  preimageHash: hex8.decode(pendingSwap.request.preimageHash),
@@ -4262,37 +4375,105 @@ var ArkadeSwaps = class _ArkadeSwaps {
4262
4375
  message: "Unable to claim: invalid VHTLC address"
4263
4376
  });
4264
4377
  }
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)
4378
+ const { vtxos } = await this.indexerProvider.getVtxos({
4379
+ scripts: [hex8.encode(vhtlcScript.pkScript)]
4380
+ });
4381
+ if (vtxos.length === 0) {
4382
+ throw new Error(
4383
+ `Swap ${pendingSwap.id}: VHTLC not found for address ${pendingSwap.response.lockupDetails.lockupAddress}`
4289
4384
  );
4290
4385
  }
4386
+ const unspentVtxos = vtxos.filter((vtxo) => !vtxo.isSpent);
4387
+ if (unspentVtxos.length === 0) {
4388
+ throw new Error(`Swap ${pendingSwap.id}: VHTLC is already spent`);
4389
+ }
4390
+ const outputScript = ArkAddress2.decode(address).pkScript;
4391
+ const refundWithoutReceiverLeaf = vhtlcScript.refundWithoutReceiver();
4392
+ const refundLocktime = pendingSwap.response.lockupDetails.timeouts.refund;
4393
+ let boltzCallCount = 0;
4394
+ let sweptCount = 0;
4395
+ let skippedCount = 0;
4396
+ for (const vtxo of unspentVtxos) {
4397
+ const isRecoverableVtxo = isRecoverable(vtxo);
4398
+ const output = {
4399
+ amount: BigInt(vtxo.value),
4400
+ script: outputScript
4401
+ };
4402
+ if (isSubmarineRefundLocktimeReached(refundLocktime)) {
4403
+ const input2 = {
4404
+ ...vtxo,
4405
+ tapLeafScript: refundWithoutReceiverLeaf,
4406
+ tapTree: vhtlcScript.encode()
4407
+ };
4408
+ await this.joinBatch(
4409
+ this.wallet.identity,
4410
+ input2,
4411
+ output,
4412
+ arkInfo,
4413
+ isRecoverableVtxo
4414
+ );
4415
+ sweptCount++;
4416
+ continue;
4417
+ }
4418
+ if (isRecoverableVtxo) {
4419
+ logger.error(
4420
+ `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.`
4421
+ );
4422
+ skippedCount++;
4423
+ continue;
4424
+ }
4425
+ const input = {
4426
+ ...vtxo,
4427
+ tapLeafScript: vhtlcScript.refund(),
4428
+ tapTree: vhtlcScript.encode()
4429
+ };
4430
+ try {
4431
+ if (boltzCallCount > 0) {
4432
+ await new Promise((r) => setTimeout(r, 2e3));
4433
+ }
4434
+ boltzCallCount++;
4435
+ await refundVHTLCwithOffchainTx(
4436
+ pendingSwap.id,
4437
+ this.wallet.identity,
4438
+ this.arkProvider,
4439
+ boltzXOnlyPublicKey,
4440
+ ourXOnlyPublicKey,
4441
+ serverXOnlyPublicKey,
4442
+ input,
4443
+ output,
4444
+ arkInfo,
4445
+ this.swapProvider.refundChainSwap.bind(this.swapProvider)
4446
+ );
4447
+ sweptCount++;
4448
+ } catch (error) {
4449
+ if (!(error instanceof BoltzRefundError)) {
4450
+ throw error;
4451
+ }
4452
+ if (!isSubmarineRefundLocktimeReached(refundLocktime)) {
4453
+ logger.error(
4454
+ `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.`
4455
+ );
4456
+ skippedCount++;
4457
+ continue;
4458
+ }
4459
+ logger.warn(
4460
+ `Swap ${pendingSwap.id}: Boltz rejected VTXO outpoint, falling back to refundWithoutReceiver via joinBatch`
4461
+ );
4462
+ const fallbackInput = {
4463
+ ...vtxo,
4464
+ tapLeafScript: refundWithoutReceiverLeaf,
4465
+ tapTree: vhtlcScript.encode()
4466
+ };
4467
+ await this.joinBatch(this.wallet.identity, fallbackInput, output, arkInfo, false);
4468
+ sweptCount++;
4469
+ }
4470
+ }
4291
4471
  const finalStatus = await this.getSwapStatus(pendingSwap.id);
4292
4472
  await this.savePendingChainSwap({
4293
4473
  ...pendingSwap,
4294
4474
  status: finalStatus.status
4295
4475
  });
4476
+ return { swept: sweptCount, skipped: skippedCount };
4296
4477
  }
4297
4478
  // =========================================================================
4298
4479
  // Chain swaps: BTC -> ARK
@@ -4713,7 +4894,13 @@ var ArkadeSwaps = class _ArkadeSwaps {
4713
4894
  this.validateQuoteOptions(options);
4714
4895
  const floor = await this.resolveQuoteFloor(swapId, options);
4715
4896
  const slippageBps = options?.maxSlippageBps ?? 0;
4716
- return Math.floor(floor - floor * slippageBps / 1e4);
4897
+ const effectiveFloor = Math.floor(floor - floor * slippageBps / 1e4);
4898
+ if (effectiveFloor < 1) {
4899
+ throw new TypeError(
4900
+ `Invalid quote configuration: maxSlippageBps=${slippageBps} reduces floor ${floor} below 1 sat`
4901
+ );
4902
+ }
4903
+ return effectiveFloor;
4717
4904
  }
4718
4905
  async resolveQuoteFloor(swapId, options) {
4719
4906
  if (options?.minAcceptableAmount !== void 0) {
@@ -4749,7 +4936,13 @@ var ArkadeSwaps = class _ArkadeSwaps {
4749
4936
  }
4750
4937
  }
4751
4938
  validateQuote(amount, effectiveFloor) {
4752
- if (!(amount > 0)) {
4939
+ if (!Number.isSafeInteger(amount)) {
4940
+ throw new QuoteRejectedError({
4941
+ reason: "non_safe_integer",
4942
+ quotedAmount: amount
4943
+ });
4944
+ }
4945
+ if (amount <= 0) {
4753
4946
  throw new QuoteRejectedError({
4754
4947
  reason: "non_positive",
4755
4948
  quotedAmount: amount
@@ -4932,20 +5125,7 @@ var ArkadeSwaps = class _ArkadeSwaps {
4932
5125
  onchainAmount: amount,
4933
5126
  lockupAddress,
4934
5127
  refundPublicKey: serverPublicKey,
4935
- timeoutBlockHeights: timeoutBlockHeights ?? {
4936
- refund: extractTimeLockFromLeafOutput(
4937
- tree.refundWithoutBoltzLeaf?.output ?? ""
4938
- ),
4939
- unilateralClaim: extractTimeLockFromLeafOutput(
4940
- tree.unilateralClaimLeaf?.output ?? ""
4941
- ),
4942
- unilateralRefund: extractTimeLockFromLeafOutput(
4943
- tree.unilateralRefundLeaf?.output ?? ""
4944
- ),
4945
- unilateralRefundWithoutReceiver: extractTimeLockFromLeafOutput(
4946
- tree.unilateralRefundWithoutBoltzLeaf?.output ?? ""
4947
- )
4948
- }
5128
+ timeoutBlockHeights: resolveVhtlcTimeouts(tree, timeoutBlockHeights)
4949
5129
  },
4950
5130
  status,
4951
5131
  type: "reverse",
@@ -4978,26 +5158,20 @@ var ArkadeSwaps = class _ArkadeSwaps {
4978
5158
  address: lockupAddress,
4979
5159
  expectedAmount: amount,
4980
5160
  claimPublicKey: serverPublicKey,
4981
- timeoutBlockHeights: timeoutBlockHeights ?? {
4982
- refund: extractTimeLockFromLeafOutput(
4983
- tree.refundWithoutBoltzLeaf?.output ?? ""
4984
- ),
4985
- unilateralClaim: extractTimeLockFromLeafOutput(
4986
- tree.unilateralClaimLeaf?.output ?? ""
4987
- ),
4988
- unilateralRefund: extractTimeLockFromLeafOutput(
4989
- tree.unilateralRefundLeaf?.output ?? ""
4990
- ),
4991
- unilateralRefundWithoutReceiver: extractTimeLockFromLeafOutput(
4992
- tree.unilateralRefundWithoutBoltzLeaf?.output ?? ""
4993
- )
4994
- }
5161
+ timeoutBlockHeights: resolveVhtlcTimeouts(tree, timeoutBlockHeights)
4995
5162
  }
4996
5163
  });
4997
5164
  } else if (isRestoredChainSwap(swap)) {
4998
5165
  const refundDetails = swap.refundDetails;
4999
5166
  if (!refundDetails) continue;
5000
- const { amount, lockupAddress, serverPublicKey, timeoutBlockHeight } = refundDetails;
5167
+ const {
5168
+ amount,
5169
+ lockupAddress,
5170
+ serverPublicKey,
5171
+ timeoutBlockHeight,
5172
+ tree,
5173
+ timeoutBlockHeights
5174
+ } = refundDetails;
5001
5175
  chainSwaps.push({
5002
5176
  id,
5003
5177
  type: "chain",
@@ -5023,7 +5197,8 @@ var ArkadeSwaps = class _ArkadeSwaps {
5023
5197
  amount,
5024
5198
  lockupAddress,
5025
5199
  serverPublicKey,
5026
- timeoutBlockHeight
5200
+ timeoutBlockHeight,
5201
+ timeouts: resolveVhtlcTimeouts(tree, timeoutBlockHeights)
5027
5202
  }
5028
5203
  }
5029
5204
  });
@@ -7,7 +7,7 @@ import {
7
7
  isSubmarineFinalStatus,
8
8
  isSubmarineSwapRefundable,
9
9
  logger
10
- } from "./chunk-SJ5SYSMK.js";
10
+ } from "./chunk-B4CYBKFJ.js";
11
11
 
12
12
  // src/expo/swapsPollProcessor.ts
13
13
  var SWAP_POLL_TASK_TYPE = "swap-poll";