@satora/swap 1.4.0 → 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.
@@ -0,0 +1,8 @@
1
+ import {
2
+ electrumReader
3
+ } from "./chunk-INWVLX4A.js";
4
+ import "./chunk-LSCRTQOS.js";
5
+ export {
6
+ electrumReader
7
+ };
8
+ //# sourceMappingURL=bitcoin-reader-electrum-Z2YCNDLB.js.map
@@ -0,0 +1,13 @@
1
+ import {
2
+ DEFAULT_ESPLORA_URLS,
3
+ esploraReader,
4
+ htlcFactsFromEsploraTxs,
5
+ resolveMinConfirmations
6
+ } from "./chunk-LSCRTQOS.js";
7
+ export {
8
+ DEFAULT_ESPLORA_URLS,
9
+ esploraReader,
10
+ htlcFactsFromEsploraTxs,
11
+ resolveMinConfirmations
12
+ };
13
+ //# sourceMappingURL=bitcoin-reader-esplora-B2BDEI6Z.js.map
@@ -1,3 +1,7 @@
1
+ import {
2
+ resolveMinConfirmations
3
+ } from "./chunk-LSCRTQOS.js";
4
+
1
5
  // src/contracts/bitcoin-reader-electrum.ts
2
6
  import { sha256 } from "@noble/hashes/sha2.js";
3
7
  import { hex } from "@scure/base";
@@ -24,7 +28,6 @@ function scriptHashOf(script) {
24
28
  }
25
29
  function electrumReader(client, opts) {
26
30
  const network = opts?.network ?? "mainnet";
27
- const defaultMinConf = opts?.minConfirmations ?? 0;
28
31
  const fallback = opts?.fallback;
29
32
  const scriptFor = (address) => btc.OutScript.encode(
30
33
  btc.Address(toBtcSignerNetwork(network)).decode(address)
@@ -110,7 +113,7 @@ function electrumReader(client, opts) {
110
113
  }
111
114
  return {
112
115
  async getHtlcFacts(address, minConfirmations) {
113
- const required = minConfirmations ?? defaultMinConf;
116
+ const required = minConfirmations ?? resolveMinConfirmations(opts?.minConfirmations);
114
117
  try {
115
118
  return await getHtlcFactsElectrum(address, required);
116
119
  } catch (error) {
@@ -149,4 +152,4 @@ function electrumReader(client, opts) {
149
152
  export {
150
153
  electrumReader
151
154
  };
152
- //# sourceMappingURL=chunk-FTPIQSRW.js.map
155
+ //# sourceMappingURL=chunk-INWVLX4A.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../src/contracts/bitcoin-reader-electrum.ts"],"sourcesContent":["/**\n * An Electrum-backed {@link BitcoinChainReader} — reads HTLC facts from our own\n * Fulcrum over WebSocket instead of public esplora REST endpoints.\n *\n * Two advantages over the esplora reader:\n *\n * - **Freshness**: our Fulcrum sees a transaction the moment its bitcoind\n * accepts it, while public explorers index with a lag — the exact lag that\n * made a server-funding hint's targeted verify come back \"absent\" and park\n * the claim on the 60s at-risk poller.\n * - **Push**: implements the reader's optional `subscribe` capability via\n * `blockchain.scripthash.subscribe`, so the manager reconciles the moment\n * an HTLC address's history changes instead of waiting for a poll tick.\n *\n * Reads fall back to a wrapped reader (typically the esplora one) whenever the\n * Electrum server errors, so a Fulcrum outage degrades to today's behavior\n * rather than blinding the tracker.\n */\n\nimport { sha256 } from \"@noble/hashes/sha2.js\";\nimport { hex } from \"@scure/base\";\nimport * as btc from \"@scure/btc-signer\";\nimport type { BitcoinHtlcFacts } from \"./bitcoin.js\";\nimport type { BitcoinChainReader } from \"./bitcoin-manager.js\";\nimport {\n type BitcoinReaderPolicy,\n resolveMinConfirmations,\n} from \"./bitcoin-reader-esplora.js\";\n\n/** One entry of `blockchain.scripthash.get_history`. */\ntype ElectrumHistoryEntry = {\n tx_hash: string;\n /** Block height; 0 for mempool, -1 for mempool with unconfirmed parents. */\n height: number;\n};\n\n/**\n * The Electrum client surface this reader needs. The pure SDK's\n * `ElectrumWsClient` satisfies it; tests inject a fake.\n */\nexport type ElectrumRpc = {\n request<T>(method: string, params: unknown[]): Promise<T>;\n subscribeScriptHash(\n scripthash: string,\n onChange: (status: string | null) => void,\n ): Promise<string | null>;\n unsubscribeScriptHash(\n scripthash: string,\n onChange: (status: string | null) => void,\n ): void;\n};\n\nexport type ElectrumReaderNetwork =\n | \"mainnet\"\n | \"testnet\"\n | \"signet\"\n | \"regtest\";\n\n/** btc-signer only ships mainnet/testnet; regtest differs in bech32 prefix. */\nconst REGTEST_NETWORK = {\n bech32: \"bcrt\",\n pubKeyHash: 0x6f,\n scriptHash: 0xc4,\n wif: 0xef,\n} as const;\n\nfunction toBtcSignerNetwork(network: ElectrumReaderNetwork) {\n switch (network) {\n case \"mainnet\":\n return btc.NETWORK;\n case \"testnet\":\n case \"signet\":\n return btc.TEST_NETWORK;\n case \"regtest\":\n return REGTEST_NETWORK;\n }\n}\n\n/** Electrum scripthash: sha256 of the output script, reversed, hex. */\nfunction scriptHashOf(script: Uint8Array): string {\n return hex.encode(sha256(script).reverse());\n}\n\n/**\n * Build a {@link BitcoinChainReader} over an Electrum connection, with push\n * support and an optional fallback reader for outages.\n */\nexport function electrumReader(\n client: ElectrumRpc,\n opts?: BitcoinReaderPolicy & {\n /** Network the HTLC addresses live on; defaults to mainnet. */\n network?: ElectrumReaderNetwork;\n /** Reader consulted when the Electrum server errors (e.g. esplora). */\n fallback?: BitcoinChainReader;\n },\n): BitcoinChainReader {\n const network = opts?.network ?? \"mainnet\";\n const fallback = opts?.fallback;\n\n const scriptFor = (address: string): Uint8Array =>\n btc.OutScript.encode(\n btc.Address(toBtcSignerNetwork(network)).decode(address),\n );\n\n async function getHtlcFactsElectrum(\n address: string,\n required: number,\n ): Promise<BitcoinHtlcFacts> {\n const script = scriptFor(address);\n const scripthash = scriptHashOf(script);\n const history = await client.request<ElectrumHistoryEntry[]>(\n \"blockchain.scripthash.get_history\",\n [scripthash],\n );\n if (!history || history.length === 0)\n return { funding: \"absent\", fundedSats: 0 };\n\n // HTLC addresses see at most a handful of txs (fund + resolve), so\n // fetching each raw tx is cheap.\n const txs = await Promise.all(\n history.map(async (entry) => {\n const raw = await client.request<string>(\"blockchain.transaction.get\", [\n entry.tx_hash,\n ]);\n return {\n entry,\n tx: btc.Transaction.fromRaw(hex.decode(raw), {\n allowUnknownOutputs: true,\n allowUnknownInputs: true,\n disableScriptCheck: true,\n }),\n };\n }),\n );\n\n const scriptHex = hex.encode(script);\n const outputIndicesPaying = (tx: btc.Transaction): number[] => {\n const indices: number[] = [];\n for (let i = 0; i < tx.outputsLength; i++) {\n const out = tx.getOutput(i);\n if (out.script && hex.encode(out.script) === scriptHex) indices.push(i);\n }\n return indices;\n };\n\n // Among all txs paying the address, the one paying the MOST is the\n // funding candidate — same rule as the pure SDK's UTXO lookup. The\n // address is public, so a stray dust payment (whether it landed before\n // or after the real funding) must not be mistaken for the funding and\n // observe the swap as underfunded/invalid.\n let funding: (typeof txs)[number] | undefined;\n let fundedSats = 0;\n for (const candidate of txs) {\n let paid = 0;\n for (const i of outputIndicesPaying(candidate.tx))\n paid += Number(candidate.tx.getOutput(i).amount ?? 0n);\n if (paid > fundedSats) {\n funding = candidate;\n fundedSats = paid;\n }\n }\n if (!funding) return { funding: \"absent\", fundedSats: 0 };\n const fundedVouts = outputIndicesPaying(funding.tx);\n\n // A tx spending one of the funding outputs is the resolving spend; its\n // witness classifies claim vs refund.\n for (const { tx } of txs) {\n for (let i = 0; i < tx.inputsLength; i++) {\n const input = tx.getInput(i);\n if (\n input.txid &&\n hex.encode(input.txid) === funding.entry.tx_hash &&\n input.index !== undefined &&\n fundedVouts.includes(input.index)\n ) {\n return {\n funding: \"confirmed\",\n fundedSats: 0, // already spent — the amount no longer matters\n spendWitness: (input.finalScriptWitness ?? []).map((item) =>\n hex.encode(item),\n ),\n };\n }\n }\n }\n\n // Same depth policy as the esplora reader: 0/1 read straight off the\n // confirmed flag; deeper policies need the tip to compute depth.\n const height = funding.entry.height;\n const isConfirmed = height > 0;\n let deepEnough: boolean;\n if (isConfirmed) {\n if (required <= 1) {\n deepEnough = true;\n } else {\n const tip = await client.request<{ height: number }>(\n \"blockchain.headers.subscribe\",\n [],\n );\n deepEnough = tip.height - height + 1 >= required;\n }\n } else {\n deepEnough = required === 0;\n }\n return { funding: deepEnough ? \"confirmed\" : \"mempool\", fundedSats };\n }\n\n return {\n async getHtlcFacts(address, minConfirmations) {\n const required =\n minConfirmations ?? resolveMinConfirmations(opts?.minConfirmations);\n try {\n return await getHtlcFactsElectrum(address, required);\n } catch (error) {\n if (!fallback) throw error;\n console.warn(\n \"electrumReader: falling back to secondary reader:\",\n error instanceof Error ? error.message : error,\n );\n return fallback.getHtlcFacts(address, minConfirmations);\n }\n },\n\n subscribe(address, onChange) {\n // An address the configured network can't decode (e.g. a signet address\n // under a mainnet-configured reader) must not throw out of register()\n // and abort tracking — degrade to the poll cadence instead.\n let scripthash: string;\n try {\n scripthash = scriptHashOf(scriptFor(address));\n } catch (error) {\n console.warn(\n \"electrumReader: subscribe skipped (address decode failed):\",\n error instanceof Error ? error.message : error,\n );\n return () => {};\n }\n const handler = () => onChange();\n // Best-effort: if the subscription fails the tracker's poll cadence\n // remains the backstop, so the error is logged, not thrown.\n client.subscribeScriptHash(scripthash, handler).catch((error) => {\n console.warn(\n \"electrumReader: scripthash subscribe failed:\",\n error instanceof Error ? error.message : error,\n );\n });\n return () => client.unsubscribeScriptHash(scripthash, handler);\n },\n };\n}\n"],"mappings":"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","names":[]}
@@ -1,4 +1,7 @@
1
1
  // src/contracts/bitcoin-reader-esplora.ts
2
+ function resolveMinConfirmations(source) {
3
+ return (typeof source === "function" ? source() : source) ?? 0;
4
+ }
2
5
  function htlcFactsFromEsploraTxs(txs, address, opts) {
3
6
  for (const tx of txs) {
4
7
  const spend = tx.vin.find(
@@ -45,11 +48,10 @@ function esploraReader(esploraUrls, fetchImpl = fetch, policy) {
45
48
  const bases = (Array.isArray(esploraUrls) ? esploraUrls : [esploraUrls]).map(
46
49
  (url) => url.replace(/\/+$/, "")
47
50
  );
48
- const minConf = policy?.minConfirmations ?? 0;
49
51
  let start = 0;
50
52
  return {
51
53
  async getHtlcFacts(address, minConfirmations) {
52
- const required = minConfirmations ?? minConf;
54
+ const required = minConfirmations ?? resolveMinConfirmations(policy?.minConfirmations);
53
55
  const from = start++ % bases.length;
54
56
  let lastError;
55
57
  for (let i = 0; i < bases.length; i++) {
@@ -80,8 +82,9 @@ function esploraReader(esploraUrls, fetchImpl = fetch, policy) {
80
82
  }
81
83
 
82
84
  export {
85
+ resolveMinConfirmations,
83
86
  htlcFactsFromEsploraTxs,
84
87
  DEFAULT_ESPLORA_URLS,
85
88
  esploraReader
86
89
  };
87
- //# sourceMappingURL=chunk-THI4UO6R.js.map
90
+ //# sourceMappingURL=chunk-LSCRTQOS.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../src/contracts/bitcoin-reader-esplora.ts"],"sourcesContent":["/**\n * A esplora-backed {@link BitcoinChainReader} — the concrete chain source the\n * {@link BitcoinContractManager} uses in production.\n *\n * Kept separate from the manager so the manager stays free of any HTTP/esplora\n * dependency (and unit-testable against a fake reader). A single\n * `GET /address/{addr}/txs` yields both the funding state and the spending\n * input's witness, so no extra round-trips are needed.\n */\n\nimport type { BitcoinHtlcFacts } from \"./bitcoin.js\";\nimport type { BitcoinChainReader } from \"./bitcoin-manager.js\";\n\n/** The slice of esplora's tx JSON we read. */\ntype EsploraTx = {\n vin: Array<{\n witness?: string[];\n prevout?: { scriptpubkey_address?: string } | null;\n }>;\n vout: Array<{ scriptpubkey_address?: string; value?: number }>;\n status?: { confirmed?: boolean; block_height?: number };\n};\n\n/** A depth already resolved to a number, for the pure classifier below. */\nexport type BitcoinConfirmationPolicy = {\n minConfirmations?: number;\n};\n\n/** A depth, or a getter resolved per read so it can change without a rebuild. */\nexport type MinConfirmationsSource = number | (() => number | undefined);\n\n/**\n * When a funding tx counts as `confirmed` for observation purposes.\n *\n * `minConfirmations: 0` (the default) accepts an UNCONFIRMED funding, so a swap\n * claims as soon as the funding hits the mempool instead of waiting ~10min for\n * a block. This TRUSTS the funder not to double-spend: claiming publishes the\n * preimage, so a funder who then replaces its funding tx could take both legs.\n * Our own server therefore broadcasts HTLC fundings without signalling RBF.\n * Note that a non-signalling tx is still replaceable by full-RBF miners — set\n * `1` (or more) for a policy that doesn't rely on the funder's good behaviour;\n * values above 1 make the reader fetch the tip height to compute depth.\n */\nexport type BitcoinReaderPolicy = {\n minConfirmations?: MinConfirmationsSource;\n};\n\nexport function resolveMinConfirmations(\n source: MinConfirmationsSource | undefined,\n): number {\n return (typeof source === \"function\" ? source() : source) ?? 0;\n}\n\n/**\n * Reduce an address's esplora tx history to HTLC facts. If a tx spends an output\n * at the address, that's the resolving spend (its witness classifies claim vs\n * refund); otherwise a tx paying the address is the funding, gated by the\n * confirmation policy (see {@link BitcoinConfirmationPolicy}).\n */\nexport function htlcFactsFromEsploraTxs(\n txs: EsploraTx[],\n address: string,\n opts?: BitcoinConfirmationPolicy & { tipHeight?: number },\n): BitcoinHtlcFacts {\n for (const tx of txs) {\n const spend = tx.vin.find(\n (vin) => vin.prevout?.scriptpubkey_address === address,\n );\n if (spend)\n return {\n funding: \"confirmed\",\n fundedSats: 0, // already spent — the amount no longer matters\n spendWitness: spend.witness ?? [],\n };\n }\n // Among all txs paying the address, the one paying the MOST is the funding\n // candidate — matching the pure SDK's UTXO lookup. The address is public,\n // so a stray dust payment must not be mistaken for the funding and observe\n // the swap as underfunded/invalid.\n const paidSats = (tx: EsploraTx): number =>\n tx.vout\n .filter((vout) => vout.scriptpubkey_address === address)\n .reduce((sum, vout) => sum + (vout.value ?? 0), 0);\n let funding: EsploraTx | undefined;\n let fundedSats = 0;\n for (const tx of txs) {\n const paid = paidSats(tx);\n if (paid > fundedSats) {\n funding = tx;\n fundedSats = paid;\n }\n }\n if (funding) {\n const minConf = opts?.minConfirmations ?? 0;\n let deepEnough: boolean;\n if (funding.status?.confirmed) {\n deepEnough =\n minConf <= 1 ||\n (opts?.tipHeight !== undefined &&\n funding.status.block_height !== undefined &&\n opts.tipHeight - funding.status.block_height + 1 >= minConf);\n } else {\n deepEnough = minConf === 0;\n }\n return {\n funding: deepEnough ? \"confirmed\" : \"mempool\",\n fundedSats,\n };\n }\n return { funding: \"absent\", fundedSats: 0 };\n}\n\n/**\n * Public esplora endpoints tried in rotation (mainnet). blockstream.info runs the\n * reference esplora, so it's API-compatible with mempool.space; spreading calls\n * across both halves the per-provider load and survives one being throttled.\n */\nexport const DEFAULT_ESPLORA_URLS = [\n \"https://mempool.space/api\",\n \"https://blockstream.info/api\",\n];\n\n/**\n * Build a {@link BitcoinChainReader} over one or more esplora REST endpoints.\n * With several, each call starts at a rotating endpoint (to spread load across\n * providers) and fails over to the rest on error — so a throttled or failing\n * provider doesn't stall tracking.\n */\nexport function esploraReader(\n esploraUrls: string | string[],\n fetchImpl: typeof fetch = fetch,\n policy?: BitcoinReaderPolicy,\n): BitcoinChainReader {\n const bases = (Array.isArray(esploraUrls) ? esploraUrls : [esploraUrls]).map(\n (url) => url.replace(/\\/+$/, \"\"),\n );\n let start = 0;\n return {\n async getHtlcFacts(address, minConfirmations) {\n const required =\n minConfirmations ?? resolveMinConfirmations(policy?.minConfirmations);\n const from = start++ % bases.length; // rotate the primary to spread load\n let lastError: unknown;\n for (let i = 0; i < bases.length; i++) {\n const base = bases[(from + i) % bases.length];\n try {\n const res = await fetchImpl(\n `${base}/address/${encodeURIComponent(address)}/txs`,\n );\n if (!res.ok) throw new Error(`esplora ${res.status} at ${base}`);\n const txs = (await res.json()) as EsploraTx[];\n // Depth policies beyond 1 need the tip to compute confirmations;\n // 0 and 1 read straight off the tx's confirmed flag.\n let tipHeight: number | undefined;\n if (required > 1) {\n const tip = await fetchImpl(`${base}/blocks/tip/height`);\n if (!tip.ok) throw new Error(`esplora ${tip.status} at ${base}`);\n tipHeight = Number(await tip.text());\n }\n return htlcFactsFromEsploraTxs(txs, address, {\n minConfirmations: required,\n tipHeight,\n });\n } catch (error) {\n lastError = error;\n }\n }\n throw lastError ?? new Error(\"no esplora endpoints configured\");\n },\n };\n}\n"],"mappings":";AA+CO,SAAS,wBACd,QACQ;AACR,UAAQ,OAAO,WAAW,aAAa,OAAO,IAAI,WAAW;AAC/D;AAQO,SAAS,wBACd,KACA,SACA,MACkB;AAClB,aAAW,MAAM,KAAK;AACpB,UAAM,QAAQ,GAAG,IAAI;AAAA,MACnB,CAAC,QAAQ,IAAI,SAAS,yBAAyB;AAAA,IACjD;AACA,QAAI;AACF,aAAO;AAAA,QACL,SAAS;AAAA,QACT,YAAY;AAAA;AAAA,QACZ,cAAc,MAAM,WAAW,CAAC;AAAA,MAClC;AAAA,EACJ;AAKA,QAAM,WAAW,CAAC,OAChB,GAAG,KACA,OAAO,CAAC,SAAS,KAAK,yBAAyB,OAAO,EACtD,OAAO,CAAC,KAAK,SAAS,OAAO,KAAK,SAAS,IAAI,CAAC;AACrD,MAAI;AACJ,MAAI,aAAa;AACjB,aAAW,MAAM,KAAK;AACpB,UAAM,OAAO,SAAS,EAAE;AACxB,QAAI,OAAO,YAAY;AACrB,gBAAU;AACV,mBAAa;AAAA,IACf;AAAA,EACF;AACA,MAAI,SAAS;AACX,UAAM,UAAU,MAAM,oBAAoB;AAC1C,QAAI;AACJ,QAAI,QAAQ,QAAQ,WAAW;AAC7B,mBACE,WAAW,KACV,MAAM,cAAc,UACnB,QAAQ,OAAO,iBAAiB,UAChC,KAAK,YAAY,QAAQ,OAAO,eAAe,KAAK;AAAA,IAC1D,OAAO;AACL,mBAAa,YAAY;AAAA,IAC3B;AACA,WAAO;AAAA,MACL,SAAS,aAAa,cAAc;AAAA,MACpC;AAAA,IACF;AAAA,EACF;AACA,SAAO,EAAE,SAAS,UAAU,YAAY,EAAE;AAC5C;AAOO,IAAM,uBAAuB;AAAA,EAClC;AAAA,EACA;AACF;AAQO,SAAS,cACd,aACA,YAA0B,OAC1B,QACoB;AACpB,QAAM,SAAS,MAAM,QAAQ,WAAW,IAAI,cAAc,CAAC,WAAW,GAAG;AAAA,IACvE,CAAC,QAAQ,IAAI,QAAQ,QAAQ,EAAE;AAAA,EACjC;AACA,MAAI,QAAQ;AACZ,SAAO;AAAA,IACL,MAAM,aAAa,SAAS,kBAAkB;AAC5C,YAAM,WACJ,oBAAoB,wBAAwB,QAAQ,gBAAgB;AACtE,YAAM,OAAO,UAAU,MAAM;AAC7B,UAAI;AACJ,eAAS,IAAI,GAAG,IAAI,MAAM,QAAQ,KAAK;AACrC,cAAM,OAAO,OAAO,OAAO,KAAK,MAAM,MAAM;AAC5C,YAAI;AACF,gBAAM,MAAM,MAAM;AAAA,YAChB,GAAG,IAAI,YAAY,mBAAmB,OAAO,CAAC;AAAA,UAChD;AACA,cAAI,CAAC,IAAI,GAAI,OAAM,IAAI,MAAM,WAAW,IAAI,MAAM,OAAO,IAAI,EAAE;AAC/D,gBAAM,MAAO,MAAM,IAAI,KAAK;AAG5B,cAAI;AACJ,cAAI,WAAW,GAAG;AAChB,kBAAM,MAAM,MAAM,UAAU,GAAG,IAAI,oBAAoB;AACvD,gBAAI,CAAC,IAAI,GAAI,OAAM,IAAI,MAAM,WAAW,IAAI,MAAM,OAAO,IAAI,EAAE;AAC/D,wBAAY,OAAO,MAAM,IAAI,KAAK,CAAC;AAAA,UACrC;AACA,iBAAO,wBAAwB,KAAK,SAAS;AAAA,YAC3C,kBAAkB;AAAA,YAClB;AAAA,UACF,CAAC;AAAA,QACH,SAAS,OAAO;AACd,sBAAY;AAAA,QACd;AAAA,MACF;AACA,YAAM,aAAa,IAAI,MAAM,iCAAiC;AAAA,IAChE;AAAA,EACF;AACF;","names":[]}
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);
@@ -1112,7 +1116,8 @@ function refQueryKey(ref) {
1112
1116
  var AVG_BLOCK_MS = {
1113
1117
  1: 12e3,
1114
1118
  137: 2100,
1115
- 42161: 250
1119
+ 42161: 250,
1120
+ 30: 3e4
1116
1121
  };
1117
1122
  var SCAN_MARGIN_BLOCKS = 1000n;
1118
1123
  var EvmContractManager = class _EvmContractManager {
@@ -1368,7 +1373,9 @@ var DEFAULT_EVM_RPCS = {
1368
1373
  "https://arb1.arbitrum.io/rpc",
1369
1374
  "https://arbitrum.drpc.org",
1370
1375
  "https://arbitrum-one-rpc.publicnode.com"
1371
- ]
1376
+ ],
1377
+ // Rootstock: the public node, HTTP only (no WebSocket endpoint exists).
1378
+ 30: ["https://public-node.rsk.co"]
1372
1379
  };
1373
1380
  var SWAP_CREATED = (0, import_viem.parseAbiItem)(
1374
1381
  "event SwapCreated(bytes32 indexed preimageHash, address indexed refundAddress, address indexed claimAddress, address token, uint256 amount, uint256 timelock, bytes32 key)"
@@ -1401,25 +1408,52 @@ function activeKey(q) {
1401
1408
  }
1402
1409
  });
1403
1410
  }
1404
- 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
+ }
1405
1442
  return {
1406
1443
  async getHtlcEventsBatch(queries, fromBlock = 0n) {
1407
1444
  const results = /* @__PURE__ */ new Map();
1408
1445
  if (queries.length === 0) return results;
1409
1446
  for (const q of queries) results.set(htlcQueryKey(q), []);
1410
- const logs = await client.request({
1411
- method: "eth_getLogs",
1412
- params: [
1413
- {
1414
- address: unique(queries.map((q) => q.htlc)),
1415
- topics: [
1416
- HTLC_EVENT_TOPICS,
1417
- unique(queries.map((q) => q.preimageHash))
1418
- ],
1419
- fromBlock: (0, import_viem.numberToHex)(fromBlock)
1420
- }
1421
- ]
1422
- });
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
+ );
1423
1457
  const grouped = /* @__PURE__ */ new Map();
1424
1458
  for (const log of logs) {
1425
1459
  const decoded = tryDecode(log);
@@ -1535,12 +1569,15 @@ function toHtlcEvent(decoded, query) {
1535
1569
  function unique(values) {
1536
1570
  return [...new Set(values)];
1537
1571
  }
1538
- function createEvmRpcReader(rpcUrls) {
1572
+ function createEvmRpcReader(rpcUrls, options = {}) {
1539
1573
  const urls = Array.isArray(rpcUrls) ? rpcUrls : [rpcUrls];
1540
1574
  const transport = urls.length > 1 ? (0, import_viem.fallback)(urls.map((url) => (0, import_viem.http)(url))) : (0, import_viem.http)(urls[0]);
1541
1575
  const client = (0, import_viem.createPublicClient)({ transport });
1542
- return evmReaderFromClient(client);
1576
+ return evmReaderFromClient(client, options);
1543
1577
  }
1578
+ var EVM_LOG_RANGE_LIMITS = {
1579
+ 30: 2000n
1580
+ };
1544
1581
  function defaultEvmReaders(overrides) {
1545
1582
  const chainIds = /* @__PURE__ */ new Set([
1546
1583
  ...Object.keys(DEFAULT_EVM_RPCS).map(Number),
@@ -1551,7 +1588,13 @@ function defaultEvmReaders(overrides) {
1551
1588
  const override = overrides?.[chainId];
1552
1589
  const defaults = DEFAULT_EVM_RPCS[chainId] ?? [];
1553
1590
  const urls = override ? [override, ...defaults] : defaults;
1554
- 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
+ );
1555
1598
  }
1556
1599
  return readers;
1557
1600
  }
@@ -2318,21 +2361,38 @@ function swapToTracked(stored) {
2318
2361
  }
2319
2362
 
2320
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;
2321
2371
  var SwapTracker = class {
2322
2372
  #managers;
2323
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();
2324
2380
  /** Last actions emitted per swap — for dedupe and the subscribe-time snapshot. */
2325
2381
  #lastActions = /* @__PURE__ */ new Map();
2382
+ /** The latest server status hint per swap, for {@link #withClaimHint}. */
2383
+ #hintStatuses = /* @__PURE__ */ new Map();
2326
2384
  #subscribers = /* @__PURE__ */ new Set();
2327
2385
  #refreshIntervalMs;
2328
2386
  #atRiskReconcileIntervalMs;
2329
2387
  #lastAtRiskReconcileAt = 0;
2388
+ #lastRetryAt = 0;
2330
2389
  #eventUnsubs = [];
2331
2390
  #timer;
2332
2391
  constructor(managers, options) {
2333
2392
  this.#managers = managers;
2334
2393
  this.#refreshIntervalMs = options?.refreshIntervalMs ?? 0;
2335
- this.#atRiskReconcileIntervalMs = options?.atRiskReconcileIntervalMs ?? 6e4;
2394
+ const atRisk = options?.atRiskReconcileIntervalMs ?? DEFAULT_AT_RISK_RECONCILE_INTERVAL_MS;
2395
+ this.#atRiskReconcileIntervalMs = typeof atRisk === "function" ? atRisk : () => atRisk;
2336
2396
  }
2337
2397
  /**
2338
2398
  * Whether every on-chain leg of `swap` is observable — i.e. the tracker can
@@ -2355,7 +2415,22 @@ var SwapTracker = class {
2355
2415
  continue;
2356
2416
  }
2357
2417
  this.#swaps.set(swap.swapId, swap);
2358
- 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
+ }
2359
2434
  }
2360
2435
  for (const manager of new Set(this.#managers.values())) {
2361
2436
  this.#eventUnsubs.push(manager.onEvent(() => this.#recomputeAll()));
@@ -2363,6 +2438,7 @@ var SwapTracker = class {
2363
2438
  await this.#refreshManagers();
2364
2439
  this.#recomputeAll();
2365
2440
  this.#lastAtRiskReconcileAt = Date.now();
2441
+ this.#lastRetryAt = this.#lastAtRiskReconcileAt;
2366
2442
  if (this.#refreshIntervalMs > 0)
2367
2443
  this.#timer = setInterval(
2368
2444
  () => void this.#tick(),
@@ -2412,7 +2488,11 @@ var SwapTracker = class {
2412
2488
  */
2413
2489
  async #tick() {
2414
2490
  const now = Date.now();
2415
- 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()) {
2416
2496
  this.#lastAtRiskReconcileAt = now;
2417
2497
  const legs = [...this.#swaps.values()].filter((swap) => this.#isAtRisk(swap)).flatMap((swap) => legsOf(swap));
2418
2498
  await Promise.all(
@@ -2425,6 +2505,25 @@ var SwapTracker = class {
2425
2505
  }
2426
2506
  this.#recomputeAll();
2427
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
+ }
2428
2527
  /**
2429
2528
  * Whether a swap needs chain-verified monitoring independent of the server:
2430
2529
  * its client leg holds (or held) funds on-chain, so knowing when a refund
@@ -2484,6 +2583,7 @@ var SwapTracker = class {
2484
2583
  async applyHint(swapId, opts) {
2485
2584
  const swap = this.#swaps.get(swapId);
2486
2585
  if (!swap) return;
2586
+ if (opts?.status !== void 0) this.#hintStatuses.set(swapId, opts.status);
2487
2587
  const legs = legsOf(swap);
2488
2588
  const results = await Promise.allSettled(
2489
2589
  legs.map((leg) => this.#managerFor(leg).reconcile(leg))
@@ -2520,6 +2620,7 @@ var SwapTracker = class {
2520
2620
  for (const leg of legsOf(swap))
2521
2621
  void this.#managerFor(leg).unregister(leg);
2522
2622
  this.#swaps.clear();
2623
+ this.#unregistered.clear();
2523
2624
  this.#subscribers.clear();
2524
2625
  }
2525
2626
  #managerFor(ref) {
@@ -2531,6 +2632,28 @@ var SwapTracker = class {
2531
2632
  #recomputeAll() {
2532
2633
  for (const swap of this.#swaps.values()) this.#recompute(swap);
2533
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
+ }
2534
2657
  #recompute(swap, force = false) {
2535
2658
  let clientHtlc;
2536
2659
  let serverHtlc;
@@ -2550,8 +2673,9 @@ var SwapTracker = class {
2550
2673
  if (serverHtlc === void 0 || now === void 0) return;
2551
2674
  serverChainNow = now;
2552
2675
  }
2553
- const status = deriveSwapStatus({ clientHtlc, serverHtlc });
2554
- 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);
2555
2679
  const actions = {
2556
2680
  ...deriveSwapActions({
2557
2681
  status,
@@ -2602,12 +2726,16 @@ var Client = class {
2602
2726
  #startPromise;
2603
2727
  /** Swap ids whose settled status was already re-fetched this session. */
2604
2728
  #settledSyncDone = /* @__PURE__ */ new Set();
2729
+ /** Read per chain read, so it can change without rebuilding the client. */
2730
+ #bitcoinMinConfirmations;
2605
2731
  constructor(...args) {
2606
2732
  if (args[0] instanceof import_lendaswap_sdk_pure2.Client) {
2607
2733
  this.#legacy = args[0];
2608
2734
  this.#tracking = args[1] ?? {
2609
2735
  enabled: false
2610
2736
  };
2737
+ assertMinConfirmations(this.#tracking.bitcoinMinConfirmations);
2738
+ this.#bitcoinMinConfirmations = this.#tracking.bitcoinMinConfirmations;
2611
2739
  } else {
2612
2740
  this.#legacy = new import_lendaswap_sdk_pure2.Client(
2613
2741
  ...args
@@ -2918,6 +3046,22 @@ var Client = class {
2918
3046
  return this.#legacy.hasReceivedVtxo(...args);
2919
3047
  }
2920
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
+ }
2921
3065
  /**
2922
3066
  * Start observing the user's active swaps and deriving each one's next action.
2923
3067
  *
@@ -2946,8 +3090,18 @@ var Client = class {
2946
3090
  async #doStartTracking() {
2947
3091
  try {
2948
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
+ );
2949
3097
  const tracker = chainVerified ? new SwapTracker(await this.#ensureManagers(), {
2950
- 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
2951
3105
  }) : new HintTracker({
2952
3106
  fetchStatus: async (swapId) => (await this.getSwap(swapId, { updateStorage: true })).status,
2953
3107
  refreshIntervalMs: this.#tracking.refreshIntervalMs ?? 5e3
@@ -3133,8 +3287,7 @@ var Client = class {
3133
3287
  evmRpcUrls,
3134
3288
  esploraUrl,
3135
3289
  electrumWsUrl,
3136
- bitcoinNetwork,
3137
- bitcoinMinConfirmations
3290
+ bitcoinNetwork
3138
3291
  } = this.#tracking;
3139
3292
  const chainTime = async () => (await this.getMtp()).mtp * 1e3;
3140
3293
  const arkadeUrl = arkadeServerUrl ?? defaultArkadeServerUrl("bitcoin");
@@ -3155,7 +3308,7 @@ var Client = class {
3155
3308
  electrumWsUrl: electrumWsUrl ?? import_lendaswap_sdk_pure2.DEFAULT_ELECTRUM_WS_URLS[network],
3156
3309
  network,
3157
3310
  chainTime,
3158
- minConfirmations: bitcoinMinConfirmations
3311
+ minConfirmations: () => this.#bitcoinMinConfirmations
3159
3312
  })
3160
3313
  );
3161
3314
  this.#managers = managers;
@@ -3276,9 +3429,11 @@ var ClientBuilder = class {
3276
3429
  * funding hits the mempool, which TRUSTS the funder not to double-spend it
3277
3430
  * (claiming publishes the preimage, so a funder that then replaced its
3278
3431
  * funding could take both legs). Set `1` (or more) for a block-depth policy
3279
- * 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.
3280
3434
  */
3281
3435
  withBitcoinMinConfirmations(minConfirmations) {
3436
+ assertMinConfirmations(minConfirmations);
3282
3437
  this.#bitcoinMinConfirmations = minConfirmations;
3283
3438
  return this;
3284
3439
  }
@@ -3335,6 +3490,13 @@ var ClientBuilder = class {
3335
3490
  });
3336
3491
  }
3337
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
+ }
3338
3500
  function legacyBaseUrl(legacy) {
3339
3501
  try {
3340
3502
  return legacy.baseUrl;
@@ -3383,6 +3545,7 @@ init_bitcoin_reader_esplora();
3383
3545
  htlcFactsFromEsploraTxs,
3384
3546
  htlcKey,
3385
3547
  htlcQueryKey,
3548
+ resolveMinConfirmations,
3386
3549
  swapToTracked,
3387
3550
  ...require("@lendasat/lendaswap-sdk-pure")
3388
3551
  });