Skip to content

Commit

Permalink
Merge pull request #46 from LN-Zap/refactor
Browse files Browse the repository at this point in the history
Refactor and modularise
  • Loading branch information
mrfelton authored Feb 19, 2024
2 parents 931ec5e + 5ce0eaa commit f771823
Show file tree
Hide file tree
Showing 21 changed files with 1,180 additions and 765 deletions.
Binary file modified bun.lockb
Binary file not shown.
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@
"@types/config": "3.3.3",
"@types/node-cache": "4.2.5",
"pino-pretty": "10.3.1",
"prettier": "3.2.5",
"tap": "18.7.0"
},
"engines": {
Expand Down
48 changes: 48 additions & 0 deletions src/components/Content.tsx
Original file line number Diff line number Diff line change
@@ -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;
}) => (
<Layout {...props.siteData}>
<div class="logo">
<svg
width="20"
height="20"
viewBox="0 0 155 120"
fill="none"
xmlns="http://www.w3.org/2000/svg"
focusable="false"
>
<path
fillRule="evenodd"
clipRule="evenodd"
d="M7.06565 43.2477C1.90963 41.2685 -0.665684 35.4843 1.31353 30.3283C3.29274 25.1722 9.07699 22.5969 14.233 24.5761L51.4526 38.8634C51.4937 38.8798 51.535 38.896 51.5765 38.9119L70.2481 46.0792C75.4041 48.0584 81.1883 45.4831 83.1675 40.3271C85.1468 35.1711 82.5714 29.3868 77.4154 27.4076L77.4132 27.4068C77.4139 27.4064 77.4145 27.406 77.4151 27.4056L58.7436 20.2383C53.5876 18.2591 51.0123 12.4749 52.9915 7.31885C54.9707 2.16283 60.755 -0.412485 65.911 1.56673L120.828 22.6473C120.959 22.6977 121.089 22.7506 121.217 22.8059C121.453 22.8928 121.69 22.9815 121.926 23.0721C147.706 32.9681 160.583 61.8894 150.686 87.6695C140.79 113.45 111.869 126.326 86.089 116.43C85.5927 116.24 85.1011 116.042 84.6144 115.838C84.3783 115.766 84.1431 115.686 83.9091 115.596L30.0742 94.9308C24.9182 92.9516 22.3428 87.1673 24.3221 82.0113C26.3013 76.8553 32.0855 74.2799 37.2415 76.2592L55.9106 83.4256C55.9103 83.4242 55.9099 83.4229 55.9095 83.4215L55.9133 83.423C61.0694 85.4022 66.8536 82.8269 68.8328 77.6709C70.812 72.5148 68.2367 66.7306 63.0807 64.7514L54.6786 61.5261C54.6787 61.5257 54.6788 61.5252 54.6789 61.5247L7.06565 43.2477Z"
fill="currentColor"
></path>
</svg>
</div>

<div class="header">
<h1>{props.siteData.title}</h1>
<p>{props.siteData.subtitle}</p>
</div>

<div class="content">
<pre>
<span class="blue">curl</span> -L -X GET{" "}
<span class="green">'{props.siteData.baseUrl}/v1/fee-estimates'</span>
</pre>

<pre>{raw(JSON.stringify(props.data, null, 2))}</pre>
</div>

<div class="footer">
<a href="https://github.com/LN-Zap/bitcoin-blended-fee-estimator">
https://github.com/LN-Zap/bitcoin-blended-fee-estimator
</a>
</div>
</Layout>
);
21 changes: 21 additions & 0 deletions src/components/Layout.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
export const Layout = (props: SiteData) => {
return (
<html lang="en">
<head>
<title>{props.title}</title>
<meta name="description" content={props.subtitle} />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta charset="UTF-8" />
<meta name="color-scheme" content="light dark" />
<link rel="stylesheet" href="/static/style.css" />
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" />
<link
href="https://fonts.googleapis.com/css2?family=Arimo&family=Montserrat&family=Roboto:wght@100&display=swap"
rel="stylesheet"
/>
</head>
<body>{props.children}</body>
</html>
);
};
2 changes: 2 additions & 0 deletions src/components/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
export * from "./Content";
export * from "./Layout";
83 changes: 59 additions & 24 deletions src/custom.d.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,24 @@
// MempoolFeeEstimates represents the fee estimates for different transaction speeds.
interface Provider {
getBlockHeight(): Promise<number>;
getBlockHash(): Promise<string>;
getFeeEstimates(): Promise<FeeByBlockTarget>;
getAllData(): Promise<ProviderData>;
}

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)
Expand All @@ -8,35 +28,24 @@ type MempoolFeeEstimates = {
minimumFee: number; // minimum relay fee (sat/vb)
};

// FeeByBlockTarget represents the fee by block target.
type FeeByBlockTarget = {
[key: number]: 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)
// MempoolFeeEstimates represents the data returned by the Esplora API.
type EsploraFeeEstimates = {
[key: string]: number;
};

// BlockTargetMapping represents the mapping of block targets.
type BlockTargetMapping = {
[key: number]: string; // dynamic numeric keys with string as value
// FeeByBlockTarget represents the fee by block target.
type FeeByBlockTarget = {
[target: string]: number; // fees by confirmation target
};

// 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 BitcoindRpcBatchResponse {
interface EstimateSmartFeeBatchResponse {
result?: EstimateSmartFeeResponse;
error?: any;
}
Expand All @@ -48,5 +57,31 @@ interface EstimateSmartFeeResponse {
blocks?: number; // block number where estimate was found
}

// EstimateMode represents the mode for fee estimation.
type EstimateMode = "ECONOMICAL" | "CONSERVATIVE"; // estimate mode can be either 'ECONOMICAL' or 'CONSERVATIVE'
interface BlockCountResponse {
result: number;
}

interface BestBlockHashResponse {
result: string;
}

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)
}
159 changes: 159 additions & 0 deletions src/lib/DataProviderManager.ts
Original file line number Diff line number Diff line change
@@ -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<Estimates> {
let data = this.cache.get<Estimates>("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<DataPoint[]> {
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<DataPoint[]> {
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;
}
}
38 changes: 38 additions & 0 deletions src/lib/logger.ts
Original file line number Diff line number Diff line change
@@ -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;
}
Loading

0 comments on commit f771823

Please sign in to comment.