aboutsummaryrefslogtreecommitdiffstats
path: root/lib/esplora.ts
diff options
context:
space:
mode:
authorAdam Soltys <[email protected]>2026-02-04 13:05:01 -0800
committerAdam Soltys <[email protected]>2026-02-06 08:59:26 -0800
commit384c4778ca5421c091a7d282b90b00965f3596cd (patch)
tree0803cb8872128007e5487f94a9b9e7a00cbffba6 /lib/esplora.ts
parent5e3bfba5eb3d54cc03551f98624a2e92dca5825f (diff)
downloadcoinos-server-384c4778ca5421c091a7d282b90b00965f3596cd.tar.gz
coinos-server-384c4778ca5421c091a7d282b90b00965f3596cd.zip
zmq logic
Diffstat (limited to 'lib/esplora.ts')
-rw-r--r--lib/esplora.ts153
1 files changed, 153 insertions, 0 deletions
diff --git a/lib/esplora.ts b/lib/esplora.ts
new file mode 100644
index 00000000..4fd81b32
--- /dev/null
+++ b/lib/esplora.ts
@@ -0,0 +1,153 @@
+import config from "$config";
+import { HDKey } from "@scure/bip32";
+import { p2wpkh, NETWORK, TEST_NETWORK } from "@scure/btc-signer";
+
+const { esploraUrl } = config.bitcoin;
+
+const REGTEST_NETWORK = {
+ bech32: "bcrt",
+ pubKeyHash: 0x6f,
+ scriptHash: 0xc4,
+ wif: 0xef,
+};
+
+const REGTEST_VERSIONS = { private: 0x04358394, public: 0x043587cf };
+
+const btcNetwork =
+ config.bitcoin.network === "regtest"
+ ? REGTEST_NETWORK
+ : config.bitcoin.network === "testnet"
+ ? TEST_NETWORK
+ : NETWORK;
+
+const hdVersions =
+ config.bitcoin.network === "regtest" || config.bitcoin.network === "testnet"
+ ? REGTEST_VERSIONS
+ : undefined;
+
+// Esplora API
+
+export const getUtxos = async (address: string) => {
+ const r = await fetch(`${esploraUrl}/address/${address}/utxo`);
+ if (!r.ok) throw new Error(`esplora getUtxos: ${r.status}`);
+ return r.json();
+};
+
+export const getAddressUtxos = async (addresses: string[]) => {
+ const results = [];
+ for (const address of addresses) {
+ const utxos = await getUtxos(address);
+ for (const u of utxos) {
+ u.address = address;
+ results.push(u);
+ }
+ }
+ return results;
+};
+
+export const broadcastTx = async (txHex: string) => {
+ const r = await fetch(`${esploraUrl}/tx`, {
+ method: "POST",
+ body: txHex,
+ headers: { "Content-Type": "text/plain" },
+ });
+ if (!r.ok) {
+ const body = await r.text();
+ throw new Error(`broadcast failed: ${body}`);
+ }
+ return r.text();
+};
+
+export const getTxStatus = async (txid: string) => {
+ const r = await fetch(`${esploraUrl}/tx/${txid}/status`);
+ if (!r.ok) throw new Error(`esplora getTxStatus: ${r.status}`);
+ return r.json();
+};
+
+export const getFeeEstimates = async () => {
+ const r = await fetch(`${esploraUrl}/fee-estimates`);
+ if (!r.ok) throw new Error(`esplora getFeeEstimates: ${r.status}`);
+ return r.json();
+};
+
+export const getTx = async (txid: string) => {
+ const r = await fetch(`${esploraUrl}/tx/${txid}`);
+ if (!r.ok) throw new Error(`esplora getTx: ${r.status}`);
+ return r.json();
+};
+
+export const getAddressTxs = async (address: string) => {
+ const r = await fetch(`${esploraUrl}/address/${address}/txs`);
+ if (!r.ok) throw new Error(`esplora getAddressTxs: ${r.status}`);
+ return r.json();
+};
+
+export const getTxHex = async (txid: string) => {
+ const r = await fetch(`${esploraUrl}/tx/${txid}/hex`);
+ if (!r.ok) throw new Error(`esplora getTxHex: ${r.status}`);
+ return r.text();
+};
+
+// Address derivation
+
+const hdVersionsForKey = (pubkey: string) => {
+ if (pubkey.startsWith("tpub") || pubkey.startsWith("tprv"))
+ return REGTEST_VERSIONS;
+ // xpub/xprv use default BITCOIN_VERSIONS (mainnet) — pass undefined
+ return undefined;
+};
+
+export const deriveAddress = (
+ pubkey: string,
+ fingerprint: string,
+ index: number,
+ internal = false,
+) => {
+ const accountKey = HDKey.fromExtendedKey(pubkey, hdVersionsForKey(pubkey));
+ const chain = internal ? 1 : 0;
+ const child = accountKey.deriveChild(chain).deriveChild(index);
+
+ const { address } = p2wpkh(child.publicKey, btcNetwork);
+ const path = `m/${chain}/${index}`;
+
+ return { address, path };
+};
+
+export const deriveAddresses = (
+ pubkey: string,
+ fingerprint: string,
+ count: number,
+ internal = false,
+) => {
+ const addresses = [];
+ for (let i = 0; i < count; i++) {
+ const { address } = deriveAddress(pubkey, fingerprint, i, internal);
+ addresses.push(address);
+ }
+ return addresses;
+};
+
+// Migration helpers
+
+export const parseDescriptor = (desc: string) => {
+ // Parse wpkh([fingerprint]pubkey/0/*)#checksum
+ const match = desc.match(/wpkh\(\[([a-f0-9]+)\]([^/]+)\/\d+\/\*\)/);
+ if (!match) return null;
+ return { fingerprint: match[1], pubkey: match[2] };
+};
+
+export const findLastUsedIndex = async (
+ pubkey: string,
+ fingerprint: string,
+ maxScan = 100,
+) => {
+ let lastUsed = -1;
+ for (let i = 0; i < maxScan; i++) {
+ const { address } = deriveAddress(pubkey, fingerprint, i, false);
+ const txs = await getAddressTxs(address);
+ if (txs.length > 0) lastUsed = i;
+ }
+ return lastUsed + 1;
+};
+
+export { btcNetwork, hdVersions };