@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.
@@ -145,6 +145,8 @@ var QuoteRejectedError = class _QuoteRejectedError extends SwapError {
145
145
  return `Boltz quote ${options.quotedAmount} is below acceptable floor ${options.floor}`;
146
146
  case "non_positive":
147
147
  return `Boltz quote ${options.quotedAmount} is not positive`;
148
+ case "non_safe_integer":
149
+ return `Boltz quote ${options.quotedAmount} is not a safe positive satoshi integer`;
148
150
  case "no_baseline":
149
151
  return "Cannot accept quote: no minAcceptableAmount and no stored pending swap";
150
152
  }
@@ -198,6 +200,7 @@ var QuoteRejectedError = class _QuoteRejectedError extends SwapError {
198
200
  message
199
201
  });
200
202
  case "non_positive":
203
+ case "non_safe_integer":
201
204
  if (quotedAmount === null) return null;
202
205
  return new _QuoteRejectedError({
203
206
  reason,
@@ -213,6 +216,7 @@ var QUOTE_REJECTION_TRANSPORT_PREFIX = "QUOTE_REJECTED::";
213
216
  var QUOTE_REJECTION_REASONS = /* @__PURE__ */ new Set([
214
217
  "below_floor",
215
218
  "non_positive",
219
+ "non_safe_integer",
216
220
  "no_baseline"
217
221
  ]);
218
222
  var BoltzRefundError = class extends Error {
@@ -1359,6 +1363,20 @@ function extractInvoiceAmount(amountSats, fees) {
1359
1363
  if (miner >= amountSats) return 0;
1360
1364
  return Math.ceil((amountSats - miner) / (1 - percentage / 100));
1361
1365
  }
1366
+ function resolveVhtlcTimeouts(tree, timeoutBlockHeights) {
1367
+ const resolved = timeoutBlockHeights ?? {
1368
+ refund: extractTimeLockFromLeafOutput(tree.refundWithoutBoltzLeaf?.output ?? ""),
1369
+ unilateralClaim: extractTimeLockFromLeafOutput(tree.unilateralClaimLeaf?.output ?? ""),
1370
+ unilateralRefund: extractTimeLockFromLeafOutput(tree.unilateralRefundLeaf?.output ?? ""),
1371
+ unilateralRefundWithoutReceiver: extractTimeLockFromLeafOutput(
1372
+ tree.unilateralRefundWithoutBoltzLeaf?.output ?? ""
1373
+ )
1374
+ };
1375
+ if (!resolved.refund || !resolved.unilateralClaim || !resolved.unilateralRefund || !resolved.unilateralRefundWithoutReceiver) {
1376
+ return void 0;
1377
+ }
1378
+ return resolved;
1379
+ }
1362
1380
 
1363
1381
  // src/logger.ts
1364
1382
  var logger = console;
@@ -1373,6 +1391,13 @@ var SwapManager = class _SwapManager {
1373
1391
  * enough that a real "swap unknown to this provider" surfaces quickly.
1374
1392
  */
1375
1393
  static NOT_FOUND_THRESHOLD = 10;
1394
+ /**
1395
+ * Delay between re-attempts of a chain refund that left VTXOs deferred
1396
+ * (e.g. pre-CLTV recoverable VTXO, or Boltz 3-of-3 rejected before CLTV
1397
+ * has elapsed). Boltz won't send another status update once the swap
1398
+ * is `swap.expired`, so the manager owns the local retry cadence.
1399
+ */
1400
+ static REFUND_RETRY_DELAY_MS = 6e4;
1376
1401
  swapProvider;
1377
1402
  config;
1378
1403
  // Event listeners storage (supports multiple listeners per event)
@@ -1391,6 +1416,11 @@ var SwapManager = class _SwapManager {
1391
1416
  reconnectTimer = null;
1392
1417
  initialPollTimer = null;
1393
1418
  pollRetryTimers = /* @__PURE__ */ new Map();
1419
+ // Per-swap retry timers for chain refunds that left work undone
1420
+ // (refundArk returned `skipped > 0`). The swap is held in
1421
+ // `monitoredSwaps` past its terminal Boltz status until the local
1422
+ // refund completes or the manager stops.
1423
+ refundRetryTimers = /* @__PURE__ */ new Map();
1394
1424
  // Per-swap counter of consecutive `SwapNotFoundError` responses from
1395
1425
  // `getSwapStatus`. Reset on any successful poll. Once a swap reaches
1396
1426
  // `NOT_FOUND_THRESHOLD` consecutive 404s the safety net trips and the
@@ -1587,6 +1617,10 @@ var SwapManager = class _SwapManager {
1587
1617
  clearTimeout(timer);
1588
1618
  }
1589
1619
  this.pollRetryTimers.clear();
1620
+ for (const timer of this.refundRetryTimers.values()) {
1621
+ clearTimeout(timer);
1622
+ }
1623
+ this.refundRetryTimers.clear();
1590
1624
  this.notFoundCounts.clear();
1591
1625
  }
1592
1626
  /**
@@ -1643,6 +1677,11 @@ var SwapManager = class _SwapManager {
1643
1677
  clearTimeout(retryTimer);
1644
1678
  this.pollRetryTimers.delete(swapId);
1645
1679
  }
1680
+ const refundRetryTimer = this.refundRetryTimers.get(swapId);
1681
+ if (refundRetryTimer) {
1682
+ clearTimeout(refundRetryTimer);
1683
+ this.refundRetryTimers.delete(swapId);
1684
+ }
1646
1685
  this.notFoundCounts.delete(swapId);
1647
1686
  logger.log(`Removed swap ${swapId} from monitoring`);
1648
1687
  }
@@ -1914,15 +1953,57 @@ var SwapManager = class _SwapManager {
1914
1953
  await this.executeAutonomousAction(swap);
1915
1954
  }
1916
1955
  if (this.isFinalStatus(swap)) {
1917
- this.monitoredSwaps.delete(swap.id);
1918
- this.swapSubscriptions.delete(swap.id);
1919
- const retryTimer = this.pollRetryTimers.get(swap.id);
1920
- if (retryTimer) {
1921
- clearTimeout(retryTimer);
1922
- this.pollRetryTimers.delete(swap.id);
1956
+ if (this.refundRetryTimers.has(swap.id)) {
1957
+ return;
1923
1958
  }
1924
- this.swapCompletedListeners.forEach((listener) => listener(swap));
1959
+ this.finalizeMonitoredSwap(swap);
1960
+ }
1961
+ }
1962
+ /**
1963
+ * Drop a swap from monitoring and emit the terminal completion event.
1964
+ * Shared between the on-status-update finalization path and the
1965
+ * refund-retry finalization path (used when a previously-deferred
1966
+ * chain refund has finished its remaining work).
1967
+ */
1968
+ finalizeMonitoredSwap(swap) {
1969
+ if (!this.monitoredSwaps.has(swap.id)) return;
1970
+ this.monitoredSwaps.delete(swap.id);
1971
+ this.swapSubscriptions.delete(swap.id);
1972
+ const retryTimer = this.pollRetryTimers.get(swap.id);
1973
+ if (retryTimer) {
1974
+ clearTimeout(retryTimer);
1975
+ this.pollRetryTimers.delete(swap.id);
1925
1976
  }
1977
+ const refundRetry = this.refundRetryTimers.get(swap.id);
1978
+ if (refundRetry) {
1979
+ clearTimeout(refundRetry);
1980
+ this.refundRetryTimers.delete(swap.id);
1981
+ }
1982
+ this.swapCompletedListeners.forEach((listener) => listener(swap));
1983
+ }
1984
+ /**
1985
+ * Schedule another `executeAutonomousAction` run for a chain swap whose
1986
+ * refund left VTXOs deferred. After the retry completes, if no further
1987
+ * deferral was reported, finalize monitoring cleanup.
1988
+ */
1989
+ scheduleRefundRetry(swap, delayMs) {
1990
+ const existing = this.refundRetryTimers.get(swap.id);
1991
+ if (existing) clearTimeout(existing);
1992
+ this.refundRetryTimers.set(
1993
+ swap.id,
1994
+ setTimeout(async () => {
1995
+ this.refundRetryTimers.delete(swap.id);
1996
+ if (!this.isRunning) return;
1997
+ if (!this.monitoredSwaps.has(swap.id)) return;
1998
+ try {
1999
+ await this.executeAutonomousAction(swap);
2000
+ } finally {
2001
+ if (!this.refundRetryTimers.has(swap.id) && this.isFinalStatus(swap)) {
2002
+ this.finalizeMonitoredSwap(swap);
2003
+ }
2004
+ }
2005
+ }, delayMs)
2006
+ );
1926
2007
  }
1927
2008
  /**
1928
2009
  * Execute autonomous action based on swap status
@@ -1977,10 +2058,27 @@ var SwapManager = class _SwapManager {
1977
2058
  } else if (isChainRefundableStatus(swap.status)) {
1978
2059
  if (swap.request.from === "ARK") {
1979
2060
  logger.log(`Auto-refunding ARK chain swap ${swap.id}`);
1980
- await this.executeRefundArkAction(swap);
1981
- this.actionExecutedListeners.forEach(
1982
- (listener) => listener(swap, "refundArk")
1983
- );
2061
+ try {
2062
+ const outcome = await this.executeRefundArkAction(swap);
2063
+ if (outcome && outcome.skipped > 0) {
2064
+ logger.log(
2065
+ `Chain swap ${swap.id}: ${outcome.skipped} VTXO(s) deferred \u2014 scheduling refund retry`
2066
+ );
2067
+ this.scheduleRefundRetry(swap, _SwapManager.REFUND_RETRY_DELAY_MS);
2068
+ }
2069
+ this.actionExecutedListeners.forEach(
2070
+ (listener) => listener(swap, "refundArk")
2071
+ );
2072
+ } catch (error) {
2073
+ logger.error(
2074
+ `Auto-refunding ARK chain swap ${swap.id} failed; scheduling retry`,
2075
+ error
2076
+ );
2077
+ this.swapFailedListeners.forEach(
2078
+ (listener) => listener(swap, error)
2079
+ );
2080
+ this.scheduleRefundRetry(swap, _SwapManager.REFUND_RETRY_DELAY_MS);
2081
+ }
1984
2082
  }
1985
2083
  if (swap.request.from === "BTC") {
1986
2084
  logger.warn(
@@ -2061,7 +2159,7 @@ var SwapManager = class _SwapManager {
2061
2159
  logger.error("refundArk callback not set");
2062
2160
  return;
2063
2161
  }
2064
- await this.refundArkCallback(swap);
2162
+ return this.refundArkCallback(swap);
2065
2163
  }
2066
2164
  /**
2067
2165
  * Execute sign server claim action for chain swap.
@@ -2161,9 +2259,7 @@ var SwapManager = class _SwapManager {
2161
2259
  */
2162
2260
  async pollAllSwaps() {
2163
2261
  if (this.monitoredSwaps.size === 0) return;
2164
- const pollPromises = Array.from(this.monitoredSwaps.values()).map(
2165
- (swap) => this.pollSingleSwap(swap)
2166
- );
2262
+ const pollPromises = Array.from(this.monitoredSwaps.values()).filter((swap) => !this.refundRetryTimers.has(swap.id)).map((swap) => this.pollSingleSwap(swap));
2167
2263
  await Promise.allSettled(pollPromises);
2168
2264
  }
2169
2265
  async pollSingleSwap(swap) {
@@ -2216,6 +2312,7 @@ var SwapManager = class _SwapManager {
2216
2312
  * Boltz endpoint).
2217
2313
  */
2218
2314
  async handleSwapNotFound(swap) {
2315
+ if (this.refundRetryTimers.has(swap.id)) return;
2219
2316
  const count = (this.notFoundCounts.get(swap.id) ?? 0) + 1;
2220
2317
  this.notFoundCounts.set(swap.id, count);
2221
2318
  logger.warn(
@@ -2236,6 +2333,7 @@ var SwapManager = class _SwapManager {
2236
2333
  * 404s without recovering anything.
2237
2334
  */
2238
2335
  async markSwapAsUnknownToProvider(swap) {
2336
+ if (this.refundRetryTimers.has(swap.id)) return;
2239
2337
  if (!this.monitoredSwaps.has(swap.id)) {
2240
2338
  this.notFoundCounts.delete(swap.id);
2241
2339
  return;
@@ -2248,6 +2346,11 @@ var SwapManager = class _SwapManager {
2248
2346
  clearTimeout(retryTimer);
2249
2347
  this.pollRetryTimers.delete(swap.id);
2250
2348
  }
2349
+ const refundRetryTimer = this.refundRetryTimers.get(swap.id);
2350
+ if (refundRetryTimer) {
2351
+ clearTimeout(refundRetryTimer);
2352
+ this.refundRetryTimers.delete(swap.id);
2353
+ }
2251
2354
  this.notFoundCounts.delete(swap.id);
2252
2355
  this.swapUpdateListeners.forEach((listener) => listener(swap, oldStatus));
2253
2356
  const subscribers = this.swapSubscriptions.get(swap.id);
@@ -2998,7 +3101,7 @@ var ArkadeSwaps = class _ArkadeSwaps {
2998
3101
  await this.claimBtc(swap);
2999
3102
  },
3000
3103
  refundArk: async (swap) => {
3001
- await this.refundArk(swap);
3104
+ return this.refundArk(swap);
3002
3105
  },
3003
3106
  signServerClaim: async (swap) => {
3004
3107
  await this.signCooperativeClaimForServer(swap);
@@ -3211,51 +3314,67 @@ var ArkadeSwaps = class _ArkadeSwaps {
3211
3314
  throw new Error(
3212
3315
  `Swap ${pendingSwap.id}: VHTLC address mismatch. Expected ${lockupAddress}, got ${vhtlcAddress}`
3213
3316
  );
3214
- let vtxo;
3317
+ let unspentVtxos = [];
3318
+ let rawVtxos = [];
3215
3319
  for (let attempt = 1; attempt <= CLAIM_VTXO_RETRY_ATTEMPTS; attempt++) {
3216
- const { vtxos } = await this.indexerProvider.getVtxos({
3320
+ const result = await this.indexerProvider.getVtxos({
3217
3321
  scripts: [import_base9.hex.encode(vhtlcScript.pkScript)]
3218
3322
  });
3219
- if (vtxos.length > 0) {
3220
- vtxo = vtxos[0];
3323
+ rawVtxos = result.vtxos;
3324
+ unspentVtxos = result.vtxos.filter((vtxo) => !vtxo.isSpent);
3325
+ if (unspentVtxos.length > 0) {
3221
3326
  break;
3222
3327
  }
3223
3328
  if (attempt < CLAIM_VTXO_RETRY_ATTEMPTS) {
3224
3329
  await new Promise((resolve) => setTimeout(resolve, CLAIM_VTXO_RETRY_DELAY_MS));
3225
3330
  }
3226
3331
  }
3227
- if (!vtxo) {
3228
- throw new Error(`Swap ${pendingSwap.id}: no spendable virtual coins found`);
3229
- }
3230
- if (vtxo.isSpent) {
3332
+ if (unspentVtxos.length === 0) {
3333
+ if (rawVtxos.length === 0) {
3334
+ throw new Error(`Swap ${pendingSwap.id}: no spendable virtual coins found`);
3335
+ }
3231
3336
  throw new Error(`Swap ${pendingSwap.id}: VHTLC is already spent`);
3232
3337
  }
3233
- const input = {
3234
- ...vtxo,
3235
- tapLeafScript: vhtlcScript.claim(),
3236
- tapTree: vhtlcScript.encode()
3237
- };
3238
- const output = {
3239
- amount: BigInt(vtxo.value),
3240
- script: import_sdk8.ArkAddress.decode(address).pkScript
3241
- };
3242
3338
  const vhtlcIdentity = claimVHTLCIdentity(this.wallet.identity, preimage);
3243
- let finalStatus;
3244
- if ((0, import_sdk8.isRecoverable)(vtxo)) {
3245
- await this.joinBatch(vhtlcIdentity, input, output, arkInfo);
3246
- finalStatus = "transaction.claimed";
3247
- } else {
3248
- await claimVHTLCwithOffchainTx(
3249
- vhtlcIdentity,
3250
- vhtlcScript,
3251
- serverXOnly,
3252
- input,
3253
- output,
3254
- arkInfo,
3255
- this.arkProvider
3339
+ const outputScript = import_sdk8.ArkAddress.decode(address).pkScript;
3340
+ const claimErrors = [];
3341
+ let usedOffchainClaim = false;
3342
+ for (const vtxo of unspentVtxos) {
3343
+ const input = {
3344
+ ...vtxo,
3345
+ tapLeafScript: vhtlcScript.claim(),
3346
+ tapTree: vhtlcScript.encode()
3347
+ };
3348
+ const output = {
3349
+ amount: BigInt(vtxo.value),
3350
+ script: outputScript
3351
+ };
3352
+ try {
3353
+ if ((0, import_sdk8.isRecoverable)(vtxo)) {
3354
+ await this.joinBatch(vhtlcIdentity, input, output, arkInfo);
3355
+ } else {
3356
+ await claimVHTLCwithOffchainTx(
3357
+ vhtlcIdentity,
3358
+ vhtlcScript,
3359
+ serverXOnly,
3360
+ input,
3361
+ output,
3362
+ arkInfo,
3363
+ this.arkProvider
3364
+ );
3365
+ usedOffchainClaim = true;
3366
+ }
3367
+ } catch (error) {
3368
+ claimErrors.push({ vtxo, error });
3369
+ }
3370
+ }
3371
+ if (claimErrors.length > 0) {
3372
+ const details = claimErrors.map(({ vtxo, error }) => `${vtxo.txid}:${vtxo.vout} (${error.message})`).join("; ");
3373
+ throw new Error(
3374
+ `Swap ${pendingSwap.id}: failed to claim ${claimErrors.length}/${unspentVtxos.length} VTXOs: ${details}`
3256
3375
  );
3257
- finalStatus = (await this.getSwapStatus(pendingSwap.id)).status;
3258
3376
  }
3377
+ const finalStatus = usedOffchainClaim ? (await this.getSwapStatus(pendingSwap.id)).status : "transaction.claimed";
3259
3378
  await updateReverseSwapStatus(
3260
3379
  pendingSwap,
3261
3380
  finalStatus,
@@ -3630,6 +3749,7 @@ var ArkadeSwaps = class _ArkadeSwaps {
3630
3749
  if (boltzCallCount > 0) {
3631
3750
  await new Promise((r) => setTimeout(r, 2e3));
3632
3751
  }
3752
+ boltzCallCount++;
3633
3753
  await refundVHTLCwithOffchainTx(
3634
3754
  pendingSwap.id,
3635
3755
  this.wallet.identity,
@@ -3642,7 +3762,6 @@ var ArkadeSwaps = class _ArkadeSwaps {
3642
3762
  arkInfo,
3643
3763
  this.swapProvider.refundSubmarineSwap.bind(this.swapProvider)
3644
3764
  );
3645
- boltzCallCount++;
3646
3765
  sweptCount++;
3647
3766
  } catch (error) {
3648
3767
  if (!(error instanceof BoltzRefundError)) {
@@ -4140,8 +4259,17 @@ var ArkadeSwaps = class _ArkadeSwaps {
4140
4259
  await this.swapProvider.postBtcTransaction(claimTx.hex);
4141
4260
  }
4142
4261
  /**
4143
- * When an ARK to BTC swap fails, refund sats on ARK chain by claiming the VHTLC.
4262
+ * When an ARK to BTC swap fails, refund every unspent VTXO at the chain
4263
+ * swap's ARK lockup address.
4264
+ *
4265
+ * Path selection per VTXO:
4266
+ * - CLTV has elapsed → `refundWithoutReceiver` via `joinBatch` (no Boltz).
4267
+ * - Pre-CLTV recoverable → skipped (Boltz can't co-sign swept-batch refund).
4268
+ * - Pre-CLTV non-recoverable → cooperative 3-of-3 refund via Boltz.
4269
+ *
4144
4270
  * @param pendingSwap - The pending chain swap to refund.
4271
+ * @returns Counts of VTXOs swept vs. deferred. A `swept: 0` outcome means
4272
+ * the call was a no-op — callers should retry after CLTV.
4145
4273
  */
4146
4274
  async refundArk(pendingSwap) {
4147
4275
  if (!pendingSwap.response.lockupDetails.serverPublicKey)
@@ -4165,21 +4293,6 @@ var ArkadeSwaps = class _ArkadeSwaps {
4165
4293
  "boltz",
4166
4294
  pendingSwap.id
4167
4295
  );
4168
- const vhtlcPkScript = import_sdk8.ArkAddress.decode(
4169
- pendingSwap.response.lockupDetails.lockupAddress
4170
- ).pkScript;
4171
- const { vtxos } = await this.indexerProvider.getVtxos({
4172
- scripts: [import_base9.hex.encode(vhtlcPkScript)]
4173
- });
4174
- if (vtxos.length === 0) {
4175
- throw new Error(
4176
- `Swap ${pendingSwap.id}: VHTLC not found for address ${pendingSwap.response.lockupDetails.lockupAddress}`
4177
- );
4178
- }
4179
- const vtxo = vtxos[0];
4180
- if (vtxo.isSpent) {
4181
- throw new Error(`Swap ${pendingSwap.id}: VHTLC is already spent`);
4182
- }
4183
4296
  const { vhtlcAddress, vhtlcScript } = this.createVHTLCScript({
4184
4297
  network: arkInfo.network,
4185
4298
  preimageHash: import_base9.hex.decode(pendingSwap.request.preimageHash),
@@ -4195,37 +4308,105 @@ var ArkadeSwaps = class _ArkadeSwaps {
4195
4308
  message: "Unable to claim: invalid VHTLC address"
4196
4309
  });
4197
4310
  }
4198
- const isRecoverableVtxo = (0, import_sdk8.isRecoverable)(vtxo);
4199
- const input = {
4200
- ...vtxo,
4201
- tapLeafScript: isRecoverableVtxo ? vhtlcScript.refundWithoutReceiver() : vhtlcScript.refund(),
4202
- tapTree: vhtlcScript.encode()
4203
- };
4204
- const output = {
4205
- amount: BigInt(vtxo.value),
4206
- script: import_sdk8.ArkAddress.decode(address).pkScript
4207
- };
4208
- if (isRecoverableVtxo) {
4209
- await this.joinBatch(this.wallet.identity, input, output, arkInfo);
4210
- } else {
4211
- await refundVHTLCwithOffchainTx(
4212
- pendingSwap.id,
4213
- this.wallet.identity,
4214
- this.arkProvider,
4215
- boltzXOnlyPublicKey,
4216
- ourXOnlyPublicKey,
4217
- serverXOnlyPublicKey,
4218
- input,
4219
- output,
4220
- arkInfo,
4221
- this.swapProvider.refundChainSwap.bind(this.swapProvider)
4311
+ const { vtxos } = await this.indexerProvider.getVtxos({
4312
+ scripts: [import_base9.hex.encode(vhtlcScript.pkScript)]
4313
+ });
4314
+ if (vtxos.length === 0) {
4315
+ throw new Error(
4316
+ `Swap ${pendingSwap.id}: VHTLC not found for address ${pendingSwap.response.lockupDetails.lockupAddress}`
4222
4317
  );
4223
4318
  }
4319
+ const unspentVtxos = vtxos.filter((vtxo) => !vtxo.isSpent);
4320
+ if (unspentVtxos.length === 0) {
4321
+ throw new Error(`Swap ${pendingSwap.id}: VHTLC is already spent`);
4322
+ }
4323
+ const outputScript = import_sdk8.ArkAddress.decode(address).pkScript;
4324
+ const refundWithoutReceiverLeaf = vhtlcScript.refundWithoutReceiver();
4325
+ const refundLocktime = pendingSwap.response.lockupDetails.timeouts.refund;
4326
+ let boltzCallCount = 0;
4327
+ let sweptCount = 0;
4328
+ let skippedCount = 0;
4329
+ for (const vtxo of unspentVtxos) {
4330
+ const isRecoverableVtxo = (0, import_sdk8.isRecoverable)(vtxo);
4331
+ const output = {
4332
+ amount: BigInt(vtxo.value),
4333
+ script: outputScript
4334
+ };
4335
+ if (isSubmarineRefundLocktimeReached(refundLocktime)) {
4336
+ const input2 = {
4337
+ ...vtxo,
4338
+ tapLeafScript: refundWithoutReceiverLeaf,
4339
+ tapTree: vhtlcScript.encode()
4340
+ };
4341
+ await this.joinBatch(
4342
+ this.wallet.identity,
4343
+ input2,
4344
+ output,
4345
+ arkInfo,
4346
+ isRecoverableVtxo
4347
+ );
4348
+ sweptCount++;
4349
+ continue;
4350
+ }
4351
+ if (isRecoverableVtxo) {
4352
+ logger.error(
4353
+ `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.`
4354
+ );
4355
+ skippedCount++;
4356
+ continue;
4357
+ }
4358
+ const input = {
4359
+ ...vtxo,
4360
+ tapLeafScript: vhtlcScript.refund(),
4361
+ tapTree: vhtlcScript.encode()
4362
+ };
4363
+ try {
4364
+ if (boltzCallCount > 0) {
4365
+ await new Promise((r) => setTimeout(r, 2e3));
4366
+ }
4367
+ boltzCallCount++;
4368
+ await refundVHTLCwithOffchainTx(
4369
+ pendingSwap.id,
4370
+ this.wallet.identity,
4371
+ this.arkProvider,
4372
+ boltzXOnlyPublicKey,
4373
+ ourXOnlyPublicKey,
4374
+ serverXOnlyPublicKey,
4375
+ input,
4376
+ output,
4377
+ arkInfo,
4378
+ this.swapProvider.refundChainSwap.bind(this.swapProvider)
4379
+ );
4380
+ sweptCount++;
4381
+ } catch (error) {
4382
+ if (!(error instanceof BoltzRefundError)) {
4383
+ throw error;
4384
+ }
4385
+ if (!isSubmarineRefundLocktimeReached(refundLocktime)) {
4386
+ logger.error(
4387
+ `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.`
4388
+ );
4389
+ skippedCount++;
4390
+ continue;
4391
+ }
4392
+ logger.warn(
4393
+ `Swap ${pendingSwap.id}: Boltz rejected VTXO outpoint, falling back to refundWithoutReceiver via joinBatch`
4394
+ );
4395
+ const fallbackInput = {
4396
+ ...vtxo,
4397
+ tapLeafScript: refundWithoutReceiverLeaf,
4398
+ tapTree: vhtlcScript.encode()
4399
+ };
4400
+ await this.joinBatch(this.wallet.identity, fallbackInput, output, arkInfo, false);
4401
+ sweptCount++;
4402
+ }
4403
+ }
4224
4404
  const finalStatus = await this.getSwapStatus(pendingSwap.id);
4225
4405
  await this.savePendingChainSwap({
4226
4406
  ...pendingSwap,
4227
4407
  status: finalStatus.status
4228
4408
  });
4409
+ return { swept: sweptCount, skipped: skippedCount };
4229
4410
  }
4230
4411
  // =========================================================================
4231
4412
  // Chain swaps: BTC -> ARK
@@ -4646,7 +4827,13 @@ var ArkadeSwaps = class _ArkadeSwaps {
4646
4827
  this.validateQuoteOptions(options);
4647
4828
  const floor = await this.resolveQuoteFloor(swapId, options);
4648
4829
  const slippageBps = options?.maxSlippageBps ?? 0;
4649
- return Math.floor(floor - floor * slippageBps / 1e4);
4830
+ const effectiveFloor = Math.floor(floor - floor * slippageBps / 1e4);
4831
+ if (effectiveFloor < 1) {
4832
+ throw new TypeError(
4833
+ `Invalid quote configuration: maxSlippageBps=${slippageBps} reduces floor ${floor} below 1 sat`
4834
+ );
4835
+ }
4836
+ return effectiveFloor;
4650
4837
  }
4651
4838
  async resolveQuoteFloor(swapId, options) {
4652
4839
  if (options?.minAcceptableAmount !== void 0) {
@@ -4682,7 +4869,13 @@ var ArkadeSwaps = class _ArkadeSwaps {
4682
4869
  }
4683
4870
  }
4684
4871
  validateQuote(amount, effectiveFloor) {
4685
- if (!(amount > 0)) {
4872
+ if (!Number.isSafeInteger(amount)) {
4873
+ throw new QuoteRejectedError({
4874
+ reason: "non_safe_integer",
4875
+ quotedAmount: amount
4876
+ });
4877
+ }
4878
+ if (amount <= 0) {
4686
4879
  throw new QuoteRejectedError({
4687
4880
  reason: "non_positive",
4688
4881
  quotedAmount: amount
@@ -4865,20 +5058,7 @@ var ArkadeSwaps = class _ArkadeSwaps {
4865
5058
  onchainAmount: amount,
4866
5059
  lockupAddress,
4867
5060
  refundPublicKey: serverPublicKey,
4868
- timeoutBlockHeights: timeoutBlockHeights ?? {
4869
- refund: extractTimeLockFromLeafOutput(
4870
- tree.refundWithoutBoltzLeaf?.output ?? ""
4871
- ),
4872
- unilateralClaim: extractTimeLockFromLeafOutput(
4873
- tree.unilateralClaimLeaf?.output ?? ""
4874
- ),
4875
- unilateralRefund: extractTimeLockFromLeafOutput(
4876
- tree.unilateralRefundLeaf?.output ?? ""
4877
- ),
4878
- unilateralRefundWithoutReceiver: extractTimeLockFromLeafOutput(
4879
- tree.unilateralRefundWithoutBoltzLeaf?.output ?? ""
4880
- )
4881
- }
5061
+ timeoutBlockHeights: resolveVhtlcTimeouts(tree, timeoutBlockHeights)
4882
5062
  },
4883
5063
  status,
4884
5064
  type: "reverse",
@@ -4911,26 +5091,20 @@ var ArkadeSwaps = class _ArkadeSwaps {
4911
5091
  address: lockupAddress,
4912
5092
  expectedAmount: amount,
4913
5093
  claimPublicKey: serverPublicKey,
4914
- timeoutBlockHeights: timeoutBlockHeights ?? {
4915
- refund: extractTimeLockFromLeafOutput(
4916
- tree.refundWithoutBoltzLeaf?.output ?? ""
4917
- ),
4918
- unilateralClaim: extractTimeLockFromLeafOutput(
4919
- tree.unilateralClaimLeaf?.output ?? ""
4920
- ),
4921
- unilateralRefund: extractTimeLockFromLeafOutput(
4922
- tree.unilateralRefundLeaf?.output ?? ""
4923
- ),
4924
- unilateralRefundWithoutReceiver: extractTimeLockFromLeafOutput(
4925
- tree.unilateralRefundWithoutBoltzLeaf?.output ?? ""
4926
- )
4927
- }
5094
+ timeoutBlockHeights: resolveVhtlcTimeouts(tree, timeoutBlockHeights)
4928
5095
  }
4929
5096
  });
4930
5097
  } else if (isRestoredChainSwap(swap)) {
4931
5098
  const refundDetails = swap.refundDetails;
4932
5099
  if (!refundDetails) continue;
4933
- const { amount, lockupAddress, serverPublicKey, timeoutBlockHeight } = refundDetails;
5100
+ const {
5101
+ amount,
5102
+ lockupAddress,
5103
+ serverPublicKey,
5104
+ timeoutBlockHeight,
5105
+ tree,
5106
+ timeoutBlockHeights
5107
+ } = refundDetails;
4934
5108
  chainSwaps.push({
4935
5109
  id,
4936
5110
  type: "chain",
@@ -4956,7 +5130,8 @@ var ArkadeSwaps = class _ArkadeSwaps {
4956
5130
  amount,
4957
5131
  lockupAddress,
4958
5132
  serverPublicKey,
4959
- timeoutBlockHeight
5133
+ timeoutBlockHeight,
5134
+ timeouts: resolveVhtlcTimeouts(tree, timeoutBlockHeights)
4960
5135
  }
4961
5136
  }
4962
5137
  });
@@ -1,7 +1,7 @@
1
- import { I as IArkadeSwaps, A as ArkadeSwaps, Q as QuoteSwapOptions, V as VhtlcTimeouts } from '../arkade-swaps-9M7FRuq1.cjs';
2
- import { n as SwapManagerClient, C as CreateLightningInvoiceRequest, e as CreateLightningInvoiceResponse, S as SendLightningPaymentRequest, f as SendLightningPaymentResponse, c as BoltzSubmarineSwap, b as BoltzReverseSwap, g as SubmarineRefundOutcome, h as SubmarineRecoveryInfo, i as SubmarineRecoveryResult, F as FeesResponse, a as BoltzChainSwap, k as ArkToBtcResponse, l as BtcToArkResponse, d as Chain, j as ChainFeesResponse, L as LimitsResponse, G as GetSwapStatusResponse, B as BoltzSwap } from '../types-CrKkVzBB.cjs';
3
- import { E as ExpoArkadeSwapsConfig } from '../swapsPollProcessor-CuITxZie.cjs';
4
- export { D as DefineSwapBackgroundTaskOptions, b as ExpoArkadeLightningConfig, c as ExpoSwapBackgroundConfig, P as PersistedSwapBackgroundConfig, S as SWAP_POLL_TASK_TYPE, a as SwapTaskDependencies } from '../swapsPollProcessor-CuITxZie.cjs';
1
+ import { I as IArkadeSwaps, A as ArkadeSwaps, Q as QuoteSwapOptions, V as VhtlcTimeouts } from '../arkade-swaps-CfMets16.cjs';
2
+ import { o as SwapManagerClient, C as CreateLightningInvoiceRequest, e as CreateLightningInvoiceResponse, S as SendLightningPaymentRequest, f as SendLightningPaymentResponse, c as BoltzSubmarineSwap, b as BoltzReverseSwap, g as SubmarineRefundOutcome, h as SubmarineRecoveryInfo, i as SubmarineRecoveryResult, F as FeesResponse, a as BoltzChainSwap, k as ArkToBtcResponse, m as ChainArkRefundOutcome, l as BtcToArkResponse, d as Chain, j as ChainFeesResponse, L as LimitsResponse, G as GetSwapStatusResponse, B as BoltzSwap } from '../types--axEWA8c.cjs';
3
+ import { E as ExpoArkadeSwapsConfig } from '../swapsPollProcessor-BpAqG0V6.cjs';
4
+ export { D as DefineSwapBackgroundTaskOptions, b as ExpoArkadeLightningConfig, c as ExpoSwapBackgroundConfig, P as PersistedSwapBackgroundConfig, S as SWAP_POLL_TASK_TYPE, a as SwapTaskDependencies } from '../swapsPollProcessor-BpAqG0V6.cjs';
5
5
  import { ArkInfo, Identity, ArkTxInput, VHTLC } from '@arkade-os/sdk';
6
6
  import { TransactionOutput } from '@scure/btc-signer/psbt.js';
7
7
  import '@arkade-os/sdk/worker/expo';
@@ -116,7 +116,7 @@ declare class ExpoArkadeSwaps implements IArkadeSwaps {
116
116
  txid: string;
117
117
  }>;
118
118
  claimBtc(pendingSwap: BoltzChainSwap): Promise<void>;
119
- refundArk(pendingSwap: BoltzChainSwap): Promise<void>;
119
+ refundArk(pendingSwap: BoltzChainSwap): Promise<ChainArkRefundOutcome>;
120
120
  btcToArk(args: {
121
121
  feeSatsPerByte?: number;
122
122
  senderLockAmount?: number;
@@ -1,7 +1,7 @@
1
- import { I as IArkadeSwaps, A as ArkadeSwaps, Q as QuoteSwapOptions, V as VhtlcTimeouts } from '../arkade-swaps-DNsyWeFr.js';
2
- import { n as SwapManagerClient, C as CreateLightningInvoiceRequest, e as CreateLightningInvoiceResponse, S as SendLightningPaymentRequest, f as SendLightningPaymentResponse, c as BoltzSubmarineSwap, b as BoltzReverseSwap, g as SubmarineRefundOutcome, h as SubmarineRecoveryInfo, i as SubmarineRecoveryResult, F as FeesResponse, a as BoltzChainSwap, k as ArkToBtcResponse, l as BtcToArkResponse, d as Chain, j as ChainFeesResponse, L as LimitsResponse, G as GetSwapStatusResponse, B as BoltzSwap } from '../types-CrKkVzBB.js';
3
- import { E as ExpoArkadeSwapsConfig } from '../swapsPollProcessor-CEgeGlbP.js';
4
- export { D as DefineSwapBackgroundTaskOptions, b as ExpoArkadeLightningConfig, c as ExpoSwapBackgroundConfig, P as PersistedSwapBackgroundConfig, S as SWAP_POLL_TASK_TYPE, a as SwapTaskDependencies } from '../swapsPollProcessor-CEgeGlbP.js';
1
+ import { I as IArkadeSwaps, A as ArkadeSwaps, Q as QuoteSwapOptions, V as VhtlcTimeouts } from '../arkade-swaps-BXAD1s8j.js';
2
+ import { o as SwapManagerClient, C as CreateLightningInvoiceRequest, e as CreateLightningInvoiceResponse, S as SendLightningPaymentRequest, f as SendLightningPaymentResponse, c as BoltzSubmarineSwap, b as BoltzReverseSwap, g as SubmarineRefundOutcome, h as SubmarineRecoveryInfo, i as SubmarineRecoveryResult, F as FeesResponse, a as BoltzChainSwap, k as ArkToBtcResponse, m as ChainArkRefundOutcome, l as BtcToArkResponse, d as Chain, j as ChainFeesResponse, L as LimitsResponse, G as GetSwapStatusResponse, B as BoltzSwap } from '../types--axEWA8c.js';
3
+ import { E as ExpoArkadeSwapsConfig } from '../swapsPollProcessor-DFVOAy_-.js';
4
+ export { D as DefineSwapBackgroundTaskOptions, b as ExpoArkadeLightningConfig, c as ExpoSwapBackgroundConfig, P as PersistedSwapBackgroundConfig, S as SWAP_POLL_TASK_TYPE, a as SwapTaskDependencies } from '../swapsPollProcessor-DFVOAy_-.js';
5
5
  import { ArkInfo, Identity, ArkTxInput, VHTLC } from '@arkade-os/sdk';
6
6
  import { TransactionOutput } from '@scure/btc-signer/psbt.js';
7
7
  import '@arkade-os/sdk/worker/expo';
@@ -116,7 +116,7 @@ declare class ExpoArkadeSwaps implements IArkadeSwaps {
116
116
  txid: string;
117
117
  }>;
118
118
  claimBtc(pendingSwap: BoltzChainSwap): Promise<void>;
119
- refundArk(pendingSwap: BoltzChainSwap): Promise<void>;
119
+ refundArk(pendingSwap: BoltzChainSwap): Promise<ChainArkRefundOutcome>;
120
120
  btcToArk(args: {
121
121
  feeSatsPerByte?: number;
122
122
  senderLockAmount?: number;
@@ -1,9 +1,9 @@
1
1
  import {
2
2
  SWAP_POLL_TASK_TYPE
3
- } from "../chunk-HNQDJOLM.js";
3
+ } from "../chunk-H6F67K2A.js";
4
4
  import {
5
5
  ArkadeSwaps
6
- } from "../chunk-SJ5SYSMK.js";
6
+ } from "../chunk-B4CYBKFJ.js";
7
7
  import "../chunk-SJQJQO7P.js";
8
8
 
9
9
  // src/expo/arkade-lightning.ts