@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.
package/dist/index.cjs CHANGED
@@ -35,6 +35,7 @@ __export(index_exports, {
35
35
  ArkadeSwapsMessageHandler: () => ArkadeSwapsMessageHandler,
36
36
  BoltzRefundError: () => BoltzRefundError,
37
37
  BoltzSwapProvider: () => BoltzSwapProvider,
38
+ InMemorySwapRepository: () => InMemorySwapRepository,
38
39
  IndexedDbSwapRepository: () => IndexedDbSwapRepository,
39
40
  InsufficientFundsError: () => InsufficientFundsError,
40
41
  InvoiceExpiredError: () => InvoiceExpiredError,
@@ -215,6 +216,8 @@ var QuoteRejectedError = class _QuoteRejectedError extends SwapError {
215
216
  return `Boltz quote ${options.quotedAmount} is below acceptable floor ${options.floor}`;
216
217
  case "non_positive":
217
218
  return `Boltz quote ${options.quotedAmount} is not positive`;
219
+ case "non_safe_integer":
220
+ return `Boltz quote ${options.quotedAmount} is not a safe positive satoshi integer`;
218
221
  case "no_baseline":
219
222
  return "Cannot accept quote: no minAcceptableAmount and no stored pending swap";
220
223
  }
@@ -268,6 +271,7 @@ var QuoteRejectedError = class _QuoteRejectedError extends SwapError {
268
271
  message
269
272
  });
270
273
  case "non_positive":
274
+ case "non_safe_integer":
271
275
  if (quotedAmount === null) return null;
272
276
  return new _QuoteRejectedError({
273
277
  reason,
@@ -283,6 +287,7 @@ var QUOTE_REJECTION_TRANSPORT_PREFIX = "QUOTE_REJECTED::";
283
287
  var QUOTE_REJECTION_REASONS = /* @__PURE__ */ new Set([
284
288
  "below_floor",
285
289
  "non_positive",
290
+ "non_safe_integer",
286
291
  "no_baseline"
287
292
  ]);
288
293
  var BoltzRefundError = class extends Error {
@@ -1496,6 +1501,20 @@ function extractInvoiceAmount(amountSats, fees) {
1496
1501
  if (miner >= amountSats) return 0;
1497
1502
  return Math.ceil((amountSats - miner) / (1 - percentage / 100));
1498
1503
  }
1504
+ function resolveVhtlcTimeouts(tree, timeoutBlockHeights) {
1505
+ const resolved = timeoutBlockHeights ?? {
1506
+ refund: extractTimeLockFromLeafOutput(tree.refundWithoutBoltzLeaf?.output ?? ""),
1507
+ unilateralClaim: extractTimeLockFromLeafOutput(tree.unilateralClaimLeaf?.output ?? ""),
1508
+ unilateralRefund: extractTimeLockFromLeafOutput(tree.unilateralRefundLeaf?.output ?? ""),
1509
+ unilateralRefundWithoutReceiver: extractTimeLockFromLeafOutput(
1510
+ tree.unilateralRefundWithoutBoltzLeaf?.output ?? ""
1511
+ )
1512
+ };
1513
+ if (!resolved.refund || !resolved.unilateralClaim || !resolved.unilateralRefund || !resolved.unilateralRefundWithoutReceiver) {
1514
+ return void 0;
1515
+ }
1516
+ return resolved;
1517
+ }
1499
1518
 
1500
1519
  // src/logger.ts
1501
1520
  var logger = console;
@@ -1513,6 +1532,13 @@ var SwapManager = class _SwapManager {
1513
1532
  * enough that a real "swap unknown to this provider" surfaces quickly.
1514
1533
  */
1515
1534
  static NOT_FOUND_THRESHOLD = 10;
1535
+ /**
1536
+ * Delay between re-attempts of a chain refund that left VTXOs deferred
1537
+ * (e.g. pre-CLTV recoverable VTXO, or Boltz 3-of-3 rejected before CLTV
1538
+ * has elapsed). Boltz won't send another status update once the swap
1539
+ * is `swap.expired`, so the manager owns the local retry cadence.
1540
+ */
1541
+ static REFUND_RETRY_DELAY_MS = 6e4;
1516
1542
  swapProvider;
1517
1543
  config;
1518
1544
  // Event listeners storage (supports multiple listeners per event)
@@ -1531,6 +1557,11 @@ var SwapManager = class _SwapManager {
1531
1557
  reconnectTimer = null;
1532
1558
  initialPollTimer = null;
1533
1559
  pollRetryTimers = /* @__PURE__ */ new Map();
1560
+ // Per-swap retry timers for chain refunds that left work undone
1561
+ // (refundArk returned `skipped > 0`). The swap is held in
1562
+ // `monitoredSwaps` past its terminal Boltz status until the local
1563
+ // refund completes or the manager stops.
1564
+ refundRetryTimers = /* @__PURE__ */ new Map();
1534
1565
  // Per-swap counter of consecutive `SwapNotFoundError` responses from
1535
1566
  // `getSwapStatus`. Reset on any successful poll. Once a swap reaches
1536
1567
  // `NOT_FOUND_THRESHOLD` consecutive 404s the safety net trips and the
@@ -1727,6 +1758,10 @@ var SwapManager = class _SwapManager {
1727
1758
  clearTimeout(timer);
1728
1759
  }
1729
1760
  this.pollRetryTimers.clear();
1761
+ for (const timer of this.refundRetryTimers.values()) {
1762
+ clearTimeout(timer);
1763
+ }
1764
+ this.refundRetryTimers.clear();
1730
1765
  this.notFoundCounts.clear();
1731
1766
  }
1732
1767
  /**
@@ -1783,6 +1818,11 @@ var SwapManager = class _SwapManager {
1783
1818
  clearTimeout(retryTimer);
1784
1819
  this.pollRetryTimers.delete(swapId);
1785
1820
  }
1821
+ const refundRetryTimer = this.refundRetryTimers.get(swapId);
1822
+ if (refundRetryTimer) {
1823
+ clearTimeout(refundRetryTimer);
1824
+ this.refundRetryTimers.delete(swapId);
1825
+ }
1786
1826
  this.notFoundCounts.delete(swapId);
1787
1827
  logger.log(`Removed swap ${swapId} from monitoring`);
1788
1828
  }
@@ -2054,16 +2094,58 @@ var SwapManager = class _SwapManager {
2054
2094
  await this.executeAutonomousAction(swap);
2055
2095
  }
2056
2096
  if (this.isFinalStatus(swap)) {
2057
- this.monitoredSwaps.delete(swap.id);
2058
- this.swapSubscriptions.delete(swap.id);
2059
- const retryTimer = this.pollRetryTimers.get(swap.id);
2060
- if (retryTimer) {
2061
- clearTimeout(retryTimer);
2062
- this.pollRetryTimers.delete(swap.id);
2097
+ if (this.refundRetryTimers.has(swap.id)) {
2098
+ return;
2063
2099
  }
2064
- this.swapCompletedListeners.forEach((listener) => listener(swap));
2100
+ this.finalizeMonitoredSwap(swap);
2065
2101
  }
2066
2102
  }
2103
+ /**
2104
+ * Drop a swap from monitoring and emit the terminal completion event.
2105
+ * Shared between the on-status-update finalization path and the
2106
+ * refund-retry finalization path (used when a previously-deferred
2107
+ * chain refund has finished its remaining work).
2108
+ */
2109
+ finalizeMonitoredSwap(swap) {
2110
+ if (!this.monitoredSwaps.has(swap.id)) return;
2111
+ this.monitoredSwaps.delete(swap.id);
2112
+ this.swapSubscriptions.delete(swap.id);
2113
+ const retryTimer = this.pollRetryTimers.get(swap.id);
2114
+ if (retryTimer) {
2115
+ clearTimeout(retryTimer);
2116
+ this.pollRetryTimers.delete(swap.id);
2117
+ }
2118
+ const refundRetry = this.refundRetryTimers.get(swap.id);
2119
+ if (refundRetry) {
2120
+ clearTimeout(refundRetry);
2121
+ this.refundRetryTimers.delete(swap.id);
2122
+ }
2123
+ this.swapCompletedListeners.forEach((listener) => listener(swap));
2124
+ }
2125
+ /**
2126
+ * Schedule another `executeAutonomousAction` run for a chain swap whose
2127
+ * refund left VTXOs deferred. After the retry completes, if no further
2128
+ * deferral was reported, finalize monitoring cleanup.
2129
+ */
2130
+ scheduleRefundRetry(swap, delayMs) {
2131
+ const existing = this.refundRetryTimers.get(swap.id);
2132
+ if (existing) clearTimeout(existing);
2133
+ this.refundRetryTimers.set(
2134
+ swap.id,
2135
+ setTimeout(async () => {
2136
+ this.refundRetryTimers.delete(swap.id);
2137
+ if (!this.isRunning) return;
2138
+ if (!this.monitoredSwaps.has(swap.id)) return;
2139
+ try {
2140
+ await this.executeAutonomousAction(swap);
2141
+ } finally {
2142
+ if (!this.refundRetryTimers.has(swap.id) && this.isFinalStatus(swap)) {
2143
+ this.finalizeMonitoredSwap(swap);
2144
+ }
2145
+ }
2146
+ }, delayMs)
2147
+ );
2148
+ }
2067
2149
  /**
2068
2150
  * Execute autonomous action based on swap status
2069
2151
  * Uses locking to prevent race conditions with manual operations
@@ -2117,10 +2199,27 @@ var SwapManager = class _SwapManager {
2117
2199
  } else if (isChainRefundableStatus(swap.status)) {
2118
2200
  if (swap.request.from === "ARK") {
2119
2201
  logger.log(`Auto-refunding ARK chain swap ${swap.id}`);
2120
- await this.executeRefundArkAction(swap);
2121
- this.actionExecutedListeners.forEach(
2122
- (listener) => listener(swap, "refundArk")
2123
- );
2202
+ try {
2203
+ const outcome = await this.executeRefundArkAction(swap);
2204
+ if (outcome && outcome.skipped > 0) {
2205
+ logger.log(
2206
+ `Chain swap ${swap.id}: ${outcome.skipped} VTXO(s) deferred \u2014 scheduling refund retry`
2207
+ );
2208
+ this.scheduleRefundRetry(swap, _SwapManager.REFUND_RETRY_DELAY_MS);
2209
+ }
2210
+ this.actionExecutedListeners.forEach(
2211
+ (listener) => listener(swap, "refundArk")
2212
+ );
2213
+ } catch (error) {
2214
+ logger.error(
2215
+ `Auto-refunding ARK chain swap ${swap.id} failed; scheduling retry`,
2216
+ error
2217
+ );
2218
+ this.swapFailedListeners.forEach(
2219
+ (listener) => listener(swap, error)
2220
+ );
2221
+ this.scheduleRefundRetry(swap, _SwapManager.REFUND_RETRY_DELAY_MS);
2222
+ }
2124
2223
  }
2125
2224
  if (swap.request.from === "BTC") {
2126
2225
  logger.warn(
@@ -2201,7 +2300,7 @@ var SwapManager = class _SwapManager {
2201
2300
  logger.error("refundArk callback not set");
2202
2301
  return;
2203
2302
  }
2204
- await this.refundArkCallback(swap);
2303
+ return this.refundArkCallback(swap);
2205
2304
  }
2206
2305
  /**
2207
2306
  * Execute sign server claim action for chain swap.
@@ -2301,9 +2400,7 @@ var SwapManager = class _SwapManager {
2301
2400
  */
2302
2401
  async pollAllSwaps() {
2303
2402
  if (this.monitoredSwaps.size === 0) return;
2304
- const pollPromises = Array.from(this.monitoredSwaps.values()).map(
2305
- (swap) => this.pollSingleSwap(swap)
2306
- );
2403
+ const pollPromises = Array.from(this.monitoredSwaps.values()).filter((swap) => !this.refundRetryTimers.has(swap.id)).map((swap) => this.pollSingleSwap(swap));
2307
2404
  await Promise.allSettled(pollPromises);
2308
2405
  }
2309
2406
  async pollSingleSwap(swap) {
@@ -2356,6 +2453,7 @@ var SwapManager = class _SwapManager {
2356
2453
  * Boltz endpoint).
2357
2454
  */
2358
2455
  async handleSwapNotFound(swap) {
2456
+ if (this.refundRetryTimers.has(swap.id)) return;
2359
2457
  const count = (this.notFoundCounts.get(swap.id) ?? 0) + 1;
2360
2458
  this.notFoundCounts.set(swap.id, count);
2361
2459
  logger.warn(
@@ -2376,6 +2474,7 @@ var SwapManager = class _SwapManager {
2376
2474
  * 404s without recovering anything.
2377
2475
  */
2378
2476
  async markSwapAsUnknownToProvider(swap) {
2477
+ if (this.refundRetryTimers.has(swap.id)) return;
2379
2478
  if (!this.monitoredSwaps.has(swap.id)) {
2380
2479
  this.notFoundCounts.delete(swap.id);
2381
2480
  return;
@@ -2388,6 +2487,11 @@ var SwapManager = class _SwapManager {
2388
2487
  clearTimeout(retryTimer);
2389
2488
  this.pollRetryTimers.delete(swap.id);
2390
2489
  }
2490
+ const refundRetryTimer = this.refundRetryTimers.get(swap.id);
2491
+ if (refundRetryTimer) {
2492
+ clearTimeout(refundRetryTimer);
2493
+ this.refundRetryTimers.delete(swap.id);
2494
+ }
2391
2495
  this.notFoundCounts.delete(swap.id);
2392
2496
  this.swapUpdateListeners.forEach((listener) => listener(swap, oldStatus));
2393
2497
  const subscribers = this.swapSubscriptions.get(swap.id);
@@ -3145,7 +3249,7 @@ var ArkadeSwaps = class _ArkadeSwaps {
3145
3249
  await this.claimBtc(swap);
3146
3250
  },
3147
3251
  refundArk: async (swap) => {
3148
- await this.refundArk(swap);
3252
+ return this.refundArk(swap);
3149
3253
  },
3150
3254
  signServerClaim: async (swap) => {
3151
3255
  await this.signCooperativeClaimForServer(swap);
@@ -3358,51 +3462,67 @@ var ArkadeSwaps = class _ArkadeSwaps {
3358
3462
  throw new Error(
3359
3463
  `Swap ${pendingSwap.id}: VHTLC address mismatch. Expected ${lockupAddress}, got ${vhtlcAddress}`
3360
3464
  );
3361
- let vtxo;
3465
+ let unspentVtxos = [];
3466
+ let rawVtxos = [];
3362
3467
  for (let attempt = 1; attempt <= CLAIM_VTXO_RETRY_ATTEMPTS; attempt++) {
3363
- const { vtxos } = await this.indexerProvider.getVtxos({
3468
+ const result = await this.indexerProvider.getVtxos({
3364
3469
  scripts: [import_base9.hex.encode(vhtlcScript.pkScript)]
3365
3470
  });
3366
- if (vtxos.length > 0) {
3367
- vtxo = vtxos[0];
3471
+ rawVtxos = result.vtxos;
3472
+ unspentVtxos = result.vtxos.filter((vtxo) => !vtxo.isSpent);
3473
+ if (unspentVtxos.length > 0) {
3368
3474
  break;
3369
3475
  }
3370
3476
  if (attempt < CLAIM_VTXO_RETRY_ATTEMPTS) {
3371
3477
  await new Promise((resolve) => setTimeout(resolve, CLAIM_VTXO_RETRY_DELAY_MS));
3372
3478
  }
3373
3479
  }
3374
- if (!vtxo) {
3375
- throw new Error(`Swap ${pendingSwap.id}: no spendable virtual coins found`);
3376
- }
3377
- if (vtxo.isSpent) {
3480
+ if (unspentVtxos.length === 0) {
3481
+ if (rawVtxos.length === 0) {
3482
+ throw new Error(`Swap ${pendingSwap.id}: no spendable virtual coins found`);
3483
+ }
3378
3484
  throw new Error(`Swap ${pendingSwap.id}: VHTLC is already spent`);
3379
3485
  }
3380
- const input = {
3381
- ...vtxo,
3382
- tapLeafScript: vhtlcScript.claim(),
3383
- tapTree: vhtlcScript.encode()
3384
- };
3385
- const output = {
3386
- amount: BigInt(vtxo.value),
3387
- script: import_sdk8.ArkAddress.decode(address).pkScript
3388
- };
3389
3486
  const vhtlcIdentity = claimVHTLCIdentity(this.wallet.identity, preimage);
3390
- let finalStatus;
3391
- if ((0, import_sdk8.isRecoverable)(vtxo)) {
3392
- await this.joinBatch(vhtlcIdentity, input, output, arkInfo);
3393
- finalStatus = "transaction.claimed";
3394
- } else {
3395
- await claimVHTLCwithOffchainTx(
3396
- vhtlcIdentity,
3397
- vhtlcScript,
3398
- serverXOnly,
3399
- input,
3400
- output,
3401
- arkInfo,
3402
- this.arkProvider
3487
+ const outputScript = import_sdk8.ArkAddress.decode(address).pkScript;
3488
+ const claimErrors = [];
3489
+ let usedOffchainClaim = false;
3490
+ for (const vtxo of unspentVtxos) {
3491
+ const input = {
3492
+ ...vtxo,
3493
+ tapLeafScript: vhtlcScript.claim(),
3494
+ tapTree: vhtlcScript.encode()
3495
+ };
3496
+ const output = {
3497
+ amount: BigInt(vtxo.value),
3498
+ script: outputScript
3499
+ };
3500
+ try {
3501
+ if ((0, import_sdk8.isRecoverable)(vtxo)) {
3502
+ await this.joinBatch(vhtlcIdentity, input, output, arkInfo);
3503
+ } else {
3504
+ await claimVHTLCwithOffchainTx(
3505
+ vhtlcIdentity,
3506
+ vhtlcScript,
3507
+ serverXOnly,
3508
+ input,
3509
+ output,
3510
+ arkInfo,
3511
+ this.arkProvider
3512
+ );
3513
+ usedOffchainClaim = true;
3514
+ }
3515
+ } catch (error) {
3516
+ claimErrors.push({ vtxo, error });
3517
+ }
3518
+ }
3519
+ if (claimErrors.length > 0) {
3520
+ const details = claimErrors.map(({ vtxo, error }) => `${vtxo.txid}:${vtxo.vout} (${error.message})`).join("; ");
3521
+ throw new Error(
3522
+ `Swap ${pendingSwap.id}: failed to claim ${claimErrors.length}/${unspentVtxos.length} VTXOs: ${details}`
3403
3523
  );
3404
- finalStatus = (await this.getSwapStatus(pendingSwap.id)).status;
3405
3524
  }
3525
+ const finalStatus = usedOffchainClaim ? (await this.getSwapStatus(pendingSwap.id)).status : "transaction.claimed";
3406
3526
  await updateReverseSwapStatus(
3407
3527
  pendingSwap,
3408
3528
  finalStatus,
@@ -3777,6 +3897,7 @@ var ArkadeSwaps = class _ArkadeSwaps {
3777
3897
  if (boltzCallCount > 0) {
3778
3898
  await new Promise((r) => setTimeout(r, 2e3));
3779
3899
  }
3900
+ boltzCallCount++;
3780
3901
  await refundVHTLCwithOffchainTx(
3781
3902
  pendingSwap.id,
3782
3903
  this.wallet.identity,
@@ -3789,7 +3910,6 @@ var ArkadeSwaps = class _ArkadeSwaps {
3789
3910
  arkInfo,
3790
3911
  this.swapProvider.refundSubmarineSwap.bind(this.swapProvider)
3791
3912
  );
3792
- boltzCallCount++;
3793
3913
  sweptCount++;
3794
3914
  } catch (error) {
3795
3915
  if (!(error instanceof BoltzRefundError)) {
@@ -4287,8 +4407,17 @@ var ArkadeSwaps = class _ArkadeSwaps {
4287
4407
  await this.swapProvider.postBtcTransaction(claimTx.hex);
4288
4408
  }
4289
4409
  /**
4290
- * When an ARK to BTC swap fails, refund sats on ARK chain by claiming the VHTLC.
4410
+ * When an ARK to BTC swap fails, refund every unspent VTXO at the chain
4411
+ * swap's ARK lockup address.
4412
+ *
4413
+ * Path selection per VTXO:
4414
+ * - CLTV has elapsed → `refundWithoutReceiver` via `joinBatch` (no Boltz).
4415
+ * - Pre-CLTV recoverable → skipped (Boltz can't co-sign swept-batch refund).
4416
+ * - Pre-CLTV non-recoverable → cooperative 3-of-3 refund via Boltz.
4417
+ *
4291
4418
  * @param pendingSwap - The pending chain swap to refund.
4419
+ * @returns Counts of VTXOs swept vs. deferred. A `swept: 0` outcome means
4420
+ * the call was a no-op — callers should retry after CLTV.
4292
4421
  */
4293
4422
  async refundArk(pendingSwap) {
4294
4423
  if (!pendingSwap.response.lockupDetails.serverPublicKey)
@@ -4312,21 +4441,6 @@ var ArkadeSwaps = class _ArkadeSwaps {
4312
4441
  "boltz",
4313
4442
  pendingSwap.id
4314
4443
  );
4315
- const vhtlcPkScript = import_sdk8.ArkAddress.decode(
4316
- pendingSwap.response.lockupDetails.lockupAddress
4317
- ).pkScript;
4318
- const { vtxos } = await this.indexerProvider.getVtxos({
4319
- scripts: [import_base9.hex.encode(vhtlcPkScript)]
4320
- });
4321
- if (vtxos.length === 0) {
4322
- throw new Error(
4323
- `Swap ${pendingSwap.id}: VHTLC not found for address ${pendingSwap.response.lockupDetails.lockupAddress}`
4324
- );
4325
- }
4326
- const vtxo = vtxos[0];
4327
- if (vtxo.isSpent) {
4328
- throw new Error(`Swap ${pendingSwap.id}: VHTLC is already spent`);
4329
- }
4330
4444
  const { vhtlcAddress, vhtlcScript } = this.createVHTLCScript({
4331
4445
  network: arkInfo.network,
4332
4446
  preimageHash: import_base9.hex.decode(pendingSwap.request.preimageHash),
@@ -4342,37 +4456,105 @@ var ArkadeSwaps = class _ArkadeSwaps {
4342
4456
  message: "Unable to claim: invalid VHTLC address"
4343
4457
  });
4344
4458
  }
4345
- const isRecoverableVtxo = (0, import_sdk8.isRecoverable)(vtxo);
4346
- const input = {
4347
- ...vtxo,
4348
- tapLeafScript: isRecoverableVtxo ? vhtlcScript.refundWithoutReceiver() : vhtlcScript.refund(),
4349
- tapTree: vhtlcScript.encode()
4350
- };
4351
- const output = {
4352
- amount: BigInt(vtxo.value),
4353
- script: import_sdk8.ArkAddress.decode(address).pkScript
4354
- };
4355
- if (isRecoverableVtxo) {
4356
- await this.joinBatch(this.wallet.identity, input, output, arkInfo);
4357
- } else {
4358
- await refundVHTLCwithOffchainTx(
4359
- pendingSwap.id,
4360
- this.wallet.identity,
4361
- this.arkProvider,
4362
- boltzXOnlyPublicKey,
4363
- ourXOnlyPublicKey,
4364
- serverXOnlyPublicKey,
4365
- input,
4366
- output,
4367
- arkInfo,
4368
- this.swapProvider.refundChainSwap.bind(this.swapProvider)
4459
+ const { vtxos } = await this.indexerProvider.getVtxos({
4460
+ scripts: [import_base9.hex.encode(vhtlcScript.pkScript)]
4461
+ });
4462
+ if (vtxos.length === 0) {
4463
+ throw new Error(
4464
+ `Swap ${pendingSwap.id}: VHTLC not found for address ${pendingSwap.response.lockupDetails.lockupAddress}`
4369
4465
  );
4370
4466
  }
4467
+ const unspentVtxos = vtxos.filter((vtxo) => !vtxo.isSpent);
4468
+ if (unspentVtxos.length === 0) {
4469
+ throw new Error(`Swap ${pendingSwap.id}: VHTLC is already spent`);
4470
+ }
4471
+ const outputScript = import_sdk8.ArkAddress.decode(address).pkScript;
4472
+ const refundWithoutReceiverLeaf = vhtlcScript.refundWithoutReceiver();
4473
+ const refundLocktime = pendingSwap.response.lockupDetails.timeouts.refund;
4474
+ let boltzCallCount = 0;
4475
+ let sweptCount = 0;
4476
+ let skippedCount = 0;
4477
+ for (const vtxo of unspentVtxos) {
4478
+ const isRecoverableVtxo = (0, import_sdk8.isRecoverable)(vtxo);
4479
+ const output = {
4480
+ amount: BigInt(vtxo.value),
4481
+ script: outputScript
4482
+ };
4483
+ if (isSubmarineRefundLocktimeReached(refundLocktime)) {
4484
+ const input2 = {
4485
+ ...vtxo,
4486
+ tapLeafScript: refundWithoutReceiverLeaf,
4487
+ tapTree: vhtlcScript.encode()
4488
+ };
4489
+ await this.joinBatch(
4490
+ this.wallet.identity,
4491
+ input2,
4492
+ output,
4493
+ arkInfo,
4494
+ isRecoverableVtxo
4495
+ );
4496
+ sweptCount++;
4497
+ continue;
4498
+ }
4499
+ if (isRecoverableVtxo) {
4500
+ logger.error(
4501
+ `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.`
4502
+ );
4503
+ skippedCount++;
4504
+ continue;
4505
+ }
4506
+ const input = {
4507
+ ...vtxo,
4508
+ tapLeafScript: vhtlcScript.refund(),
4509
+ tapTree: vhtlcScript.encode()
4510
+ };
4511
+ try {
4512
+ if (boltzCallCount > 0) {
4513
+ await new Promise((r) => setTimeout(r, 2e3));
4514
+ }
4515
+ boltzCallCount++;
4516
+ await refundVHTLCwithOffchainTx(
4517
+ pendingSwap.id,
4518
+ this.wallet.identity,
4519
+ this.arkProvider,
4520
+ boltzXOnlyPublicKey,
4521
+ ourXOnlyPublicKey,
4522
+ serverXOnlyPublicKey,
4523
+ input,
4524
+ output,
4525
+ arkInfo,
4526
+ this.swapProvider.refundChainSwap.bind(this.swapProvider)
4527
+ );
4528
+ sweptCount++;
4529
+ } catch (error) {
4530
+ if (!(error instanceof BoltzRefundError)) {
4531
+ throw error;
4532
+ }
4533
+ if (!isSubmarineRefundLocktimeReached(refundLocktime)) {
4534
+ logger.error(
4535
+ `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.`
4536
+ );
4537
+ skippedCount++;
4538
+ continue;
4539
+ }
4540
+ logger.warn(
4541
+ `Swap ${pendingSwap.id}: Boltz rejected VTXO outpoint, falling back to refundWithoutReceiver via joinBatch`
4542
+ );
4543
+ const fallbackInput = {
4544
+ ...vtxo,
4545
+ tapLeafScript: refundWithoutReceiverLeaf,
4546
+ tapTree: vhtlcScript.encode()
4547
+ };
4548
+ await this.joinBatch(this.wallet.identity, fallbackInput, output, arkInfo, false);
4549
+ sweptCount++;
4550
+ }
4551
+ }
4371
4552
  const finalStatus = await this.getSwapStatus(pendingSwap.id);
4372
4553
  await this.savePendingChainSwap({
4373
4554
  ...pendingSwap,
4374
4555
  status: finalStatus.status
4375
4556
  });
4557
+ return { swept: sweptCount, skipped: skippedCount };
4376
4558
  }
4377
4559
  // =========================================================================
4378
4560
  // Chain swaps: BTC -> ARK
@@ -4793,7 +4975,13 @@ var ArkadeSwaps = class _ArkadeSwaps {
4793
4975
  this.validateQuoteOptions(options);
4794
4976
  const floor = await this.resolveQuoteFloor(swapId, options);
4795
4977
  const slippageBps = options?.maxSlippageBps ?? 0;
4796
- return Math.floor(floor - floor * slippageBps / 1e4);
4978
+ const effectiveFloor = Math.floor(floor - floor * slippageBps / 1e4);
4979
+ if (effectiveFloor < 1) {
4980
+ throw new TypeError(
4981
+ `Invalid quote configuration: maxSlippageBps=${slippageBps} reduces floor ${floor} below 1 sat`
4982
+ );
4983
+ }
4984
+ return effectiveFloor;
4797
4985
  }
4798
4986
  async resolveQuoteFloor(swapId, options) {
4799
4987
  if (options?.minAcceptableAmount !== void 0) {
@@ -4829,7 +5017,13 @@ var ArkadeSwaps = class _ArkadeSwaps {
4829
5017
  }
4830
5018
  }
4831
5019
  validateQuote(amount, effectiveFloor) {
4832
- if (!(amount > 0)) {
5020
+ if (!Number.isSafeInteger(amount)) {
5021
+ throw new QuoteRejectedError({
5022
+ reason: "non_safe_integer",
5023
+ quotedAmount: amount
5024
+ });
5025
+ }
5026
+ if (amount <= 0) {
4833
5027
  throw new QuoteRejectedError({
4834
5028
  reason: "non_positive",
4835
5029
  quotedAmount: amount
@@ -5012,20 +5206,7 @@ var ArkadeSwaps = class _ArkadeSwaps {
5012
5206
  onchainAmount: amount,
5013
5207
  lockupAddress,
5014
5208
  refundPublicKey: serverPublicKey,
5015
- timeoutBlockHeights: timeoutBlockHeights ?? {
5016
- refund: extractTimeLockFromLeafOutput(
5017
- tree.refundWithoutBoltzLeaf?.output ?? ""
5018
- ),
5019
- unilateralClaim: extractTimeLockFromLeafOutput(
5020
- tree.unilateralClaimLeaf?.output ?? ""
5021
- ),
5022
- unilateralRefund: extractTimeLockFromLeafOutput(
5023
- tree.unilateralRefundLeaf?.output ?? ""
5024
- ),
5025
- unilateralRefundWithoutReceiver: extractTimeLockFromLeafOutput(
5026
- tree.unilateralRefundWithoutBoltzLeaf?.output ?? ""
5027
- )
5028
- }
5209
+ timeoutBlockHeights: resolveVhtlcTimeouts(tree, timeoutBlockHeights)
5029
5210
  },
5030
5211
  status,
5031
5212
  type: "reverse",
@@ -5058,26 +5239,20 @@ var ArkadeSwaps = class _ArkadeSwaps {
5058
5239
  address: lockupAddress,
5059
5240
  expectedAmount: amount,
5060
5241
  claimPublicKey: serverPublicKey,
5061
- timeoutBlockHeights: timeoutBlockHeights ?? {
5062
- refund: extractTimeLockFromLeafOutput(
5063
- tree.refundWithoutBoltzLeaf?.output ?? ""
5064
- ),
5065
- unilateralClaim: extractTimeLockFromLeafOutput(
5066
- tree.unilateralClaimLeaf?.output ?? ""
5067
- ),
5068
- unilateralRefund: extractTimeLockFromLeafOutput(
5069
- tree.unilateralRefundLeaf?.output ?? ""
5070
- ),
5071
- unilateralRefundWithoutReceiver: extractTimeLockFromLeafOutput(
5072
- tree.unilateralRefundWithoutBoltzLeaf?.output ?? ""
5073
- )
5074
- }
5242
+ timeoutBlockHeights: resolveVhtlcTimeouts(tree, timeoutBlockHeights)
5075
5243
  }
5076
5244
  });
5077
5245
  } else if (isRestoredChainSwap(swap)) {
5078
5246
  const refundDetails = swap.refundDetails;
5079
5247
  if (!refundDetails) continue;
5080
- const { amount, lockupAddress, serverPublicKey, timeoutBlockHeight } = refundDetails;
5248
+ const {
5249
+ amount,
5250
+ lockupAddress,
5251
+ serverPublicKey,
5252
+ timeoutBlockHeight,
5253
+ tree,
5254
+ timeoutBlockHeights
5255
+ } = refundDetails;
5081
5256
  chainSwaps.push({
5082
5257
  id,
5083
5258
  type: "chain",
@@ -5103,7 +5278,8 @@ var ArkadeSwaps = class _ArkadeSwaps {
5103
5278
  amount,
5104
5279
  lockupAddress,
5105
5280
  serverPublicKey,
5106
- timeoutBlockHeight
5281
+ timeoutBlockHeight,
5282
+ timeouts: resolveVhtlcTimeouts(tree, timeoutBlockHeights)
5107
5283
  }
5108
5284
  }
5109
5285
  });
@@ -5473,9 +5649,14 @@ var ArkadeSwapsMessageHandler = class _ArkadeSwapsMessageHandler {
5473
5649
  case "CLAIM_BTC":
5474
5650
  await this.handler.claimBtc(message.payload);
5475
5651
  return this.tagged({ id, type: "BTC_CLAIM_EXECUTED" });
5476
- case "REFUND_ARK":
5477
- await this.handler.refundArk(message.payload);
5478
- return this.tagged({ id, type: "ARK_REFUND_EXECUTED" });
5652
+ case "REFUND_ARK": {
5653
+ const outcome = await this.handler.refundArk(message.payload);
5654
+ return this.tagged({
5655
+ id,
5656
+ type: "ARK_REFUND_EXECUTED",
5657
+ payload: outcome
5658
+ });
5659
+ }
5479
5660
  case "SIGN_SERVER_CLAIM":
5480
5661
  await this.handler.signCooperativeClaimForServer(message.payload);
5481
5662
  return this.tagged({ id, type: "SERVER_CLAIM_SIGNED" });
@@ -6150,12 +6331,13 @@ var ServiceWorkerArkadeSwaps = class _ServiceWorkerArkadeSwaps {
6150
6331
  });
6151
6332
  }
6152
6333
  async refundArk(pendingSwap) {
6153
- await this.sendMessage({
6334
+ const res = await this.sendMessage({
6154
6335
  id: (0, import_sdk10.getRandomId)(),
6155
6336
  tag: this.messageTag,
6156
6337
  type: "REFUND_ARK",
6157
6338
  payload: pendingSwap
6158
6339
  });
6340
+ return res.payload;
6159
6341
  }
6160
6342
  async signCooperativeClaimForServer(pendingSwap) {
6161
6343
  await this.sendMessage({
@@ -6565,6 +6747,30 @@ async function getContractCollection(storage, contractType) {
6565
6747
  );
6566
6748
  }
6567
6749
  }
6750
+
6751
+ // src/repositories/inMemory/swap-repository.ts
6752
+ var InMemorySwapRepository = class {
6753
+ version = 1;
6754
+ swaps = /* @__PURE__ */ new Map();
6755
+ async saveSwap(swap) {
6756
+ this.swaps.set(swap.id, swap);
6757
+ }
6758
+ async deleteSwap(id) {
6759
+ this.swaps.delete(id);
6760
+ }
6761
+ async getAllSwaps(filter) {
6762
+ const swaps = [...this.swaps.values()];
6763
+ if (!filter || Object.keys(filter).length === 0) return swaps;
6764
+ const filtered = applySwapsFilter(swaps, filter);
6765
+ return applyCreatedAtOrder(filtered, filter);
6766
+ }
6767
+ async clear() {
6768
+ this.swaps.clear();
6769
+ }
6770
+ async [Symbol.asyncDispose]() {
6771
+ await this.clear();
6772
+ }
6773
+ };
6568
6774
  // Annotate the CommonJS export names for ESM import in node:
6569
6775
  0 && (module.exports = {
6570
6776
  ArkadeLightningMessageHandler,
@@ -6572,6 +6778,7 @@ async function getContractCollection(storage, contractType) {
6572
6778
  ArkadeSwapsMessageHandler,
6573
6779
  BoltzRefundError,
6574
6780
  BoltzSwapProvider,
6781
+ InMemorySwapRepository,
6575
6782
  IndexedDbSwapRepository,
6576
6783
  InsufficientFundsError,
6577
6784
  InvoiceExpiredError,