-
Notifications
You must be signed in to change notification settings - Fork 21
feat(stats): add daily Bitcoin merge-mining metrics snapshot #258
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: develop
Are you sure you want to change the base?
Changes from all commits
717cce8
1d4355f
cb0c612
ede8004
c519ab0
2900ae2
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,69 @@ | ||
| import axios from 'axios' | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. consider using node fetch instead of axios, and remove axios from the project. |
||
| import Logger from './Logger' | ||
|
|
||
| const DEFAULTS = { | ||
| baseUrl: 'https://mempool.space/api', | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This service is not reliable, almost every request fails due to time out. Consider using a reliable RPC service like Alchemy.
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Also, the service runs once per day. If it fails due to time out, and after 3 retries, then, for that day, the stats will not be calculated. Consider adding an alternative service, or a fallback in case that for for that day all 3 retries have been exhausted, and there is no calculated hashrate.
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. see Bitcoin RCP documentation https://www.alchemy.com/docs/chains/bitcoin/bitcoin-api-endpoints/getblock
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. also, using devault JSON-RPC methods will allow us to switch providers easier. https://developer.bitcoin.org/reference/rpc/getrawtransaction.html By Getting the raw transaction of the conibase transaction we can find the RSK_TAG |
||
| requestDelayMs: 250, | ||
| requestTimeoutMs: 15000, | ||
| maxRetries: 3, | ||
| retryDelayMs: 1000 | ||
| } | ||
|
|
||
| const sleep = ms => new Promise(resolve => setTimeout(resolve, ms)) | ||
|
|
||
| // Read-only client for the mempool.space REST API (Esplora-compatible), | ||
| // treating the provider as untrusted: bounded timeout, retries on network/ | ||
| // timeout and 429/5xx errors (not other 4xx), and a throttle between calls. | ||
| // Uses axios rather than a fetch global, which is absent before Node 18. | ||
| export function createBtcMempoolClient (options = {}) { | ||
| const cfg = { ...DEFAULTS, ...options } | ||
| const baseUrl = cfg.baseUrl.replace(/\/+$/, '') | ||
| const log = cfg.log || Logger('[btc-mempool-client]') | ||
|
|
||
| async function fetchOnce (path) { | ||
| try { | ||
| const { data } = await axios.get(`${baseUrl}${path}`, { timeout: cfg.requestTimeoutMs }) | ||
| return data | ||
| } catch (error) { | ||
| const status = error.response && error.response.status | ||
| // Network/timeout errors and 429/5xx are worth retrying; other 4xx are not | ||
| error.retryable = !status || status === 429 || status >= 500 | ||
| throw error | ||
| } | ||
| } | ||
|
|
||
| async function request (path) { | ||
| let lastError | ||
| for (let attempt = 0; attempt <= cfg.maxRetries; attempt++) { | ||
| try { | ||
| return await fetchOnce(path) | ||
| } catch (error) { | ||
| lastError = error | ||
| if (!error.retryable || attempt === cfg.maxRetries) break | ||
| const backoff = cfg.retryDelayMs * (attempt + 1) | ||
| log.warn(`Request to ${path} failed (${error.message}); retry ${attempt + 1}/${cfg.maxRetries} in ${backoff} ms`) | ||
| await sleep(backoff) | ||
| } | ||
| } | ||
| throw lastError | ||
| } | ||
|
|
||
| return { | ||
| getTipHeight: async () => Number(await request('/blocks/tip/height')), | ||
| getBlockHash: async height => { | ||
| const hash = String(await request(`/block-height/${height}`)).trim() | ||
| // Validated before being interpolated into later request paths | ||
| if (!/^[0-9a-f]{64}$/i.test(hash)) throw new Error(`Invalid block hash for height ${height}`) | ||
| return hash | ||
| }, | ||
| getCoinbase: async blockHash => { | ||
| const txs = await request(`/block/${blockHash}/txs/0`) | ||
| return Array.isArray(txs) ? txs[0] : null | ||
| }, | ||
|
nicov-iov marked this conversation as resolved.
|
||
| getNetworkHashrate: async (period = '1w') => { | ||
| const { currentHashrate } = await request(`/v1/mining/hashrate/${period}`) | ||
| return currentHashrate | ||
| }, | ||
|
nicov-iov marked this conversation as resolved.
|
||
| throttle: () => sleep(cfg.requestDelayMs) | ||
| } | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,64 @@ | ||
| import { BigNumber } from 'bignumber.js' | ||
| import { isRskMergeMined } from './rskMergeMiningTag' | ||
| import Logger from './Logger' | ||
|
|
||
| export async function getMergeMiningStats ({ | ||
| client, | ||
| sampleSize = 1000, | ||
| hashratePeriod = '1w', | ||
| minCoverage = 0.9, | ||
| log = Logger('[merge-mining-stats]') | ||
| }) { | ||
| if (!client) throw new Error('Missing Bitcoin client') | ||
| if (!Number.isInteger(sampleSize) || sampleSize < 1) throw new Error(`Invalid sampleSize: ${sampleSize}`) | ||
| if (!(minCoverage > 0) || minCoverage > 1) throw new Error(`Invalid minCoverage: ${minCoverage}`) | ||
|
|
||
| // Fetched first so a bad hashrate fails fast, before the long block walk | ||
| const bitcoinHashrate = new BigNumber(await client.getNetworkHashrate(hashratePeriod)) | ||
| if (!bitcoinHashrate.isFinite() || bitcoinHashrate.lte(0)) { | ||
| throw new Error(`Invalid Bitcoin hashrate: ${bitcoinHashrate.toString()}`) | ||
| } | ||
|
|
||
| const tipHeight = await client.getTipHeight() | ||
| if (!Number.isInteger(tipHeight) || tipHeight < 0) throw new Error(`Invalid tip height: ${tipHeight}`) | ||
|
|
||
| const lowestHeight = Math.max(0, tipHeight - sampleSize + 1) | ||
| const requested = tipHeight - lowestHeight + 1 | ||
| let mergeMinedBlocks = 0 | ||
| let bitcoinBlocksSampled = 0 | ||
| let failed = 0 | ||
|
|
||
| for (let height = tipHeight; height >= lowestHeight; height--) { | ||
| try { | ||
| const coinbase = await client.getCoinbase(await client.getBlockHash(height)) | ||
| if (!coinbase) throw new Error(`Missing coinbase for block ${height}`) | ||
| if (isRskMergeMined(coinbase)) mergeMinedBlocks++ | ||
| bitcoinBlocksSampled++ | ||
| } catch (error) { | ||
| failed++ | ||
| log.warn(`Skipping BTC block ${height}: ${error.message}`) | ||
| } | ||
| // Stop early once the remaining blocks can no longer restore minimum coverage | ||
| // (e.g. a provider outage), instead of walking the whole window for nothing | ||
| if (bitcoinBlocksSampled + (height - lowestHeight) < requested * minCoverage) break | ||
| await client.throttle() | ||
| } | ||
|
|
||
| // A failing or hostile provider must not skew the ratio by starving us of blocks | ||
| if (bitcoinBlocksSampled < requested * minCoverage) { | ||
| throw new Error(`Insufficient block coverage: ${bitcoinBlocksSampled}/${requested} sampled (${failed} failed)`) | ||
| } | ||
|
|
||
| const mergeMiningPercentage = new BigNumber(mergeMinedBlocks).dividedBy(bitcoinBlocksSampled) | ||
| const rootstockSecuredHashrate = bitcoinHashrate.times(mergeMiningPercentage) | ||
|
|
||
| log.info(`Sampled ${bitcoinBlocksSampled}/${requested} BTC blocks (${failed} failed); ${mergeMinedBlocks} merge-mined (${mergeMiningPercentage.times(100).toFixed(2)}%)`) | ||
|
|
||
| return { | ||
| bitcoinHashrate: bitcoinHashrate.toFixed(0), | ||
| rootstockSecuredHashrate: rootstockSecuredHashrate.toFixed(0), | ||
| mergeMiningPercentage: mergeMiningPercentage.toFixed(6), | ||
| bitcoinBlocksSampled, | ||
| mergeMinedBlocks | ||
| } | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,23 @@ | ||
| import { remove0x } from './utils' | ||
|
|
||
| // 'RSKBLOCK:' marker (RSKIP-110) merge-mining pools embed in the Bitcoin | ||
| // coinbase, in an OP_RETURN output or the input scriptSig — so both are scanned. | ||
| export const RSK_TAG_HEX = '52534b424c4f434b3a' | ||
|
|
||
| const hexHasTag = hex => | ||
| typeof hex === 'string' && remove0x(hex).toLowerCase().includes(RSK_TAG_HEX) | ||
|
|
||
| // Handles both the mempool/Esplora shape (`scriptsig` / `scriptpubkey`) and the | ||
| // Bitcoin Core RPC shape (`scriptSig.hex` / `scriptPubKey.hex`). | ||
| export function isRskMergeMined (coinbase) { | ||
| if (!coinbase) return false | ||
|
|
||
| const inputScripts = (coinbase.vin || []).map( | ||
| input => input && (input.scriptsig || (input.scriptSig && input.scriptSig.hex)) | ||
| ) | ||
| const outputScripts = (coinbase.vout || []).map( | ||
| output => output && (output.scriptpubkey || (output.scriptPubKey && output.scriptPubKey.hex)) | ||
| ) | ||
|
|
||
| return [...inputScripts, ...outputScripts].some(hexHasTag) | ||
| } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
instead of moving axios from devDependencies to dependencies, we can complete remove it, and use node fetch instead.