@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.
- package/dist/bitcoin-reader-electrum-Z2YCNDLB.js +8 -0
- package/dist/bitcoin-reader-esplora-B2BDEI6Z.js +13 -0
- package/dist/{chunk-FTPIQSRW.js → chunk-INWVLX4A.js} +6 -3
- package/dist/chunk-INWVLX4A.js.map +1 -0
- package/dist/{chunk-THI4UO6R.js → chunk-LSCRTQOS.js} +6 -3
- package/dist/chunk-LSCRTQOS.js.map +1 -0
- package/dist/index.cjs +197 -34
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +103 -71
- package/dist/index.d.ts +103 -71
- package/dist/index.js +196 -36
- package/dist/index.js.map +1 -1
- package/package.json +2 -2
- package/dist/bitcoin-reader-electrum-PY7DHD6T.js +0 -7
- package/dist/bitcoin-reader-esplora-ZLPMM5FF.js +0 -11
- package/dist/chunk-FTPIQSRW.js.map +0 -1
- package/dist/chunk-THI4UO6R.js.map +0 -1
- /package/dist/{bitcoin-reader-electrum-PY7DHD6T.js.map → bitcoin-reader-electrum-Z2YCNDLB.js.map} +0 -0
- /package/dist/{bitcoin-reader-esplora-ZLPMM5FF.js.map → bitcoin-reader-esplora-B2BDEI6Z.js.map} +0 -0
|
@@ -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 ??
|
|
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-
|
|
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 ??
|
|
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-
|
|
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 ??
|
|
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 ??
|
|
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
|
|
1411
|
-
|
|
1412
|
-
|
|
1413
|
-
|
|
1414
|
-
|
|
1415
|
-
|
|
1416
|
-
|
|
1417
|
-
|
|
1418
|
-
|
|
1419
|
-
|
|
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)
|
|
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
|
-
|
|
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
|
-
|
|
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.#
|
|
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
|
|
2554
|
-
if (
|
|
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
|
});
|