diff --git a/src/bitcoind.ts b/src/bitcoind.ts new file mode 100644 index 0000000..833c014 --- /dev/null +++ b/src/bitcoind.ts @@ -0,0 +1,99 @@ +import RpcClient from "bitcoind-rpc"; +import { LOGLEVEL, BITCOIND_ESTIMATE_MODE } from "./util"; +import { logger } from "./logger"; +import { promisify } from "util"; + +const log = logger(LOGLEVEL); + +export class BitcoindProvider implements Provider { + public rpc: RpcClient; + private targets: number[]; + + constructor(url: string, user: string, pass: string, targets: number[]) { + let { protocol, hostname: host, port } = new URL(url); + protocol = protocol.replace(/.$/, ""); + this.rpc = new RpcClient({ protocol, host, port, user, pass }); + this.targets = targets; + } + + async getBlockHeight(): Promise { + const getBlockCount = promisify(this.rpc.getBlockCount.bind(this.rpc)); + + const response = await getBlockCount(); + log.trace({ msg: "getBlockCount", response: response.result }); + + return response.result; + } + + async getBlockHash(): Promise { + const getBestBlockHash = promisify( + this.rpc.getBestBlockHash.bind(this.rpc), + ); + + const response = await getBestBlockHash(); + log.trace({ msg: "getBestBlockHash", response: response.result }); + + return response.result; + } + + async getFeeEstimate(target: number): Promise { + const estimateSmartFee = promisify( + this.rpc.estimateSmartFee.bind(this.rpc), + ); + + const response = await estimateSmartFee(target, BITCOIND_ESTIMATE_MODE); + log.trace({ msg: "estimateSmartFee", response: response.result }); + + return response.result?.feerate; + } + + async getFeeEstimates(): Promise { + const batch = promisify(this.rpc.batch.bind(this.rpc)); + + const targets = this.targets; + const rpc = this.rpc; + + function batchCall() { + targets.forEach(function (target) { + rpc.estimateSmartFee(target, BITCOIND_ESTIMATE_MODE); + }); + } + + const responses = await batch(batchCall); + + const fees: FeeByBlockTarget = {}; + let errorCount = 0; + + this.targets.forEach((target, i) => { + try { + let feeRate = responses[i].result?.feerate; + if (feeRate) { + // convert the returned value to satoshis, as it's currently returned in BTC. + fees[target] = feeRate * 1e8; + } else { + throw new Error(responses[i].result?.errors[0]); + } + } catch (error) { + errorCount++; + log.warn({ + msg: `Error getting fee estimate for target ${target}:`, + errors: responses[i].result?.errors.join(", "), + }); + } + }); + + if (errorCount === this.targets.length) { + log.error({ msg: "Error getting fee estimates" }); + throw new Error("Error getting fee estimates"); + } + + return fees; + } + + async getAllData(): Promise { + const blockHeight = await this.getBlockHeight(); + const blockHash = await this.getBlockHash(); + const feeEstimates = await this.getFeeEstimates(); + return { blockHeight, blockHash, feeEstimates }; + } +} diff --git a/src/custom.d.ts b/src/custom.d.ts index bb1d478..6dccaff 100644 --- a/src/custom.d.ts +++ b/src/custom.d.ts @@ -40,7 +40,7 @@ interface SiteData { type ExpectedResponseType = "json" | "text"; // can be either 'json' or 'text' // BatchRequest represents a bitcoind batch request response. -interface BitcoindRpcBatchResponse { +interface EstimateSmartFeeBatchResponse { result?: EstimateSmartFeeResponse; error?: any; } @@ -52,6 +52,14 @@ interface EstimateSmartFeeResponse { blocks?: number; // block number where estimate was found } +interface BlockCountResponse { + result: number; +} + +interface BestBlockHashResponse { + result: string; +} + // EstimateMode represents the mode for fee estimation. type EstimateMode = "ECONOMICAL" | "CONSERVATIVE"; // estimate mode can be either 'ECONOMICAL' or 'CONSERVATIVE' diff --git a/src/util.ts b/src/util.ts index d867e5d..46d5ed4 100644 --- a/src/util.ts +++ b/src/util.ts @@ -303,7 +303,7 @@ export async function fetchBitcoindData(): Promise { rpc.batch( batchCall, - (error: Error | null, response: BitcoindRpcBatchResponse[]) => { + (error: Error | null, response: EstimateSmartFeeBatchResponse[]) => { if (error) { log.error({ message: "Unable to fetch fee estimates from bitcoind: {error}", diff --git a/test/bitcoind.test.ts b/test/bitcoind.test.ts new file mode 100644 index 0000000..1f0e5e7 --- /dev/null +++ b/test/bitcoind.test.ts @@ -0,0 +1,72 @@ +import { expect, test, mock } from "bun:test"; +import { BitcoindProvider } from "../src/bitcoind"; +import RpcClient from "bitcoind-rpc"; + +// Mock the RpcClient +const mockRpcClient: RpcClient = new RpcClient({ + protocol: "", + host: "", + port: "", + user: "", + pass: "", +}); +mock(mockRpcClient); + +// Mock the methods +mockRpcClient.getBlockCount = ( + cb: (error: any, result: BlockCountResponse) => void, +) => cb(null, { result: 1000 }); + +mockRpcClient.getBestBlockHash = ( + cb: (error: any, result: BestBlockHashResponse) => void, +) => + cb(null, { + result: "00000000000000000007d0f98d9edca880a6c1249057a01b78b182568c64005d", + }); + +mockRpcClient.estimateSmartFee = ( + target: number, + mode: string, + cb: (error: any, result: EstimateSmartFeeBatchResponse) => void, +) => cb(null, { result: { feerate: 1000 } }); + +const provider = new BitcoindProvider( + "http://localhost:18445", + "user", + "pass", + [2], +); + +// Override the rpc property with the mock +provider.rpc = mockRpcClient; + +test("getBlockHeight", async () => { + const result = await provider.getBlockHeight(); + expect(result).toBe(1000); +}); + +test("getBlockHash", async () => { + const result = await provider.getBlockHash(); + expect(result).toBe( + "00000000000000000007d0f98d9edca880a6c1249057a01b78b182568c64005d", + ); +}); + +test("getFeeEstimate", async () => { + const result = await provider.getFeeEstimate(2); + expect(result).toEqual(1000); +}); + +// test("getFeeEstimates", async () => { +// const result = await provider.getFeeEstimates(); +// expect(result).toEqual({ 2: 1000 }); +// }); + +// test("getAllData", async () => { +// const result = await provider.getAllData(); +// expect(result).toEqual({ +// blockHeight: 1000, +// blockHash: "00000000000000000007d0f98d9edca880a6c1249057a01b78b182568c64005d", +// feeEstimates: { 2: 1000, 3: 1000, 5: 1000 }, +// }); +// });