@satora/swap 0.3.1 → 1.0.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,7 @@
1
+ import {
2
+ electrumReader
3
+ } from "./chunk-VHXEU534.js";
4
+ export {
5
+ electrumReader
6
+ };
7
+ //# sourceMappingURL=bitcoin-reader-electrum-4DOJ7NGH.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":[],"sourcesContent":[],"mappings":"","names":[]}
@@ -0,0 +1,145 @@
1
+ // src/contracts/bitcoin-reader-electrum.ts
2
+ import { sha256 } from "@noble/hashes/sha2.js";
3
+ import { hex } from "@scure/base";
4
+ import * as btc from "@scure/btc-signer";
5
+ var REGTEST_NETWORK = {
6
+ bech32: "bcrt",
7
+ pubKeyHash: 111,
8
+ scriptHash: 196,
9
+ wif: 239
10
+ };
11
+ function toBtcSignerNetwork(network) {
12
+ switch (network) {
13
+ case "mainnet":
14
+ return btc.NETWORK;
15
+ case "testnet":
16
+ case "signet":
17
+ return btc.TEST_NETWORK;
18
+ case "regtest":
19
+ return REGTEST_NETWORK;
20
+ }
21
+ }
22
+ function scriptHashOf(script) {
23
+ return hex.encode(sha256(script).reverse());
24
+ }
25
+ function electrumReader(client, opts) {
26
+ const network = opts?.network ?? "mainnet";
27
+ const defaultMinConf = opts?.minConfirmations ?? 0;
28
+ const fallback = opts?.fallback;
29
+ const scriptFor = (address) => btc.OutScript.encode(
30
+ btc.Address(toBtcSignerNetwork(network)).decode(address)
31
+ );
32
+ async function getHtlcFactsElectrum(address, required) {
33
+ const script = scriptFor(address);
34
+ const scripthash = scriptHashOf(script);
35
+ const history = await client.request(
36
+ "blockchain.scripthash.get_history",
37
+ [scripthash]
38
+ );
39
+ if (!history || history.length === 0)
40
+ return { funding: "absent", fundedSats: 0 };
41
+ const txs = await Promise.all(
42
+ history.map(async (entry) => {
43
+ const raw = await client.request("blockchain.transaction.get", [
44
+ entry.tx_hash
45
+ ]);
46
+ return {
47
+ entry,
48
+ tx: btc.Transaction.fromRaw(hex.decode(raw), {
49
+ allowUnknownOutputs: true,
50
+ allowUnknownInputs: true,
51
+ disableScriptCheck: true
52
+ })
53
+ };
54
+ })
55
+ );
56
+ const scriptHex = hex.encode(script);
57
+ const outputIndicesPaying = (tx) => {
58
+ const indices = [];
59
+ for (let i = 0; i < tx.outputsLength; i++) {
60
+ const out = tx.getOutput(i);
61
+ if (out.script && hex.encode(out.script) === scriptHex) indices.push(i);
62
+ }
63
+ return indices;
64
+ };
65
+ const funding = txs.find(({ tx }) => outputIndicesPaying(tx).length > 0);
66
+ if (!funding) return { funding: "absent", fundedSats: 0 };
67
+ const fundedVouts = outputIndicesPaying(funding.tx);
68
+ for (const { tx } of txs) {
69
+ for (let i = 0; i < tx.inputsLength; i++) {
70
+ const input = tx.getInput(i);
71
+ if (input.txid && hex.encode(input.txid) === funding.entry.tx_hash && input.index !== void 0 && fundedVouts.includes(input.index)) {
72
+ return {
73
+ funding: "confirmed",
74
+ fundedSats: 0,
75
+ // already spent — the amount no longer matters
76
+ spendWitness: (input.finalScriptWitness ?? []).map(
77
+ (item) => hex.encode(item)
78
+ )
79
+ };
80
+ }
81
+ }
82
+ }
83
+ let fundedSats = 0;
84
+ for (const i of fundedVouts)
85
+ fundedSats += Number(funding.tx.getOutput(i).amount ?? 0n);
86
+ const height = funding.entry.height;
87
+ const isConfirmed = height > 0;
88
+ let deepEnough;
89
+ if (isConfirmed) {
90
+ if (required <= 1) {
91
+ deepEnough = true;
92
+ } else {
93
+ const tip = await client.request(
94
+ "blockchain.headers.subscribe",
95
+ []
96
+ );
97
+ deepEnough = tip.height - height + 1 >= required;
98
+ }
99
+ } else {
100
+ deepEnough = required === 0;
101
+ }
102
+ return { funding: deepEnough ? "confirmed" : "mempool", fundedSats };
103
+ }
104
+ return {
105
+ async getHtlcFacts(address, minConfirmations) {
106
+ const required = minConfirmations ?? defaultMinConf;
107
+ try {
108
+ return await getHtlcFactsElectrum(address, required);
109
+ } catch (error) {
110
+ if (!fallback) throw error;
111
+ console.warn(
112
+ "electrumReader: falling back to secondary reader:",
113
+ error instanceof Error ? error.message : error
114
+ );
115
+ return fallback.getHtlcFacts(address, minConfirmations);
116
+ }
117
+ },
118
+ subscribe(address, onChange) {
119
+ let scripthash;
120
+ try {
121
+ scripthash = scriptHashOf(scriptFor(address));
122
+ } catch (error) {
123
+ console.warn(
124
+ "electrumReader: subscribe skipped (address decode failed):",
125
+ error instanceof Error ? error.message : error
126
+ );
127
+ return () => {
128
+ };
129
+ }
130
+ const handler = () => onChange();
131
+ client.subscribeScriptHash(scripthash, handler).catch((error) => {
132
+ console.warn(
133
+ "electrumReader: scripthash subscribe failed:",
134
+ error instanceof Error ? error.message : error
135
+ );
136
+ });
137
+ return () => client.unsubscribeScriptHash(scripthash, handler);
138
+ }
139
+ };
140
+ }
141
+
142
+ export {
143
+ electrumReader
144
+ };
145
+ //# sourceMappingURL=chunk-VHXEU534.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 type { BitcoinConfirmationPolicy } 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?: BitcoinConfirmationPolicy & {\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 defaultMinConf = opts?.minConfirmations ?? 0;\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 const funding = txs.find(({ tx }) => outputIndicesPaying(tx).length > 0);\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 let fundedSats = 0;\n for (const i of fundedVouts)\n fundedSats += Number(funding.tx.getOutput(i).amount ?? 0n);\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 = minConfirmations ?? defaultMinConf;\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":[]}