@satora/swap 1.3.3 → 1.5.0

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
@@ -36,8 +36,12 @@ var bitcoin_reader_esplora_exports = {};
36
36
  __export(bitcoin_reader_esplora_exports, {
37
37
  DEFAULT_ESPLORA_URLS: () => DEFAULT_ESPLORA_URLS,
38
38
  esploraReader: () => esploraReader,
39
- htlcFactsFromEsploraTxs: () => htlcFactsFromEsploraTxs
39
+ htlcFactsFromEsploraTxs: () => htlcFactsFromEsploraTxs,
40
+ resolveMinConfirmations: () => resolveMinConfirmations
40
41
  });
42
+ function resolveMinConfirmations(source) {
43
+ return (typeof source === "function" ? source() : source) ?? 0;
44
+ }
41
45
  function htlcFactsFromEsploraTxs(txs, address, opts) {
42
46
  for (const tx of txs) {
43
47
  const spend = tx.vin.find(
@@ -80,11 +84,10 @@ function esploraReader(esploraUrls, fetchImpl = fetch, policy) {
80
84
  const bases = (Array.isArray(esploraUrls) ? esploraUrls : [esploraUrls]).map(
81
85
  (url) => url.replace(/\/+$/, "")
82
86
  );
83
- const minConf = policy?.minConfirmations ?? 0;
84
87
  let start = 0;
85
88
  return {
86
89
  async getHtlcFacts(address, minConfirmations) {
87
- const required = minConfirmations ?? minConf;
90
+ const required = minConfirmations ?? resolveMinConfirmations(policy?.minConfirmations);
88
91
  const from = start++ % bases.length;
89
92
  let lastError;
90
93
  for (let i = 0; i < bases.length; i++) {
@@ -145,7 +148,6 @@ function scriptHashOf(script) {
145
148
  }
146
149
  function electrumReader(client, opts) {
147
150
  const network = opts?.network ?? "mainnet";
148
- const defaultMinConf = opts?.minConfirmations ?? 0;
149
151
  const fallback2 = opts?.fallback;
150
152
  const scriptFor = (address) => btc.OutScript.encode(
151
153
  btc.Address(toBtcSignerNetwork(network)).decode(address)
@@ -231,7 +233,7 @@ function electrumReader(client, opts) {
231
233
  }
232
234
  return {
233
235
  async getHtlcFacts(address, minConfirmations) {
234
- const required = minConfirmations ?? defaultMinConf;
236
+ const required = minConfirmations ?? resolveMinConfirmations(opts?.minConfirmations);
235
237
  try {
236
238
  return await getHtlcFactsElectrum(address, required);
237
239
  } catch (error) {
@@ -273,6 +275,7 @@ var init_bitcoin_reader_electrum = __esm({
273
275
  import_sha22 = require("@noble/hashes/sha2.js");
274
276
  import_base5 = require("@scure/base");
275
277
  btc = __toESM(require("@scure/btc-signer"), 1);
278
+ init_bitcoin_reader_esplora();
276
279
  REGTEST_NETWORK = {
277
280
  bech32: "bcrt",
278
281
  pubKeyHash: 111,
@@ -308,6 +311,7 @@ __export(src_exports, {
308
311
  htlcFactsFromEsploraTxs: () => htlcFactsFromEsploraTxs,
309
312
  htlcKey: () => htlcKey,
310
313
  htlcQueryKey: () => htlcQueryKey,
314
+ resolveMinConfirmations: () => resolveMinConfirmations,
311
315
  swapToTracked: () => swapToTracked
312
316
  });
313
317
  module.exports = __toCommonJS(src_exports);
@@ -322,7 +326,8 @@ function deriveSwapActions(input) {
322
326
  serverChainNow,
323
327
  clientRefundLocktime,
324
328
  serverRefundLocktime,
325
- clientFunds = true
329
+ clientFunds = true,
330
+ serverFunds = true
326
331
  } = input;
327
332
  switch (status) {
328
333
  case "pending": {
@@ -407,6 +412,36 @@ function deriveSwapActions(input) {
407
412
  return { recommended: "wait", actions: [wait, refund] };
408
413
  }
409
414
  case "serverfunded": {
415
+ if (!serverFunds) {
416
+ const refundUnlocked2 = clientChainNow >= clientRefundLocktime;
417
+ if (refundUnlocked2) {
418
+ const refund3 = {
419
+ id: "refund_unilateral",
420
+ recommended: true,
421
+ automation: "confirm",
422
+ reason: "Reclaim your deposit."
423
+ };
424
+ return { recommended: "refund_unilateral", actions: [refund3] };
425
+ }
426
+ const wait2 = {
427
+ id: "wait",
428
+ waitingOn: "server_funding",
429
+ recommended: true,
430
+ automation: "auto",
431
+ reason: "Waiting for the Lightning payment to settle."
432
+ };
433
+ const refund2 = {
434
+ id: "refund_unilateral",
435
+ recommended: false,
436
+ automation: "confirm",
437
+ reason: "Refund becomes available once the timelock passes.",
438
+ blockedBy: {
439
+ kind: "timelock_not_expired",
440
+ message: "The refund timelock has not passed yet."
441
+ }
442
+ };
443
+ return { recommended: "wait", actions: [wait2, refund2] };
444
+ }
410
445
  const claimWindowClosed = serverChainNow >= serverRefundLocktime;
411
446
  if (!claimWindowClosed) {
412
447
  const claim = {
@@ -1081,7 +1116,8 @@ function refQueryKey(ref) {
1081
1116
  var AVG_BLOCK_MS = {
1082
1117
  1: 12e3,
1083
1118
  137: 2100,
1084
- 42161: 250
1119
+ 42161: 250,
1120
+ 30: 3e4
1085
1121
  };
1086
1122
  var SCAN_MARGIN_BLOCKS = 1000n;
1087
1123
  var EvmContractManager = class _EvmContractManager {
@@ -1337,7 +1373,9 @@ var DEFAULT_EVM_RPCS = {
1337
1373
  "https://arb1.arbitrum.io/rpc",
1338
1374
  "https://arbitrum.drpc.org",
1339
1375
  "https://arbitrum-one-rpc.publicnode.com"
1340
- ]
1376
+ ],
1377
+ // Rootstock: the public node, HTTP only (no WebSocket endpoint exists).
1378
+ 30: ["https://public-node.rsk.co"]
1341
1379
  };
1342
1380
  var SWAP_CREATED = (0, import_viem.parseAbiItem)(
1343
1381
  "event SwapCreated(bytes32 indexed preimageHash, address indexed refundAddress, address indexed claimAddress, address token, uint256 amount, uint256 timelock, bytes32 key)"
@@ -1370,25 +1408,52 @@ function activeKey(q) {
1370
1408
  }
1371
1409
  });
1372
1410
  }
1373
- function evmReaderFromClient(client) {
1411
+ function evmReaderFromClient(client, options = {}) {
1412
+ const { maxBlockRange } = options;
1413
+ async function getLogs(filter, fromBlock) {
1414
+ if (maxBlockRange === void 0) {
1415
+ return client.request({
1416
+ method: "eth_getLogs",
1417
+ params: [{ ...filter, fromBlock: (0, import_viem.numberToHex)(fromBlock) }]
1418
+ });
1419
+ }
1420
+ const latest = (await client.getBlock()).number;
1421
+ if (latest === null) {
1422
+ throw new Error("latest block has no number");
1423
+ }
1424
+ const logs = [];
1425
+ for (let from = fromBlock; from <= latest; from += maxBlockRange) {
1426
+ const to = from + maxBlockRange - 1n < latest ? from + maxBlockRange - 1n : latest;
1427
+ logs.push(
1428
+ ...await client.request({
1429
+ method: "eth_getLogs",
1430
+ params: [
1431
+ {
1432
+ ...filter,
1433
+ fromBlock: (0, import_viem.numberToHex)(from),
1434
+ toBlock: (0, import_viem.numberToHex)(to)
1435
+ }
1436
+ ]
1437
+ })
1438
+ );
1439
+ }
1440
+ return logs;
1441
+ }
1374
1442
  return {
1375
1443
  async getHtlcEventsBatch(queries, fromBlock = 0n) {
1376
1444
  const results = /* @__PURE__ */ new Map();
1377
1445
  if (queries.length === 0) return results;
1378
1446
  for (const q of queries) results.set(htlcQueryKey(q), []);
1379
- const logs = await client.request({
1380
- method: "eth_getLogs",
1381
- params: [
1382
- {
1383
- address: unique(queries.map((q) => q.htlc)),
1384
- topics: [
1385
- HTLC_EVENT_TOPICS,
1386
- unique(queries.map((q) => q.preimageHash))
1387
- ],
1388
- fromBlock: (0, import_viem.numberToHex)(fromBlock)
1389
- }
1390
- ]
1391
- });
1447
+ const logs = await getLogs(
1448
+ {
1449
+ address: unique(queries.map((q) => q.htlc)),
1450
+ topics: [
1451
+ HTLC_EVENT_TOPICS,
1452
+ unique(queries.map((q) => q.preimageHash))
1453
+ ]
1454
+ },
1455
+ fromBlock
1456
+ );
1392
1457
  const grouped = /* @__PURE__ */ new Map();
1393
1458
  for (const log of logs) {
1394
1459
  const decoded = tryDecode(log);
@@ -1504,12 +1569,15 @@ function toHtlcEvent(decoded, query) {
1504
1569
  function unique(values) {
1505
1570
  return [...new Set(values)];
1506
1571
  }
1507
- function createEvmRpcReader(rpcUrls) {
1572
+ function createEvmRpcReader(rpcUrls, options = {}) {
1508
1573
  const urls = Array.isArray(rpcUrls) ? rpcUrls : [rpcUrls];
1509
1574
  const transport = urls.length > 1 ? (0, import_viem.fallback)(urls.map((url) => (0, import_viem.http)(url))) : (0, import_viem.http)(urls[0]);
1510
1575
  const client = (0, import_viem.createPublicClient)({ transport });
1511
- return evmReaderFromClient(client);
1576
+ return evmReaderFromClient(client, options);
1512
1577
  }
1578
+ var EVM_LOG_RANGE_LIMITS = {
1579
+ 30: 2000n
1580
+ };
1513
1581
  function defaultEvmReaders(overrides) {
1514
1582
  const chainIds = /* @__PURE__ */ new Set([
1515
1583
  ...Object.keys(DEFAULT_EVM_RPCS).map(Number),
@@ -1520,7 +1588,13 @@ function defaultEvmReaders(overrides) {
1520
1588
  const override = overrides?.[chainId];
1521
1589
  const defaults = DEFAULT_EVM_RPCS[chainId] ?? [];
1522
1590
  const urls = override ? [override, ...defaults] : defaults;
1523
- if (urls.length > 0) readers.set(chainId, createEvmRpcReader(urls));
1591
+ if (urls.length > 0)
1592
+ readers.set(
1593
+ chainId,
1594
+ createEvmRpcReader(urls, {
1595
+ maxBlockRange: EVM_LOG_RANGE_LIMITS[chainId]
1596
+ })
1597
+ );
1524
1598
  }
1525
1599
  return readers;
1526
1600
  }
@@ -1617,8 +1691,10 @@ var HintTracker = class {
1617
1691
  serverChainNow: now,
1618
1692
  clientRefundLocktime: swap.clientRefundLocktime,
1619
1693
  serverRefundLocktime: swap.serverRefundLocktime,
1620
- // Pay-on-Lightning swaps have no client-funded on-chain leg.
1621
- clientFunds: swap.clientHtlc !== void 0
1694
+ // Pay-on-Lightning swaps have no client-funded on-chain leg;
1695
+ // receive-on-Lightning swaps have no server-funded one.
1696
+ clientFunds: swap.clientHtlc !== void 0,
1697
+ serverFunds: swap.serverHtlc !== void 0
1622
1698
  });
1623
1699
  const previous = this.#lastActions.get(swap.swapId);
1624
1700
  if (!force && previous && JSON.stringify(previous) === JSON.stringify(actions))
@@ -2241,6 +2317,29 @@ function swapToTracked(stored) {
2241
2317
  // no on-chain client leg
2242
2318
  serverRefundLocktime: ms(r.vhtlc_refund_locktime)
2243
2319
  };
2320
+ // Client funds the EVM HTLC (evm_expected_sats of the BTC-pegged token);
2321
+ // the server pays the Lightning invoice off-chain and claims the HTLC —
2322
+ // no server leg to watch.
2323
+ case "evm_to_lightning":
2324
+ return {
2325
+ swapId: r.id,
2326
+ clientHtlc: evmLeg({
2327
+ chainId: r.evm_chain_id,
2328
+ htlc: r.evm_htlc_address,
2329
+ hashLock: r.hash_lock,
2330
+ claimAddress: r.server_evm_address,
2331
+ // the server claims the client's EVM HTLC
2332
+ expectedSats: r.evm_expected_sats,
2333
+ token: r.wbtc_address,
2334
+ sender: r.evm_coordinator_address,
2335
+ // the coordinator locks on the client's behalf
2336
+ timelockSec: r.evm_refund_locktime,
2337
+ createdAt: r.created_at
2338
+ }),
2339
+ clientRefundLocktime: ms(r.evm_refund_locktime),
2340
+ serverRefundLocktime: 0
2341
+ // no on-chain server leg
2342
+ };
2244
2343
  // Client funds the Arkade VHTLC (source_amount); the server pays the
2245
2344
  // Lightning invoice off-chain and claims the VHTLC — no server leg to
2246
2345
  // watch.
@@ -2262,21 +2361,38 @@ function swapToTracked(stored) {
2262
2361
  }
2263
2362
 
2264
2363
  // src/tracker/swap-tracker.ts
2364
+ var CLAIM_SUBMITTED = /* @__PURE__ */ new Set([
2365
+ "clientredeeming",
2366
+ "clientredeemed",
2367
+ "serverredeemed"
2368
+ ]);
2369
+ var DEFAULT_AT_RISK_RECONCILE_INTERVAL_MS = 6e4;
2370
+ var RETRY_UNREGISTERED_INTERVAL_MS = 6e4;
2265
2371
  var SwapTracker = class {
2266
2372
  #managers;
2267
2373
  #swaps = /* @__PURE__ */ new Map();
2374
+ /**
2375
+ * Swaps whose startup registration failed (a chain read that errored).
2376
+ * Retried every minute; dropping them would leave an active swap
2377
+ * unwatched for the whole session with no claim or refund surfaced.
2378
+ */
2379
+ #unregistered = /* @__PURE__ */ new Map();
2268
2380
  /** Last actions emitted per swap — for dedupe and the subscribe-time snapshot. */
2269
2381
  #lastActions = /* @__PURE__ */ new Map();
2382
+ /** The latest server status hint per swap, for {@link #withClaimHint}. */
2383
+ #hintStatuses = /* @__PURE__ */ new Map();
2270
2384
  #subscribers = /* @__PURE__ */ new Set();
2271
2385
  #refreshIntervalMs;
2272
2386
  #atRiskReconcileIntervalMs;
2273
2387
  #lastAtRiskReconcileAt = 0;
2388
+ #lastRetryAt = 0;
2274
2389
  #eventUnsubs = [];
2275
2390
  #timer;
2276
2391
  constructor(managers, options) {
2277
2392
  this.#managers = managers;
2278
2393
  this.#refreshIntervalMs = options?.refreshIntervalMs ?? 0;
2279
- this.#atRiskReconcileIntervalMs = options?.atRiskReconcileIntervalMs ?? 6e4;
2394
+ const atRisk = options?.atRiskReconcileIntervalMs ?? DEFAULT_AT_RISK_RECONCILE_INTERVAL_MS;
2395
+ this.#atRiskReconcileIntervalMs = typeof atRisk === "function" ? atRisk : () => atRisk;
2280
2396
  }
2281
2397
  /**
2282
2398
  * Whether every on-chain leg of `swap` is observable — i.e. the tracker can
@@ -2299,7 +2415,22 @@ var SwapTracker = class {
2299
2415
  continue;
2300
2416
  }
2301
2417
  this.#swaps.set(swap.swapId, swap);
2302
- for (const leg of legsOf(swap)) await this.#managerFor(leg).register(leg);
2418
+ const registered = [];
2419
+ try {
2420
+ for (const leg of legsOf(swap)) {
2421
+ await this.#managerFor(leg).register(leg);
2422
+ registered.push(leg);
2423
+ }
2424
+ } catch (error) {
2425
+ this.#swaps.delete(swap.swapId);
2426
+ for (const leg of registered)
2427
+ void this.#managerFor(leg).unregister(leg);
2428
+ this.#unregistered.set(swap.swapId, swap);
2429
+ console.warn(
2430
+ `SwapTracker: swap ${swap.swapId} not tracked yet, registering its legs failed (will retry):`,
2431
+ error
2432
+ );
2433
+ }
2303
2434
  }
2304
2435
  for (const manager of new Set(this.#managers.values())) {
2305
2436
  this.#eventUnsubs.push(manager.onEvent(() => this.#recomputeAll()));
@@ -2307,6 +2438,7 @@ var SwapTracker = class {
2307
2438
  await this.#refreshManagers();
2308
2439
  this.#recomputeAll();
2309
2440
  this.#lastAtRiskReconcileAt = Date.now();
2441
+ this.#lastRetryAt = this.#lastAtRiskReconcileAt;
2310
2442
  if (this.#refreshIntervalMs > 0)
2311
2443
  this.#timer = setInterval(
2312
2444
  () => void this.#tick(),
@@ -2356,7 +2488,11 @@ var SwapTracker = class {
2356
2488
  */
2357
2489
  async #tick() {
2358
2490
  const now = Date.now();
2359
- if (now - this.#lastAtRiskReconcileAt >= this.#atRiskReconcileIntervalMs) {
2491
+ if (now - this.#lastRetryAt >= RETRY_UNREGISTERED_INTERVAL_MS) {
2492
+ this.#lastRetryAt = now;
2493
+ await this.#retryUnregistered();
2494
+ }
2495
+ if (now - this.#lastAtRiskReconcileAt >= this.#atRiskReconcileIntervalMs()) {
2360
2496
  this.#lastAtRiskReconcileAt = now;
2361
2497
  const legs = [...this.#swaps.values()].filter((swap) => this.#isAtRisk(swap)).flatMap((swap) => legsOf(swap));
2362
2498
  await Promise.all(
@@ -2369,6 +2505,25 @@ var SwapTracker = class {
2369
2505
  }
2370
2506
  this.#recomputeAll();
2371
2507
  }
2508
+ /**
2509
+ * Second attempt at swaps whose startup registration failed, on its own
2510
+ * cadence so a slow at-risk interval can't delay it. `track` is idempotent
2511
+ * and rolls back on failure, so a chain that is still down just leaves the
2512
+ * swap here for the next round.
2513
+ */
2514
+ async #retryUnregistered() {
2515
+ for (const [swapId, swap] of this.#unregistered) {
2516
+ try {
2517
+ await this.track(swap);
2518
+ this.#unregistered.delete(swapId);
2519
+ } catch (error) {
2520
+ console.warn(
2521
+ `SwapTracker: retrying registration of ${swapId} failed:`,
2522
+ error
2523
+ );
2524
+ }
2525
+ }
2526
+ }
2372
2527
  /**
2373
2528
  * Whether a swap needs chain-verified monitoring independent of the server:
2374
2529
  * its client leg holds (or held) funds on-chain, so knowing when a refund
@@ -2428,6 +2583,7 @@ var SwapTracker = class {
2428
2583
  async applyHint(swapId, opts) {
2429
2584
  const swap = this.#swaps.get(swapId);
2430
2585
  if (!swap) return;
2586
+ if (opts?.status !== void 0) this.#hintStatuses.set(swapId, opts.status);
2431
2587
  const legs = legsOf(swap);
2432
2588
  const results = await Promise.allSettled(
2433
2589
  legs.map((leg) => this.#managerFor(leg).reconcile(leg))
@@ -2464,6 +2620,7 @@ var SwapTracker = class {
2464
2620
  for (const leg of legsOf(swap))
2465
2621
  void this.#managerFor(leg).unregister(leg);
2466
2622
  this.#swaps.clear();
2623
+ this.#unregistered.clear();
2467
2624
  this.#subscribers.clear();
2468
2625
  }
2469
2626
  #managerFor(ref) {
@@ -2475,6 +2632,28 @@ var SwapTracker = class {
2475
2632
  #recomputeAll() {
2476
2633
  for (const swap of this.#swaps.values()) this.#recompute(swap);
2477
2634
  }
2635
+ /**
2636
+ * A server hint that the claim was submitted overrides a chain view that
2637
+ * still says "claim now". The claim is relayed by the server, so once it
2638
+ * reports the redeem in flight, another claim can only be refused; and a
2639
+ * chain that cannot be read (no `eth_getLogs` on the public Rootstock RPCs)
2640
+ * keeps its stale pre-claim observation for good. The chain still wins once
2641
+ * it moves: a fresh `spent_claim` derives past the hint on its own, and a
2642
+ * refund observed on chain is not overridden.
2643
+ *
2644
+ * The override lasts only while the server's claim window is open. Once the
2645
+ * server can refund its leg, the chain view is what protects the client: the
2646
+ * `serverfunded` rules never claim then and lead to the client's own refund
2647
+ * at its timelock. A claim that really landed shows up on chain long before
2648
+ * that; a hint that never did must not hide the refund.
2649
+ */
2650
+ #withClaimHint(swap, derived, serverChainNow) {
2651
+ const hint = this.#hintStatuses.get(swap.swapId);
2652
+ if (hint === void 0 || !CLAIM_SUBMITTED.has(hint)) return derived;
2653
+ if (derived !== "serverfunded") return derived;
2654
+ const claimWindowOpen = serverChainNow < swap.serverRefundLocktime;
2655
+ return claimWindowOpen ? hint : derived;
2656
+ }
2478
2657
  #recompute(swap, force = false) {
2479
2658
  let clientHtlc;
2480
2659
  let serverHtlc;
@@ -2494,8 +2673,9 @@ var SwapTracker = class {
2494
2673
  if (serverHtlc === void 0 || now === void 0) return;
2495
2674
  serverChainNow = now;
2496
2675
  }
2497
- const status = deriveSwapStatus({ clientHtlc, serverHtlc });
2498
- if (status === void 0) return;
2676
+ const derived = deriveSwapStatus({ clientHtlc, serverHtlc });
2677
+ if (derived === void 0) return;
2678
+ const status = this.#withClaimHint(swap, derived, serverChainNow);
2499
2679
  const actions = {
2500
2680
  ...deriveSwapActions({
2501
2681
  status,
@@ -2503,8 +2683,10 @@ var SwapTracker = class {
2503
2683
  serverChainNow,
2504
2684
  clientRefundLocktime: swap.clientRefundLocktime,
2505
2685
  serverRefundLocktime: swap.serverRefundLocktime,
2506
- // Pay-on-Lightning swaps have no client-funded on-chain leg.
2507
- clientFunds: swap.clientHtlc !== void 0
2686
+ // Pay-on-Lightning swaps have no client-funded on-chain leg;
2687
+ // receive-on-Lightning swaps have no server-funded one.
2688
+ clientFunds: swap.clientHtlc !== void 0,
2689
+ serverFunds: swap.serverHtlc !== void 0
2508
2690
  }),
2509
2691
  // Expose the raw observations so consumers can render progress from
2510
2692
  // chain facts (which leg is funded/spent) even when the server is
@@ -2544,12 +2726,16 @@ var Client = class {
2544
2726
  #startPromise;
2545
2727
  /** Swap ids whose settled status was already re-fetched this session. */
2546
2728
  #settledSyncDone = /* @__PURE__ */ new Set();
2729
+ /** Read per chain read, so it can change without rebuilding the client. */
2730
+ #bitcoinMinConfirmations;
2547
2731
  constructor(...args) {
2548
2732
  if (args[0] instanceof import_lendaswap_sdk_pure2.Client) {
2549
2733
  this.#legacy = args[0];
2550
2734
  this.#tracking = args[1] ?? {
2551
2735
  enabled: false
2552
2736
  };
2737
+ assertMinConfirmations(this.#tracking.bitcoinMinConfirmations);
2738
+ this.#bitcoinMinConfirmations = this.#tracking.bitcoinMinConfirmations;
2553
2739
  } else {
2554
2740
  this.#legacy = new import_lendaswap_sdk_pure2.Client(
2555
2741
  ...args
@@ -2796,6 +2982,12 @@ var Client = class {
2796
2982
  );
2797
2983
  }
2798
2984
  /** Delegated to the legacy client (migration checkpoint). */
2985
+ createEvmToLightningSwap(...args) {
2986
+ return this.#trackAfterCreate(
2987
+ this.#legacy.createEvmToLightningSwap(...args)
2988
+ );
2989
+ }
2990
+ /** Delegated to the legacy client (migration checkpoint). */
2799
2991
  createEvmToArkadeSwapGeneric(...args) {
2800
2992
  return this.#trackAfterCreate(
2801
2993
  this.#legacy.createEvmToArkadeSwapGeneric(...args)
@@ -2854,6 +3046,22 @@ var Client = class {
2854
3046
  return this.#legacy.hasReceivedVtxo(...args);
2855
3047
  }
2856
3048
  // --- Satora-native features go below ---
3049
+ /** Confirmations a server-funded Bitcoin HTLC needs before it is claimable. */
3050
+ getBitcoinMinConfirmations() {
3051
+ return this.#bitcoinMinConfirmations;
3052
+ }
3053
+ /**
3054
+ * At `0` the claim goes out against a mempool funding, trusting the funder
3055
+ * not to replace it once the claim reveals the preimage.
3056
+ *
3057
+ * Only the chain monitors read a depth, so this does nothing unless the
3058
+ * client was built with {@link ClientBuilder.withChainVerifiedTracking}.
3059
+ * Takes effect on subsequent reads, including swaps already tracked.
3060
+ */
3061
+ setBitcoinMinConfirmations(minConfirmations) {
3062
+ assertMinConfirmations(minConfirmations);
3063
+ this.#bitcoinMinConfirmations = minConfirmations;
3064
+ }
2857
3065
  /**
2858
3066
  * Start observing the user's active swaps and deriving each one's next action.
2859
3067
  *
@@ -2882,8 +3090,18 @@ var Client = class {
2882
3090
  async #doStartTracking() {
2883
3091
  try {
2884
3092
  const chainVerified = this.#tracking.chainVerified || this.#tracking.managers !== void 0;
3093
+ if (!chainVerified && (this.#bitcoinMinConfirmations ?? 0) > 0)
3094
+ console.warn(
3095
+ "Client: bitcoinMinConfirmations is set but tracking follows server hints, which carry no depth. Build with ClientBuilder.withChainVerifiedTracking() for the depth to gate the claim."
3096
+ );
2885
3097
  const tracker = chainVerified ? new SwapTracker(await this.#ensureManagers(), {
2886
- refreshIntervalMs: this.#tracking.refreshIntervalMs ?? 5e3
3098
+ refreshIntervalMs: this.#tracking.refreshIntervalMs ?? 5e3,
3099
+ // Depth is only re-derived on a chain read, and nothing pushes on a
3100
+ // new block (an Electrum scripthash status doesn't change once the
3101
+ // funding is in a block), so a deep policy would otherwise sit out
3102
+ // the default interval after the block that satisfied it. Read per
3103
+ // tick, like the depth itself, so a setter call after start counts.
3104
+ atRiskReconcileIntervalMs: () => (this.#bitcoinMinConfirmations ?? 0) > 1 ? 15e3 : DEFAULT_AT_RISK_RECONCILE_INTERVAL_MS
2887
3105
  }) : new HintTracker({
2888
3106
  fetchStatus: async (swapId) => (await this.getSwap(swapId, { updateStorage: true })).status,
2889
3107
  refreshIntervalMs: this.#tracking.refreshIntervalMs ?? 5e3
@@ -3069,8 +3287,7 @@ var Client = class {
3069
3287
  evmRpcUrls,
3070
3288
  esploraUrl,
3071
3289
  electrumWsUrl,
3072
- bitcoinNetwork,
3073
- bitcoinMinConfirmations
3290
+ bitcoinNetwork
3074
3291
  } = this.#tracking;
3075
3292
  const chainTime = async () => (await this.getMtp()).mtp * 1e3;
3076
3293
  const arkadeUrl = arkadeServerUrl ?? defaultArkadeServerUrl("bitcoin");
@@ -3091,7 +3308,7 @@ var Client = class {
3091
3308
  electrumWsUrl: electrumWsUrl ?? import_lendaswap_sdk_pure2.DEFAULT_ELECTRUM_WS_URLS[network],
3092
3309
  network,
3093
3310
  chainTime,
3094
- minConfirmations: bitcoinMinConfirmations
3311
+ minConfirmations: () => this.#bitcoinMinConfirmations
3095
3312
  })
3096
3313
  );
3097
3314
  this.#managers = managers;
@@ -3212,9 +3429,11 @@ var ClientBuilder = class {
3212
3429
  * funding hits the mempool, which TRUSTS the funder not to double-spend it
3213
3430
  * (claiming publishes the preimage, so a funder that then replaced its
3214
3431
  * funding could take both legs). Set `1` (or more) for a block-depth policy
3215
- * that doesn't rely on the funder's good behaviour.
3432
+ * that doesn't rely on the funder's good behaviour. Read only by the chain
3433
+ * monitors, so it needs {@link withChainVerifiedTracking} to have any effect.
3216
3434
  */
3217
3435
  withBitcoinMinConfirmations(minConfirmations) {
3436
+ assertMinConfirmations(minConfirmations);
3218
3437
  this.#bitcoinMinConfirmations = minConfirmations;
3219
3438
  return this;
3220
3439
  }
@@ -3271,6 +3490,13 @@ var ClientBuilder = class {
3271
3490
  });
3272
3491
  }
3273
3492
  };
3493
+ function assertMinConfirmations(value) {
3494
+ if (value === void 0) return;
3495
+ if (!Number.isInteger(value) || value < 0)
3496
+ throw new Error(
3497
+ `bitcoinMinConfirmations must be a non-negative integer, got ${value}`
3498
+ );
3499
+ }
3274
3500
  function legacyBaseUrl(legacy) {
3275
3501
  try {
3276
3502
  return legacy.baseUrl;
@@ -3319,6 +3545,7 @@ init_bitcoin_reader_esplora();
3319
3545
  htlcFactsFromEsploraTxs,
3320
3546
  htlcKey,
3321
3547
  htlcQueryKey,
3548
+ resolveMinConfirmations,
3322
3549
  swapToTracked,
3323
3550
  ...require("@lendasat/lendaswap-sdk-pure")
3324
3551
  });