From 5ce0eaa2b6a51632ea4059196f00c099e1082bf3 Mon Sep 17 00:00:00 2001 From: Tom Kirkpatrick Date: Mon, 19 Feb 2024 15:39:33 +0000 Subject: [PATCH] Reorganise code layout --- src/DataProviderManager.ts | 120 ------- src/components/Content.tsx | 48 +++ src/components/Layout.tsx | 21 ++ src/components/index.ts | 2 + src/custom.d.ts | 70 ++-- src/lib/DataProviderManager.ts | 159 +++++++++ src/lib/logger.ts | 38 ++ src/lib/util.ts | 69 ++++ src/logger.ts | 26 -- src/{ => providers}/bitcoind.ts | 65 +++- src/{ => providers}/esplora.ts | 24 +- src/{ => providers}/mempool.ts | 21 +- src/server.tsx | 159 +++++---- src/util.ts | 574 ------------------------------- test/DataProviderManager.test.ts | 19 +- test/bitcoind.test.ts | 5 +- test/esplora.test.ts | 2 +- test/mempool.test.ts | 2 +- test/unit.test.ts | 89 ----- tsconfig.json | 2 +- 20 files changed, 571 insertions(+), 944 deletions(-) delete mode 100644 src/DataProviderManager.ts create mode 100644 src/components/Content.tsx create mode 100644 src/components/Layout.tsx create mode 100644 src/components/index.ts create mode 100644 src/lib/DataProviderManager.ts create mode 100644 src/lib/logger.ts create mode 100644 src/lib/util.ts delete mode 100644 src/logger.ts rename src/{ => providers}/bitcoind.ts (56%) rename src/{ => providers}/esplora.ts (83%) rename src/{ => providers}/mempool.ts (85%) delete mode 100644 src/util.ts delete mode 100644 test/unit.test.ts diff --git a/src/DataProviderManager.ts b/src/DataProviderManager.ts deleted file mode 100644 index ffc4e61..0000000 --- a/src/DataProviderManager.ts +++ /dev/null @@ -1,120 +0,0 @@ -import NodeCache from "node-cache"; -import { LOGLEVEL } from "./util"; -import { logger } from "./logger"; - -const log = logger(LOGLEVEL); - -export class DataPoint { - constructor( - public provider: Provider, - public blockHeight: number, - public blockHash: string, - public feeEstimates: FeeByBlockTarget, - ) {} -} - -export class MergedDataPoint { - constructor( - public blockHeight: number, - public blockHash: string, - public feeEstimates: FeeByBlockTarget, - ) {} -} - -export interface CacheConfig { - stdTTL: number; - checkperiod: number; -} - -export class DataProviderManager { - private providers: Provider[] = []; - private cache: NodeCache; - private heightDifferenceThreshold: number; - - constructor(cacheConfig: CacheConfig, heightDifferenceThreshold: number = 1) { - this.cache = new NodeCache(cacheConfig); - this.heightDifferenceThreshold = heightDifferenceThreshold; - } - - public registerProvider(provider: Provider) { - this.providers.push(provider); - } - - public async getMergedData(): Promise { - const dataPoints = await this.getRelevantDataPoints(); - - const blockHeight = dataPoints[0].blockHeight; - const blockHash = dataPoints[0].blockHash; - const feeEstimates = this.mergeFeeEstimates(dataPoints); - - return new MergedDataPoint(blockHeight, blockHash, feeEstimates); - } - - private async fetchDataPoints(): Promise { - return await Promise.all( - this.providers.map(async (p) => { - const blockHeight = await p.getBlockHeight(); - const blockHash = await p.getBlockHash(); - const feeEstimates = await p.getFeeEstimates(); - return new DataPoint(p, blockHeight, blockHash, feeEstimates); - }), - ); - } - - private async getSortedDataPoints(): Promise { - let dataPoints = this.cache.get("dataPoints"); - if (!dataPoints) { - dataPoints = await this.fetchDataPoints(); - this.cache.set("dataPoints", dataPoints); - } - dataPoints.sort( - (a, b) => - b.blockHeight - a.blockHeight || - this.providers.indexOf(a.provider) - this.providers.indexOf(b.provider), - ); - return dataPoints; - } - - private async getRelevantDataPoints(): Promise { - // Get sorted data points from all providers - const dataPoints = await this.getSortedDataPoints(); - - // Filter out providers that don't meet the relevancy threshold criteria - return dataPoints.filter( - (dp) => - dataPoints[0].blockHeight - dp.blockHeight <= - this.heightDifferenceThreshold, - ); - } - - private mergeFeeEstimates(dataPoints: DataPoint[]): FeeByBlockTarget { - // Start with the fee estimates from the most relevant provider - let mergedEstimates = { ...dataPoints[0].feeEstimates }; - log.debug({ msg: "Initial mergedEstimates:", mergedEstimates }); - - // Iterate over the remaining data points - for (let i = 1; i < dataPoints.length; i++) { - const estimates = dataPoints[i].feeEstimates; - const keys = Object.keys(estimates) - .map(Number) - .sort((a, b) => a - b); - log.debug({ msg: `Estimates for dataPoint ${i}:`, estimates }); - - keys.forEach((key) => { - // Only add the estimate if it has a higher confirmation target and a lower fee - if ( - key > Math.max(...Object.keys(mergedEstimates).map(Number)) && - estimates[key] < Math.min(...Object.values(mergedEstimates)) - ) { - log.debug({ - msg: `Adding estimate with target ${key} and fee ${estimates[key]} to mergedEstimates`, - }); - mergedEstimates[key] = estimates[key]; - } - }); - } - - log.debug({ msg: "Final mergedEstimates:", mergedEstimates }); - return mergedEstimates; - } -} diff --git a/src/components/Content.tsx b/src/components/Content.tsx new file mode 100644 index 0000000..ea6da5e --- /dev/null +++ b/src/components/Content.tsx @@ -0,0 +1,48 @@ +import { raw } from "hono/html"; +import { Layout } from "./Layout"; +import { BlockchainData } from "../lib/DataProviderManager"; + +export const Content = (props: { + siteData: SiteData; + data: BlockchainData; +}) => ( + + + +
+

{props.siteData.title}

+

{props.siteData.subtitle}

+
+ +
+
+        curl -L -X GET{" "}
+        '{props.siteData.baseUrl}/v1/fee-estimates'
+      
+ +
{raw(JSON.stringify(props.data, null, 2))}
+
+ + +
+); diff --git a/src/components/Layout.tsx b/src/components/Layout.tsx new file mode 100644 index 0000000..d947b58 --- /dev/null +++ b/src/components/Layout.tsx @@ -0,0 +1,21 @@ +export const Layout = (props: SiteData) => { + return ( + + + {props.title} + + + + + + + + + + {props.children} + + ); +}; diff --git a/src/components/index.ts b/src/components/index.ts new file mode 100644 index 0000000..90baa7b --- /dev/null +++ b/src/components/index.ts @@ -0,0 +1,2 @@ +export * from "./Content"; +export * from "./Layout"; diff --git a/src/custom.d.ts b/src/custom.d.ts index 6dccaff..db902c4 100644 --- a/src/custom.d.ts +++ b/src/custom.d.ts @@ -1,4 +1,24 @@ -// MempoolFeeEstimates represents the fee estimates for different transaction speeds. +interface Provider { + getBlockHeight(): Promise; + getBlockHash(): Promise; + getFeeEstimates(): Promise; + getAllData(): Promise; +} + +type DataPoint = { + provider: Provider; + blockHeight: number; + blockHash: string; + feeEstimates: FeeByBlockTarget; +}; + +// CacheConfig represents the configuration for the cache. +type CacheConfig = { + stdTTL: number; + checkperiod: number; +}; + +// MempoolFeeEstimates represents the data returned by the Mempool API. type MempoolFeeEstimates = { [key: string]: number; // dynamic keys with number as value (sat/vb) fastestFee: number; // fee for the fastest transaction speed (sat/vb) @@ -8,6 +28,7 @@ type MempoolFeeEstimates = { minimumFee: number; // minimum relay fee (sat/vb) }; +// MempoolFeeEstimates represents the data returned by the Esplora API. type EsploraFeeEstimates = { [key: string]: number; }; @@ -17,28 +38,12 @@ type FeeByBlockTarget = { [target: string]: number; // fees by confirmation target }; -// Estimates represents the current block hash and fee by block target. -type Estimates = { - current_block_hash: string | null; // current block hash - current_block_height: number | null; // current block height - fee_by_block_target: FeeByBlockTarget; // fee by block target (in sat/kb) -}; - -// BlockTargetMapping represents the mapping of block targets. -type BlockTargetMapping = { - [key: number]: string; // dynamic numeric keys with string as value -}; - -// SiteData represents the data of a site. -interface SiteData { - title: string; // title of the site - subtitle: string; // subtitle of the site - children?: any; // children of the site (optional) -} - // ExpectedResponseType represents the expected response type for an http request. type ExpectedResponseType = "json" | "text"; // can be either 'json' or 'text' +// EstimateMode represents the mode for fee estimation. +type EstimateMode = "ECONOMICAL" | "CONSERVATIVE"; // estimate mode can be either 'ECONOMICAL' or 'CONSERVATIVE' + // BatchRequest represents a bitcoind batch request response. interface EstimateSmartFeeBatchResponse { result?: EstimateSmartFeeResponse; @@ -60,18 +65,23 @@ interface BestBlockHashResponse { result: string; } -// EstimateMode represents the mode for fee estimation. -type EstimateMode = "ECONOMICAL" | "CONSERVATIVE"; // estimate mode can be either 'ECONOMICAL' or 'CONSERVATIVE' - -interface Provider { - getBlockHeight(): Promise; - getBlockHash(): Promise; - getFeeEstimates(): Promise; - getAllData(): Promise; -} - type ProviderData = { blockHeight: number; blockHash: string; feeEstimates: FeeByBlockTarget; }; + +// Estimates represents the current block hash and fee by block target. +type Estimates = { + current_block_hash: string | null; // current block hash + current_block_height: number | null; // current block height + fee_by_block_target: FeeByBlockTarget; // fee by block target (in sat/kb) +}; + +// SiteData represents the data of a site. +interface SiteData { + baseUrl: string; // base url of the site + title: string; // title of the site + subtitle: string; // subtitle of the site + children?: any; // children of the site (optional) +} diff --git a/src/lib/DataProviderManager.ts b/src/lib/DataProviderManager.ts new file mode 100644 index 0000000..acd731a --- /dev/null +++ b/src/lib/DataProviderManager.ts @@ -0,0 +1,159 @@ +import NodeCache from "node-cache"; +import { LOGLEVEL } from "./util"; +import { logger } from "./logger"; + +const log = logger(LOGLEVEL); + +export class DataProviderManager { + private providers: Provider[] = []; + private cache: NodeCache; + private feeMultiplier: number; + private feeMinimum: number; + private cacheKey: string = "data"; + + constructor( + cacheConfig: CacheConfig, + feeMultiplier: number = 1, + feeMinimum: number = 1, + ) { + this.cache = new NodeCache(cacheConfig); + this.feeMultiplier = feeMultiplier; + this.feeMinimum = feeMinimum; + } + + /** + * Registers a new data provider. + * + * @param provider - The data provider to register. + */ + public registerProvider(provider: Provider) { + this.providers.push(provider); + } + + /** + * Gets data from the cache or fetches it from the providers if it's not in the cache. + * + * @returns A promise that resolves to the fetched data. + */ + public async getData(): Promise { + let data = this.cache.get("data"); + + if (data) { + log.info({ message: "Got data from cache", data }); + return data; + } + + const dataPoints = await this.getSortedDataPoints(); + const blockHeight = dataPoints[0].blockHeight; + const blockHash = dataPoints[0].blockHash; + const feeEstimates = this.mergeFeeEstimates(dataPoints); + + // Apply the fee minimum and multiplier. + for (let [blockTarget, estimate] of Object.entries(feeEstimates)) { + if (estimate >= this.feeMinimum) { + feeEstimates[blockTarget] = Math.ceil( + (estimate *= 1000 * this.feeMultiplier), + ); + } else { + log.warn({ + msg: `Fee estimate for target ${blockTarget} was below the minimum of ${this.feeMinimum}.`, + }); + } + } + + data = { + current_block_height: blockHeight, + current_block_hash: blockHash, + fee_by_block_target: feeEstimates, + }; + + this.cache.set(this.cacheKey, data); + log.info({ message: "Got data", data }); + + return data; + } + + /** + * Fetches data points from all registered providers. + * + * @returns A promise that resolves to an array of fetched data points. + */ + private async fetchDataPoints(): Promise { + const dataPoints = await Promise.all( + this.providers.map(async (p) => { + try { + const blockHeight = await p.getBlockHeight(); + const blockHash = await p.getBlockHash(); + const feeEstimates = await p.getFeeEstimates(); + + return { + provider: p, + blockHeight, + blockHash, + feeEstimates, + } as DataPoint; + } catch (error) { + console.error( + `Error fetching data from provider ${p.constructor.name}: ${error}`, + ); + return null; + } + }), + ); + + // Filter out null results and return + return dataPoints.filter((dp) => dp !== null) as DataPoint[]; + } + + /** + * Gets sorted data points from the cache or fetches them from the providers if they're not in the cache. + * + * @returns A promise that resolves to an array of sorted data points. + */ + private async getSortedDataPoints(): Promise { + const dataPoints = await this.fetchDataPoints(); + dataPoints.sort( + (a, b) => + b.blockHeight - a.blockHeight || + this.providers.indexOf(a.provider) - this.providers.indexOf(b.provider), + ); + return dataPoints; + } + + /** + * Merges fee estimates from multiple data points. + * + * @param dataPoints - An array of data points from which to merge fee estimates. + * @returns An object containing the merged fee estimates. + */ + private mergeFeeEstimates(dataPoints: DataPoint[]): FeeByBlockTarget { + // Start with the fee estimates from the most relevant provider + let mergedEstimates = { ...dataPoints[0].feeEstimates }; + log.debug({ msg: "Initial mergedEstimates:", mergedEstimates }); + // Iterate over the remaining data points + for (let i = 1; i < dataPoints.length; i++) { + const estimates = dataPoints[i].feeEstimates; + const providerName = dataPoints[i].provider.constructor.name; + const keys = Object.keys(estimates) + .map(Number) + .sort((a, b) => a - b); + log.debug({ msg: `Estimates for dataPoint ${providerName}`, estimates }); + + keys.forEach((key) => { + // Only add the estimate if it has a higher confirmation target and a lower fee + if ( + key > Math.max(...Object.keys(mergedEstimates).map(Number)) && + estimates[key] < Math.min(...Object.values(mergedEstimates)) + ) { + log.debug({ + msg: `Adding estimate from ${providerName} with target ${key} and fee ${estimates[key]} to mergedEstimates`, + }); + mergedEstimates[key] = estimates[key]; + } + }); + } + + log.debug({ msg: "Final mergedEstimates:", mergedEstimates }); + return mergedEstimates; + } +} diff --git a/src/lib/logger.ts b/src/lib/logger.ts new file mode 100644 index 0000000..dd4f1ef --- /dev/null +++ b/src/lib/logger.ts @@ -0,0 +1,38 @@ +import pino, { type Logger } from "pino"; + +/** + * Creates a new logger with the specified log level. + * + * This function uses the `pino` library to create a new logger. The log level, message key, + * formatters, and redact options are set in the `pinoOptions` object. If the `NODE_ENV` + * environment variable is set to 'production', the logger is created with these options. + * Otherwise, it attempts to create a logger with pretty-printing enabled. If this fails, + * it falls back to creating a logger without pretty-printing. + * + * @param loglevel - The log level to set for the logger. + * @returns A new logger with the specified log level. + */ +export function logger(loglevel: string): Logger { + let log: Logger; + const pinoOptions = { + level: loglevel, + messageKey: "message", + formatters: { + level: (label: string) => { + return { level: label }; + }, + }, + redact: ["bitcoind.password"], + }; + if (process.env.NODE_ENV === "production") { + log = pino(pinoOptions); + } else { + try { + const pretty = require("pino-pretty"); + log = pino(pinoOptions, pretty()); + } catch (error) { + log = pino(pinoOptions); + } + } + return log; +} diff --git a/src/lib/util.ts b/src/lib/util.ts new file mode 100644 index 0000000..fdcc346 --- /dev/null +++ b/src/lib/util.ts @@ -0,0 +1,69 @@ +import config from "config"; +import { logger } from "./logger"; + +export const TIMEOUT = config.get("settings.timeout"); +export const LOGLEVEL = config.get("settings.loglevel"); + +const log = logger(LOGLEVEL); + +/** + * Fetches a resource from a URL with a timeout. + * + * @param url - The URL of the resource to fetch. + * @param timeout - The maximum time (in milliseconds) to wait for the fetch to complete. Defaults to `TIMEOUT`. + * @returns A promise that resolves to the fetched resource. + * + * @throws Will throw an error if the fetch request times out. + * + * @remarks Note: fetch signal abortcontroller does not work on Bun. + * See {@link https://github.com/oven-sh/bun/issues/2489} + */ +export async function fetchWithTimeout( + url: string, + timeout: number = TIMEOUT, +): Promise { + log.debug({ message: `Starting fetch request to ${url}` }); + const fetchPromise = fetch(url); + const timeoutPromise = new Promise((_, reject) => + setTimeout( + () => reject(new Error(`Request timed out after ${timeout} ms`)), + timeout, + ), + ); + + return Promise.race([fetchPromise, timeoutPromise]) as Promise; +} + +/** + * Fetches data from a specific URL with a specified response type and timeout. + * + * This function uses the `fetchWithTimeout` function to fetch data from the given URL. + * If the response is not OK (status code is not in the range 200-299), it throws an error. + * Depending on the `responseType` parameter, it either parses the response as JSON or as text. + * + * @param url - The URL to fetch data from. + * @param responseType - The expected type of the response ('json' or 'text'). + * @param timeout - The maximum time (in milliseconds) to wait for the fetch to complete. + * @returns A promise that resolves to the fetched data. + * + * @throws Will throw an error if the fetch request times out, or if the response status is not OK. + */ +export async function fetchData( + url: string, + responseType: ExpectedResponseType, + timeout: number, +): Promise { + try { + const response = await fetchWithTimeout(url, timeout); + if (!response.ok) { + throw new Error(`HTTP error! status: ${response.status}`); + } + const data = await (responseType === "json" + ? response.json() + : response.text()); + return data as T; + } catch (error) { + log.error({ msg: `Error fetching data from ${url}:`, error }); + throw error; + } +} diff --git a/src/logger.ts b/src/logger.ts deleted file mode 100644 index c04ffad..0000000 --- a/src/logger.ts +++ /dev/null @@ -1,26 +0,0 @@ -import pino, { type Logger } from "pino"; - -export function logger(loglevel: string): Logger { - let log: Logger; - const pinoOptions = { - level: loglevel, - messageKey: "message", - formatters: { - level: (label: string) => { - return { level: label }; - }, - }, - redact: ["bitcoind.password"], - }; - if (process.env.NODE_ENV === "production") { - log = pino(pinoOptions); - } else { - try { - const pretty = require("pino-pretty"); - log = pino(pinoOptions, pretty()); - } catch (error) { - log = pino(pinoOptions); - } - } - return log; -} diff --git a/src/bitcoind.ts b/src/providers/bitcoind.ts similarity index 56% rename from src/bitcoind.ts rename to src/providers/bitcoind.ts index 833c014..1c9c241 100644 --- a/src/bitcoind.ts +++ b/src/providers/bitcoind.ts @@ -1,21 +1,45 @@ import RpcClient from "bitcoind-rpc"; -import { LOGLEVEL, BITCOIND_ESTIMATE_MODE } from "./util"; -import { logger } from "./logger"; +import { LOGLEVEL } from "../lib/util"; +import { logger } from "../lib/logger"; import { promisify } from "util"; const log = logger(LOGLEVEL); +/** + * A class that provides data from a Bitcoind server. + * + * The `BitcoindProvider` class fetches data such as the current block height, block hash, + * and fee estimates from a Bitcoind server. It provides methods to fetch each of these + * data points individually, as well as a method to fetch all of them at once. + * + * @example + * const provider = new BitcoindProvider('http://localhost:8332'); + * const data = await provider.getAllData(); + */ export class BitcoindProvider implements Provider { public rpc: RpcClient; private targets: number[]; - - constructor(url: string, user: string, pass: string, targets: number[]) { + private mode: EstimateMode; + + constructor( + url: string, + user: string, + pass: string, + targets: number[], + mode: EstimateMode = "ECONOMICAL", + ) { let { protocol, hostname: host, port } = new URL(url); protocol = protocol.replace(/.$/, ""); this.rpc = new RpcClient({ protocol, host, port, user, pass }); this.targets = targets; + this.mode = mode; } + /** + * Fetches the current block height from the Bitcoind server. + * + * @returns A promise that resolves to the current block height. + */ async getBlockHeight(): Promise { const getBlockCount = promisify(this.rpc.getBlockCount.bind(this.rpc)); @@ -25,6 +49,11 @@ export class BitcoindProvider implements Provider { return response.result; } + /** + * Fetches the current block hash from the Bitcoind server. + * + * @returns A promise that resolves to the current block hash. + */ async getBlockHash(): Promise { const getBestBlockHash = promisify( this.rpc.getBestBlockHash.bind(this.rpc), @@ -36,26 +65,37 @@ export class BitcoindProvider implements Provider { return response.result; } + /** + * Fetches a fee estimate from the Bitcoind server. + * + * @returns A promise that resolves to the fetched fee estimate. + */ async getFeeEstimate(target: number): Promise { const estimateSmartFee = promisify( this.rpc.estimateSmartFee.bind(this.rpc), ); - const response = await estimateSmartFee(target, BITCOIND_ESTIMATE_MODE); + const response = await estimateSmartFee(target, this.mode); log.trace({ msg: "estimateSmartFee", response: response.result }); return response.result?.feerate; } + /** + * Fetches fee estimates from the Bitcoind server. + * + * @returns A promise that resolves to the fetched fee estimates. + */ async getFeeEstimates(): Promise { const batch = promisify(this.rpc.batch.bind(this.rpc)); const targets = this.targets; const rpc = this.rpc; + const mode = this.mode; function batchCall() { targets.forEach(function (target) { - rpc.estimateSmartFee(target, BITCOIND_ESTIMATE_MODE); + rpc.estimateSmartFee(target, mode); }); } @@ -68,8 +108,8 @@ export class BitcoindProvider implements Provider { 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; + // convert the returned value to kvb, as it's currently returned in BTC. + fees[target] = (feeRate * 1e8) / 1000; } else { throw new Error(responses[i].result?.errors[0]); } @@ -90,10 +130,19 @@ export class BitcoindProvider implements Provider { return fees; } + /** + * Fetches all data from the Bitcoind server. + * + * This method fetches the current block height, block hash, and fee estimates from the Bitcoind server. + * It then returns an object containing this data. + * + * @returns A promise that resolves to an object containing the block height, block hash, and fee estimates. + */ 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/esplora.ts b/src/providers/esplora.ts similarity index 83% rename from src/esplora.ts rename to src/providers/esplora.ts index e5f9940..cc6f53f 100644 --- a/src/esplora.ts +++ b/src/providers/esplora.ts @@ -1,11 +1,21 @@ -import { fetchData, LOGLEVEL } from "./util"; -import { logger } from "./logger"; +import { fetchData, LOGLEVEL } from "../lib/util"; +import { logger } from "../lib/logger"; const log = logger(LOGLEVEL); /** - * EsploraProvider class implements the Provider interface. - * It provides methods to fetch data from a Esplora API. + * A class that provides data from an Esplora server. + * + * The `EsploraProvider` class fetches data such as the current block height, block hash, + * and fee estimates from an Esplora server. It provides methods to fetch each of these + * data points individually, as well as a method to fetch all of them at once. + * + * This class implements the `Provider` interface, ensuring it provides all necessary + * methods and properties for a data provider. + * + * @example + * const provider = new EsploraProvider('https://blockstream.info/api/'); + * const data = await provider.getAllData(); */ export class EsploraProvider implements Provider { private url: string; @@ -14,6 +24,7 @@ export class EsploraProvider implements Provider { /** * Constructs a new EsploraProvider. + * * @param url - The base URL of the Esplora API. * @param defaultDepth - The default depth for fee estimates. * @param defaultTimeout - The default timeout for fetch requests. @@ -30,6 +41,7 @@ export class EsploraProvider implements Provider { /** * Fetches fee estimates from the Esplora API. + * * @param maxDepth - The maximum depth for fee estimates. * @returns A promise that resolves to an object of fee estimates. */ @@ -51,6 +63,7 @@ export class EsploraProvider implements Provider { /** * Fetches the current block height from the Esplora API. + * * @returns A promise that resolves to the current block height. */ async getBlockHeight(): Promise { @@ -69,6 +82,7 @@ export class EsploraProvider implements Provider { /** * Fetches the current block hash from the Esplora API. + * * @returns A promise that resolves to the current block hash. */ async getBlockHash(): Promise { @@ -90,6 +104,7 @@ export class EsploraProvider implements Provider { /** * Fetches all data (block height, block hash, and fee estimates) from the Esplora API. + * * @returns A promise that resolves to an object of all data. */ public async getAllData(): Promise { @@ -113,6 +128,7 @@ export class EsploraProvider implements Provider { /** * Transforms the fetched fee data into a FeeByBlockTarget object. + * * @param data - The fetched fee data. * @param maxDepth - The maximum depth for fee estimates. * @returns A FeeByBlockTarget object. diff --git a/src/mempool.ts b/src/providers/mempool.ts similarity index 85% rename from src/mempool.ts rename to src/providers/mempool.ts index 63b6146..e42d6cb 100644 --- a/src/mempool.ts +++ b/src/providers/mempool.ts @@ -1,11 +1,18 @@ -import { fetchData, LOGLEVEL } from "./util"; -import { logger } from "./logger"; +import { fetchData, LOGLEVEL } from "../lib/util"; +import { logger } from "../lib/logger"; const log = logger(LOGLEVEL); /** - * MempoolProvider class implements the Provider interface. - * It provides methods to fetch data from a Mempool API. + * A class that provides data from a Mempool server. + * + * The `MempoolProvider` class fetches data such as the current block height, block hash, + * and fee estimates from a Mempool server. It provides methods to fetch each of these + * data points individually, as well as a method to fetch all of them at once. + * + * @example + * const provider = new MempoolProvider('https://mempool.space/api/'); + * const data = await provider.getAllData(); */ export class MempoolProvider implements Provider { private url: string; @@ -14,6 +21,7 @@ export class MempoolProvider implements Provider { /** * Constructs a new MempoolProvider. + * * @param url - The base URL of the Mempool API. * @param defaultDepth - The default depth for fee estimates. * @param defaultTimeout - The default timeout for fetch requests. @@ -30,6 +38,7 @@ export class MempoolProvider implements Provider { /** * Fetches fee estimates from the Mempool API. + * * @param maxDepth - The maximum depth for fee estimates. * @returns A promise that resolves to an object of fee estimates. */ @@ -55,6 +64,7 @@ export class MempoolProvider implements Provider { /** * Fetches the current block height from the Mempool API. + * * @returns A promise that resolves to the current block height. */ async getBlockHeight(): Promise { @@ -73,6 +83,7 @@ export class MempoolProvider implements Provider { /** * Fetches the current block hash from the Mempool API. + * * @returns A promise that resolves to the current block hash. */ async getBlockHash(): Promise { @@ -94,6 +105,7 @@ export class MempoolProvider implements Provider { /** * Fetches all data (block height, block hash, and fee estimates) from the Mempool API. + * * @returns A promise that resolves to an object of all data. */ public async getAllData(): Promise { @@ -117,6 +129,7 @@ export class MempoolProvider implements Provider { /** * Transforms the fetched fee data into a FeeByBlockTarget object. + * * @param data - The fetched fee data. * @param maxDepth - The maximum depth for fee estimates. * @returns A FeeByBlockTarget object. diff --git a/src/server.tsx b/src/server.tsx index c6ac51e..ae23ff7 100644 --- a/src/server.tsx +++ b/src/server.tsx @@ -1,90 +1,96 @@ import { Hono } from "hono"; -import { raw } from "hono/html"; import { logger as honoLogger } from "hono/logger"; import { etag } from "hono/etag"; import { cors } from "hono/cors"; import { serveStatic } from "hono/bun"; import config from "config"; -import { getEstimates, BASE_URL, CACHE_STDTTL, PORT, LOGLEVEL } from "./util"; -import { logger } from "./logger"; +import { logger } from "./lib/logger"; +import { DataProviderManager } from "./lib/DataProviderManager"; +import { MempoolProvider } from "./providers/mempool"; +import { EsploraProvider } from "./providers/esplora"; +import { BitcoindProvider } from "./providers/bitcoind"; +import { Content } from "./components"; + +// Get application configuration values. +const PORT = config.get("server.port"); +const BASE_URL = config.get("server.baseUrl"); + +const ESPLORA_BASE_URL = config.get("esplora.baseUrl"); +const ESPLORA_FALLBACK_BASE_URL = config.get("esplora.fallbackBaseUrl"); + +const MEMPOOL_BASE_URL = config.get("mempool.baseUrl"); +const MEMPOOL_FALLBACK_BASE_URL = config.get("mempool.fallbackBaseUrl"); +const MEMPOOL_DEPTH = config.get("mempool.depth"); + +const BITCOIND_BASE_URL = config.get("bitcoind.baseUrl"); +const BITCOIND_USERNAME = config.get("bitcoind.username"); +const BITCOIND_PASSWORD = config.get("bitcoind.password"); +const BITCOIND_CONF_TARGETS = config.get("bitcoind.confTargets"); +const BITCOIND_ESTIMATE_MODE = config.get( + "bitcoind.estimateMode", +); + +const LOGLEVEL = config.get("settings.loglevel"); +const TIMEOUT = config.get("settings.timeout"); +const FEE_MULTIPLIER = config.get("settings.feeMultiplier"); +const FEE_MINIMUM = config.get("settings.feeMinimum"); +const CACHE_STDTTL = config.get("cache.stdTTL"); +const CACHE_CHECKPERIOD = config.get("cache.checkperiod"); const log = logger(LOGLEVEL); // Log the configuration values. log.info(config.util.toObject()); -// Define the layout components. - -const Layout = (props: SiteData) => { - return ( - - - {props.title} - - - - - - - - - - {props.children} - +// Register data provider service. +const service = new DataProviderManager( + { + stdTTL: CACHE_STDTTL, + checkperiod: CACHE_CHECKPERIOD, + }, + FEE_MULTIPLIER, + FEE_MINIMUM, +); + +// Register data providers. +MEMPOOL_BASE_URL && + service.registerProvider( + new MempoolProvider(MEMPOOL_BASE_URL, MEMPOOL_DEPTH, TIMEOUT), ); -}; -const Content = (props: { siteData: SiteData; estimates: Estimates }) => ( - - - -
-

{props.siteData.title}

-

{props.siteData.subtitle}

-
- -
-
-        curl -L -X GET{" "}
-        '{BASE_URL}/v1/fee-estimates'
-      
- -
{raw(JSON.stringify(props.estimates, null, 2))}
-
- - -
-); +MEMPOOL_FALLBACK_BASE_URL && + service.registerProvider( + new MempoolProvider(MEMPOOL_FALLBACK_BASE_URL, MEMPOOL_DEPTH, TIMEOUT), + ); + +ESPLORA_BASE_URL && + service.registerProvider( + new EsploraProvider(ESPLORA_BASE_URL, 1008, TIMEOUT), + ); + +ESPLORA_FALLBACK_BASE_URL && + service.registerProvider( + new EsploraProvider(ESPLORA_FALLBACK_BASE_URL, 1008, TIMEOUT), + ); + +BITCOIND_BASE_URL && + service.registerProvider( + new BitcoindProvider( + BITCOIND_BASE_URL, + BITCOIND_USERNAME, + BITCOIND_PASSWORD, + BITCOIND_CONF_TARGETS, + BITCOIND_ESTIMATE_MODE, + ), + ); // Define the app. // Initialize the Express app. const app = new Hono(); -log.info(`Fee Estimates available at ${BASE_URL}/v1/fee-estimates`); +log.info({ msg: `Fee Estimates available at ${BASE_URL}/v1/fee-estimates` }); +log.info({ msg: `Website available at ${BASE_URL}` }); // Add a health/ready endpoint. app.get("/health/ready", async (c) => { @@ -108,28 +114,29 @@ app.use("/static/*", serveStatic({ root: "./" })); * Returns the current fee estimates for the Bitcoin network, rendered as HTML. */ app.get("/", async (c) => { - let estimates: Estimates; + let data: Estimates; try { - estimates = await getEstimates(); - + data = await service.getData(); // Set cache headers. c.res.headers.set("Cache-Control", `public, max-age=${CACHE_STDTTL}`); } catch (error) { log.error(error); - estimates = { - current_block_hash: null, + data = { + current_block_height: 0, + current_block_hash: "", fee_by_block_target: {}, }; } const props = { siteData: { + baseUrl: BASE_URL, title: "Bitcoin Blended Fee Estimator", subtitle: "A blend of mempool-based and history-based Bitcoin fee estimates.", }, - estimates, + data, }; return c.html(); @@ -139,14 +146,16 @@ app.get("/", async (c) => { * Returns the current fee estimates for the Bitcoin network, rendered as JSON. */ app.get("/v1/fee-estimates", async (c) => { + let data: Estimates; + try { - let estimates = await getEstimates(); + data = await service.getData(); // Set cache headers. c.res.headers.set("Cache-Control", `public, max-age=${CACHE_STDTTL}`); // Return the estimates. - return c.json(estimates); + return c.json(data); } catch (error) { log.error(error); return c.text("Error fetching fee estimates", 500); diff --git a/src/util.ts b/src/util.ts deleted file mode 100644 index 46d5ed4..0000000 --- a/src/util.ts +++ /dev/null @@ -1,574 +0,0 @@ -import config from "config"; -import NodeCache from "node-cache"; -import RpcClient from "bitcoind-rpc"; -import { logger } from "./logger"; - -// Get application configuration values from the config package. -export const PORT = config.get("server.port"); -export const BASE_URL = config.get("server.baseUrl"); - -export const ESPLORA_BASE_URL = config.get("esplora.baseUrl"); -export const ESPLORA_FALLBACK_BASE_URL = config.get( - "esplora.fallbackBaseUrl", -); - -export const MEMPOOL_BASE_URL = config.get("mempool.baseUrl"); -export const MEMPOOL_FALLBACK_BASE_URL = config.get( - "mempool.fallbackBaseUrl", -); -export const MEMPOOL_DEPTH = config.get("mempool.depth"); - -export const BITCOIND_BASE_URL = config.get("bitcoind.baseUrl"); -export const BITCOIND_USERNAME = config.get("bitcoind.username"); -export const BITCOIND_PASSWORD = config.get("bitcoind.password"); -export const BITCOIND_CONF_TARGETS = config.get( - "bitcoind.confTargets", -); -export const BITCOIND_ESTIMATE_MODE = config.get( - "bitcoind.estimateMode", -); - -export const LOGLEVEL = config.get("settings.loglevel"); -export const TIMEOUT = config.get("settings.timeout"); -export const FEE_MULTIPLIER = config.get("settings.feeMultiplier"); -export const FEE_MINIMUM = config.get("settings.feeMinimum"); -export const CACHE_STDTTL = config.get("cache.stdTTL"); -export const CACHE_CHECKPERIOD = config.get("cache.checkperiod"); - -// Primary URLs -export const MEMPOOL_TIP_HASH_URL = - MEMPOOL_BASE_URL && `${MEMPOOL_BASE_URL}/api/blocks/tip/hash`; -export const ESPLORA_TIP_HASH_URL = - ESPLORA_BASE_URL && `${ESPLORA_BASE_URL}/api/blocks/tip/hash`; - -export const MEMPOOL_TIP_HEIGHT_URL = - MEMPOOL_BASE_URL && `${MEMPOOL_BASE_URL}/api/blocks/tip/height`; -export const ESPLORA_TIP_HEIGHT_URL = - ESPLORA_BASE_URL && `${ESPLORA_BASE_URL}/api/blocks/tip/height`; - -export const MEMPOOL_FEES_URL = - MEMPOOL_BASE_URL && `${MEMPOOL_BASE_URL}/api/v1/fees/recommended`; -export const ESPLORA_FEE_ESTIMATES_URL = - ESPLORA_BASE_URL && `${ESPLORA_BASE_URL}/api/fee-estimates`; - -// Fallback URLs -export const MEMPOOL_TIP_HASH_URL_FALLBACK = - MEMPOOL_FALLBACK_BASE_URL && - `${MEMPOOL_FALLBACK_BASE_URL}/api/blocks/tip/hash`; -export const ESPLORA_TIP_HASH_URL_FALLBACK = - ESPLORA_FALLBACK_BASE_URL && - `${ESPLORA_FALLBACK_BASE_URL}/api/blocks/tip/hash`; - -export const MEMPOOL_TIP_HEIGHT_URL_FALLBACK = - MEMPOOL_FALLBACK_BASE_URL && - `${MEMPOOL_FALLBACK_BASE_URL}/api/blocks/tip/height`; -export const ESPLORA_TIP_HEIGHT_URL_FALLBACK = - ESPLORA_FALLBACK_BASE_URL && - `${ESPLORA_FALLBACK_BASE_URL}/api/blocks/tip/height`; - -export const MEMPOOL_FEES_URL_FALLBACK = - MEMPOOL_FALLBACK_BASE_URL && - `${MEMPOOL_FALLBACK_BASE_URL}/api/v1/fees/recommended`; -export const ESPLORA_FEE_ESTIMATES_URL_FALLBACK = - ESPLORA_FALLBACK_BASE_URL && `${ESPLORA_FALLBACK_BASE_URL}/api/fee-estimates`; - -const log = logger(LOGLEVEL); - -// Initialize the cache. -export const CACHE_KEY = "estimates"; -const cache = new NodeCache({ - stdTTL: CACHE_STDTTL, - checkperiod: CACHE_CHECKPERIOD, -}); - -/** - * Helper function to extract value from a fulfilled promise. - */ -export function getValueFromFulfilledPromise( - result: PromiseSettledResult, -) { - return result && result.status === "fulfilled" && result.value - ? result.value - : null; -} - -// NOTE: fetch signal abortcontroller does not work on Bun. -// See https://github.com/oven-sh/bun/issues/2489 -export async function fetchWithTimeout( - url: string, - timeout: number = TIMEOUT, -): Promise { - log.debug({ message: `Starting fetch request to ${url}` }); - const fetchPromise = fetch(url); - const timeoutPromise = new Promise((_, reject) => - setTimeout( - () => reject(new Error(`Request timed out after ${timeout} ms`)), - timeout, - ), - ); - - return Promise.race([fetchPromise, timeoutPromise]) as Promise; -} - -/** - * Fetches data from a specific endpoint of the Esplora API. - * @param endpoint - The endpoint to fetch data from. - * @param responseType - The type of the response ('json' or 'text'). - * @returns A promise that resolves to the fetched data. - */ -export async function fetchData( - url: string, - responseType: "json" | "text", - timeout: number, -): Promise { - try { - const response = await fetchWithTimeout(url, timeout); - const data = await (responseType === "json" - ? response.json() - : response.text()); - return data as T; - } catch (error) { - log.error({ msg: `Error fetching data from ${url}:`, error }); - throw error; - } -} - -/** - * Fetches data from the given URL and validates and processes the response. - */ -export async function fetchAndProcess( - url: string, - expectedResponseType: ExpectedResponseType, -): Promise { - const response = await fetchWithTimeout(url, TIMEOUT); - if (!response.ok) { - throw new Error(`HTTP error! status: ${response.status}`); - } - log.debug({ message: `Successfully fetched data from ${url}` }); - - const contentType = response.headers.get("content-type"); - if ( - expectedResponseType === "json" && - contentType?.includes("application/json") - ) { - return response.json(); - } else if ( - expectedResponseType === "text" && - contentType?.includes("text/plain") - ) { - const text = await response.text(); - const trimmedText = text.trim(); - if (trimmedText.includes("\n") || text !== trimmedText) { - throw new Error( - "Response is not a single text string with no whitespace or newlines", - ); - } - return trimmedText; - } else { - throw new Error( - `Unexpected response type. Expected ${expectedResponseType}, but received ${contentType}`, - ); - } -} - -/** - * Fetches data from the given URL with a timeout, fallback, and error handling. - */ -export async function fetchAndHandle( - url: string, - expectedResponseType: ExpectedResponseType, - fallbackUrl?: string, -): Promise { - try { - const timeout = new Promise((resolve) => - setTimeout(resolve, TIMEOUT, "timeout"), - ); - const fetchPromise = fetchAndProcess(url, expectedResponseType); - const result = (await Promise.race([fetchPromise, timeout])) as - | Promise - | string; - - if (result === "timeout" || result instanceof Error) { - throw new Error("Fetch or timeout error"); - } - - return result; - } catch (error) { - if (fallbackUrl) { - log.debug({ message: "Trying fallback URL: ${fallbackUrl}" }); - return fetchAndProcess(fallbackUrl, expectedResponseType); - } else { - throw new Error( - `Fetch request to ${url} failed and no fallback URL was provided.`, - ); - } - } -} - -/** - * Fetches mempool fees. - */ -export async function fetchMempoolData(): Promise { - const tasks = [ - MEMPOOL_FEES_URL && fetchAndHandle(MEMPOOL_FEES_URL, "json"), - MEMPOOL_FEES_URL_FALLBACK && - fetchAndHandle(MEMPOOL_FEES_URL_FALLBACK, "json"), - ].filter(Boolean); - if (tasks.length === 0) { - return null; - } - - const results = await Promise.allSettled(tasks); - log.debug({ message: "Fetched data from mempool: {results}", results }); - - let res0 = getValueFromFulfilledPromise(results[0]); - let res1 = getValueFromFulfilledPromise(results[1]); - - // If all of the response properties are 1, then the response is an error (probably the mempool data is not available). - const isRes0Invalid = - !res0 || Object.values(res0).every((value) => value === 1); - const isRes1Invalid = - !res1 || Object.values(res1).every((value) => value === 1); - - // Return a response that is valid, or null if both responses are invald. - let data; - if (!isRes0Invalid) { - data = res0; - } else { - data = isRes1Invalid ? null : res1; - } - log.info({ message: "Using data from mempool: {data}", data }); - return data; -} - -/** - * Fetches esplora fees. - */ -export async function fetchEsploraData(): Promise { - const tasks = [ - ESPLORA_FEE_ESTIMATES_URL && - fetchAndHandle(ESPLORA_FEE_ESTIMATES_URL, "json"), - ESPLORA_FEE_ESTIMATES_URL_FALLBACK && - fetchAndHandle(ESPLORA_FEE_ESTIMATES_URL_FALLBACK, "json"), - ].filter(Boolean); - if (tasks.length === 0) { - return null; - } - const results = await Promise.allSettled(tasks); - log.debug({ message: "Fetched data from esplora: {results}", results }); - - let res0 = getValueFromFulfilledPromise(results[0]); - let res1 = getValueFromFulfilledPromise(results[1]); - - const data = res0 || res1 || null; - log.info({ message: "Using data from esplora: {data}", data }); - return data; -} - -/** - * Fetches bitcoind fees. - */ -export async function fetchBitcoindData(): Promise { - if (!BITCOIND_BASE_URL) { - return null; - } - - return new Promise((resolve, _) => { - let data: FeeByBlockTarget = {}; - - // Define the targets for which to fetch fee estimates. - const targets = BITCOIND_CONF_TARGETS; - - // Extract protocol, host, port from bitcoindBaseUrl. - let { protocol, hostname: host, port } = new URL(BITCOIND_BASE_URL); - - // Strip the trailing colon from the protocol. - protocol = protocol.replace(/.$/, ""); - - const config = { - protocol, - host, - port, - user: BITCOIND_USERNAME, - pass: BITCOIND_PASSWORD, - }; - - const rpc = new RpcClient(config); - - function batchCall() { - targets.forEach(function (target) { - rpc.estimatesmartfee(target, BITCOIND_ESTIMATE_MODE); - }); - } - - rpc.batch( - batchCall, - (error: Error | null, response: EstimateSmartFeeBatchResponse[]) => { - if (error) { - log.error({ - message: "Unable to fetch fee estimates from bitcoind: {error}", - error, - }); - resolve(null); - } else { - targets.forEach((target, i) => { - let feeRate = response[i].result?.feerate; - if (feeRate) { - // convert the returned value to satoshis, as it's currently returned in BTC. - data[target] = feeRate * 1e8; - } else { - log.error({ - message: `Failed to fetch fee estimate from bitcoind for confirmation target ${target}: {errors}`, - errors: response[i].result?.errors, - }); - } - }); - log.info({ message: "Using data from bitcoind: {data}", data }); - resolve(data); - } - }, - ); - }); -} - -export function processEstimates( - estimates: FeeByBlockTarget, - applyMultiplier = true, - convert = false, -): FeeByBlockTarget { - for (const [blockTarget, fee] of Object.entries(estimates)) { - let estimate = fee; - if (applyMultiplier) { - estimate = estimate * FEE_MULTIPLIER; - } - if (convert) { - estimate = estimate * 1000; - } - estimates[Number(blockTarget)] = Math.ceil(estimate); - } - return estimates; -} - -/** - * Fetches the current block hash. - */ -export async function fetchBlocksTipHash(): Promise { - const tasks = [ - (MEMPOOL_TIP_HASH_URL || MEMPOOL_TIP_HASH_URL_FALLBACK) && - fetchAndHandle( - MEMPOOL_TIP_HASH_URL, - "text", - MEMPOOL_TIP_HASH_URL_FALLBACK, - ), - (ESPLORA_TIP_HASH_URL || ESPLORA_TIP_HASH_URL_FALLBACK) && - fetchAndHandle( - ESPLORA_TIP_HASH_URL, - "text", - ESPLORA_TIP_HASH_URL_FALLBACK, - ), - ].filter(Boolean); - const res = await Promise.allSettled(tasks); - - let res0 = getValueFromFulfilledPromise(res[0]); - let res1 = getValueFromFulfilledPromise(res[1]); - - return res0 || res1 || null; -} - -/** - * Fetches the current block height. - */ -export async function fetchBlocksTipHeight(): Promise { - const tasks = [ - (MEMPOOL_TIP_HEIGHT_URL || MEMPOOL_TIP_HEIGHT_URL_FALLBACK) && - fetchAndHandle( - MEMPOOL_TIP_HEIGHT_URL, - "text", - MEMPOOL_TIP_HEIGHT_URL_FALLBACK, - ), - (ESPLORA_TIP_HEIGHT_URL || ESPLORA_TIP_HEIGHT_URL_FALLBACK) && - fetchAndHandle( - ESPLORA_TIP_HEIGHT_URL, - "text", - ESPLORA_TIP_HEIGHT_URL_FALLBACK, - ), - ].filter(Boolean); - const res = await Promise.allSettled(tasks); - - let res0 = getValueFromFulfilledPromise(res[0]); - let res1 = getValueFromFulfilledPromise(res[1]); - - const height = res0 || res1 || null; - return height ? Number(height) : null; -} - -/** - * Gets the current fee estimates from the cache or fetches them if they are not cached. - */ -export async function getEstimates(): Promise { - let estimates: Estimates | undefined = cache.get(CACHE_KEY); - - if (estimates) { - log.info({ message: "Got estimates from cache: ${estimates}", estimates }); - return estimates; - } - - const tasks = [ - await fetchMempoolData(), - await fetchEsploraData(), - await fetchBitcoindData(), - await fetchBlocksTipHash(), - await fetchBlocksTipHeight(), - ]; - const [result1, result2, result3, result4, result5] = - await Promise.allSettled(tasks); - const mempoolFeeEstimates = getValueFromFulfilledPromise(result1); - const esploraFeeEstimates = getValueFromFulfilledPromise(result2); - const bitcoindFeeEstimates = getValueFromFulfilledPromise(result3); - const blocksTipHash = getValueFromFulfilledPromise(result4); - const blocksTipHeight = getValueFromFulfilledPromise(result5); - - // Get the minimum fee. If the mempool fee estimates are not available, use a default value of FEE_MINIMUM sat/vbyte as a safety net. - const feeMinimum = (mempoolFeeEstimates?.minimumFee ?? FEE_MINIMUM) * 1000; - log.info({ message: "Using minimum fee: {feeMinimum}", feeMinimum }); - - estimates = { - current_block_hash: blocksTipHash, - current_block_height: blocksTipHeight, - fee_by_block_target: calculateFees( - mempoolFeeEstimates, - esploraFeeEstimates, - bitcoindFeeEstimates, - feeMinimum, - ), - }; - - cache.set(CACHE_KEY, estimates); - - log.info({ message: "Got estimates: {estimates}", estimates }); - return estimates; -} - -/** - * Get the fee estimates that are above the desired mempool depth. - */ -export function extractMempoolFees( - mempoolFeeEstimates: MempoolFeeEstimates, - depth: number, -): FeeByBlockTarget { - const feeByBlockTarget: FeeByBlockTarget = {}; - - if (mempoolFeeEstimates) { - const blockTargetMapping: BlockTargetMapping = { - 1: "fastestFee", - 3: "halfHourFee", - 6: "hourFee", - }; - for (let i = 1; i <= depth; i++) { - const feeProperty = blockTargetMapping[i]; - if (feeProperty && mempoolFeeEstimates[feeProperty]) { - feeByBlockTarget[i] = mempoolFeeEstimates[feeProperty]; - } - } - } - - return feeByBlockTarget; -} - -/** - * Filters the estimates to remove duplicates and estimates that are lower than the desired minimum fee. - */ -export function filterEstimates( - feeByBlockTarget: FeeByBlockTarget, - minFee: number, -): FeeByBlockTarget { - const result: FeeByBlockTarget = {}; - - for (const [blockTarget, fee] of Object.entries(feeByBlockTarget)) { - if (fee >= minFee) { - result[Number(blockTarget)] = fee; - } - } - - // If we didn't manage to get any fee estimates, return a single estimate with the minimum fee. - if (Object.keys(result).length === 0) { - result[1] = minFee; - } - - return result; -} - -export function addFeeEstimates( - feeByBlockTarget: { [key: number]: number }, - newEstimates: { [key: number]: number }, -) { - let highestBlockTarget = Math.max( - ...Object.keys(feeByBlockTarget).map(Number), - ); - let lowestFee = Math.min(...Object.values(feeByBlockTarget)); - - log.trace({ - message: `Initial highest block target: ${highestBlockTarget}, Lowest fee: ${lowestFee}`, - }); - - // Iterate over the new estimates - for (const [blockTarget, fee] of Object.entries(newEstimates)) { - const numericBlockTarget = Number(blockTarget); - - log.trace({ - message: `New estimate - Block target: ${numericBlockTarget}, Fee: ${fee}`, - }); - - // Only add the new estimate if the block target is higher and the fee is lower than the current ones - if (numericBlockTarget > highestBlockTarget && fee < lowestFee) { - log.trace({ - message: `Adding new estimate - Block target: ${numericBlockTarget}, Fee: ${fee}`, - }); - feeByBlockTarget[numericBlockTarget] = fee; - - // Update the highest block target and lowest fee - highestBlockTarget = numericBlockTarget; - lowestFee = fee; - - log.trace({ - message: `Updated highest block target: ${highestBlockTarget}, Lowest fee: ${lowestFee}`, - }); - } - } -} - -/** - * Calculates the fees. - */ -export function calculateFees( - mempoolFeeEstimates: MempoolFeeEstimates, - esploraFeeEstimates: FeeByBlockTarget, - bitcoindFeeEstimates: FeeByBlockTarget, - feeMinimum: number, -) { - let feeByBlockTarget: FeeByBlockTarget = {}; - - // Get the mempool fee estimates. - if (mempoolFeeEstimates) { - let estimates = extractMempoolFees(mempoolFeeEstimates, MEMPOOL_DEPTH); - estimates = processEstimates(estimates, true, true); - addFeeEstimates(feeByBlockTarget, estimates); - } - - // Add the bitcoind fee estimates. - if (bitcoindFeeEstimates) { - const estimates = processEstimates(bitcoindFeeEstimates, true, false); - addFeeEstimates(feeByBlockTarget, estimates); - } - - // Add the esplora fee estimates. - if (esploraFeeEstimates) { - const estimates = processEstimates(esploraFeeEstimates, true, true); - addFeeEstimates(feeByBlockTarget, estimates); - } - - // Filter the estimates to remove any that are lower than the desired minimum fee. - feeByBlockTarget = filterEstimates(feeByBlockTarget, feeMinimum); - - return feeByBlockTarget; -} diff --git a/test/DataProviderManager.test.ts b/test/DataProviderManager.test.ts index 8c33843..bb8743c 100644 --- a/test/DataProviderManager.test.ts +++ b/test/DataProviderManager.test.ts @@ -1,5 +1,5 @@ import { expect, test } from "bun:test"; -import { DataProviderManager } from "../src/DataProviderManager"; +import { DataProviderManager } from "../src/lib/DataProviderManager"; class MockProvider1 implements Provider { getBlockHeight = () => Promise.resolve(998); @@ -68,13 +68,14 @@ manager.registerProvider(new MockProvider2()); manager.registerProvider(new MockProvider3()); test("should merge fee estimates from multiple providers correctly", async () => { - const mergedData = await manager.getMergedData(); - expect(mergedData.blockHeight).toEqual(1000); - expect(mergedData.blockHash).toEqual("hash3"); - expect(mergedData.feeEstimates).toEqual({ - "1": 30, - "2": 20, - "3": 5, - "5": 3, + const mergedData = await manager.getData(); + expect(mergedData.current_block_height).toEqual(1000); + expect(mergedData.current_block_hash).toEqual("hash3"); + expect(mergedData.fee_by_block_target).toEqual({ + "1": 30000, + "2": 20000, + "3": 5000, + "5": 3000, + "10": 1000, }); }); diff --git a/test/bitcoind.test.ts b/test/bitcoind.test.ts index 1f0e5e7..03e7b64 100644 --- a/test/bitcoind.test.ts +++ b/test/bitcoind.test.ts @@ -1,5 +1,5 @@ import { expect, test, mock } from "bun:test"; -import { BitcoindProvider } from "../src/bitcoind"; +import { BitcoindProvider } from "../src/providers/bitcoind"; import RpcClient from "bitcoind-rpc"; // Mock the RpcClient @@ -25,7 +25,7 @@ mockRpcClient.getBestBlockHash = ( }); mockRpcClient.estimateSmartFee = ( - target: number, + target: number = 2, mode: string, cb: (error: any, result: EstimateSmartFeeBatchResponse) => void, ) => cb(null, { result: { feerate: 1000 } }); @@ -35,6 +35,7 @@ const provider = new BitcoindProvider( "user", "pass", [2], + "ECONOMICAL", ); // Override the rpc property with the mock diff --git a/test/esplora.test.ts b/test/esplora.test.ts index 610277b..2bce39d 100644 --- a/test/esplora.test.ts +++ b/test/esplora.test.ts @@ -1,5 +1,5 @@ import { expect, test } from "bun:test"; -import { EsploraProvider } from "../src/esplora"; +import { EsploraProvider } from "../src/providers/esplora"; const esploraProvider = new EsploraProvider("https://blockstream.info", 6); diff --git a/test/mempool.test.ts b/test/mempool.test.ts index eee5edc..48ebba1 100644 --- a/test/mempool.test.ts +++ b/test/mempool.test.ts @@ -1,5 +1,5 @@ import { expect, test } from "bun:test"; -import { MempoolProvider } from "../src/mempool"; +import { MempoolProvider } from "../src/providers/mempool"; const mempoolProvider = new MempoolProvider("https://mempool.space", 6); diff --git a/test/unit.test.ts b/test/unit.test.ts deleted file mode 100644 index 5c7e4f0..0000000 --- a/test/unit.test.ts +++ /dev/null @@ -1,89 +0,0 @@ -import { expect, test } from "bun:test"; -import { - addFeeEstimates, - filterEstimates, - extractMempoolFees, - calculateFees, -} from "../src/util"; - -// Define test data -const mempoolFeeEstimates: MempoolFeeEstimates = { - fastestFee: 500, - halfHourFee: 400, - hourFee: 300, - economyFee: 200, - minimumFee: 100, -}; -const bitcoindFeeEstimates: FeeByBlockTarget = { - 1: 300000, - 10: 250000, - 20: 200000, -}; -const esploraFeeEstimates: FeeByBlockTarget = { - 10: 400000, - 20: 300000, - 30: 150000, -}; - -// Test addFeeEstimates function -test("addFeeEstimates", () => { - const feeByBlockTarget: FeeByBlockTarget = { 1: 500, 2: 400, 3: 300 }; - const newEstimates: FeeByBlockTarget = { 4: 320, 5: 300, 6: 250 }; - - addFeeEstimates(feeByBlockTarget, newEstimates); - - expect(feeByBlockTarget[1]).toEqual(500); - expect(feeByBlockTarget[2]).toEqual(400); - expect(feeByBlockTarget[3]).toEqual(300); - expect(feeByBlockTarget[4]).toBeUndefined(); - expect(feeByBlockTarget[5]).toBeUndefined(); - expect(feeByBlockTarget[6]).toEqual(250); - expect(Object.keys(feeByBlockTarget).length).toEqual(4); -}); - -// Test filterEstimates function -test("filterEstimates", () => { - const feeByBlockTarget: FeeByBlockTarget = { 1: 500, 2: 400, 3: 300 }; - const feeMinimum = 350; - - const result = filterEstimates(feeByBlockTarget, feeMinimum); - - expect(result[1]).toEqual(500); - expect(result[2]).toEqual(400); - expect(result[3]).toBeUndefined(); - expect(Object.keys(result).length).toEqual(2); -}); - -// Test extractMempoolFees function -test("extractMempoolFees", () => { - const depth = 3; - - const result: FeeByBlockTarget = extractMempoolFees( - mempoolFeeEstimates, - depth, - ); - - expect(result[1]).toEqual(500); - expect(result[3]).toEqual(400); - expect(result[6]).toBeUndefined(); -}); - -// Test calculateFees function -test("calculateFees", () => { - const result: FeeByBlockTarget = calculateFees( - mempoolFeeEstimates, - esploraFeeEstimates, - bitcoindFeeEstimates, - 20000, - ); - - expect(result[1]).toEqual(500000); - expect(result[2]).toBeUndefined(); - expect(result[3]).toEqual(400000); - expect(result[4]).toBeUndefined(); - expect(result[6]).toEqual(300000); - expect(result[10]).toEqual(250000); - expect(result[20]).toEqual(200000); - expect(result[30]).toBeUndefined(); - expect(Object.keys(result).length).toEqual(5); -}); diff --git a/tsconfig.json b/tsconfig.json index e41e3ed..8f563d1 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -27,6 +27,6 @@ "useUnknownInCatchVariables": true, "noPropertyAccessFromIndexSignature": true, - "types": ["bun-types", "src/custom.d.ts"] + "types": ["bun-types"] } }