global: fixes

This commit is contained in:
nym21
2026-04-27 19:19:14 +02:00
parent 6c8afc942c
commit 66494c081c
16 changed files with 1453 additions and 5399 deletions

File diff suppressed because it is too large Load Diff

View File

@@ -41,7 +41,7 @@ use super::{
metrics::AvgAmountMetrics,
};
const VERSION: Version = Version::new(23);
const VERSION: Version = Version::new(24);
#[derive(Traversable)]
pub struct AddrMetricsVecs<M: StorageMode = Rw> {

View File

@@ -10,7 +10,9 @@ use std::{
};
use brk_rpc::Client;
use brk_types::FeeRate;
use parking_lot::RwLock;
use tracing::warn;
#[cfg(debug_assertions)]
use self::projected_blocks::verify::Verifier;
@@ -72,6 +74,11 @@ impl Rebuilder {
self.dirty.store(false, Ordering::Release);
let min_fee = client.get_mempool_min_fee().unwrap_or_else(|e| {
warn!("getmempoolinfo failed, falling back to FeeRate::MIN: {e}");
FeeRate::MIN
});
let built = {
let entries = entries.read();
let entries_slice = entries.entries();
@@ -82,7 +89,7 @@ impl Rebuilder {
#[cfg(not(debug_assertions))]
let _ = client;
Snapshot::build(blocks, entries_slice)
Snapshot::build(blocks, entries_slice, min_fee)
};
*self.snapshot.write() = Arc::new(built);

View File

@@ -2,21 +2,74 @@ use brk_types::{FeeRate, RecommendedFees};
use super::stats::BlockStats;
/// Compute recommended fees from block stats (mempool.space style).
pub fn compute_recommended_fees(stats: &[BlockStats]) -> RecommendedFees {
/// Output rounding granularity in sat/vB. mempool.space's
/// `/api/v1/fees/recommended` uses `1.0`; their `/precise`
/// variant uses `0.001`. bitview always emits precise.
const MIN_INCREMENT: FeeRate = FeeRate::new(0.001);
/// `getPreciseRecommendedFee` adds this to `fastestFee` and
/// half of it to `halfHourFee`, then floors them. Compensates
/// for sub-1-sat/vB fees mined by hashrate that ignores the
/// relay floor.
const PRIORITY_FACTOR: FeeRate = FeeRate::new(0.5);
const MIN_FASTEST_FEE: FeeRate = FeeRate::new(1.0);
const MIN_HALF_HOUR_FEE: FeeRate = FeeRate::new(0.5);
/// Literal port of mempool.space's `getPreciseRecommendedFee`
/// (backend/src/api/fee-api.ts). `min_fee` is bitcoind's live
/// `mempoolminfee` in sat/vB and acts as a floor for every tier
/// while the mempool is purging by fee.
pub fn compute_recommended_fees(stats: &[BlockStats], min_fee: FeeRate) -> RecommendedFees {
let purge_rate = min_fee.ceil_to(MIN_INCREMENT);
let minimum_fee = purge_rate.max(MIN_INCREMENT);
let first = stats.first().map_or(minimum_fee, |b| {
optimize_median_fee(b, stats.get(1), None, minimum_fee)
});
let second = stats.get(1).map_or(minimum_fee, |b| {
optimize_median_fee(b, stats.get(2), Some(first), minimum_fee)
});
let third = stats.get(2).map_or(minimum_fee, |b| {
optimize_median_fee(b, stats.get(3), Some(second), minimum_fee)
});
let mut fastest = minimum_fee.max(first);
let mut half_hour = minimum_fee.max(second);
let mut hour = minimum_fee.max(third);
let economy = third.clamp(minimum_fee, minimum_fee * 2.0);
fastest = fastest.max(half_hour).max(hour).max(economy);
half_hour = half_hour.max(hour).max(economy);
hour = hour.max(economy);
let fastest = (fastest + PRIORITY_FACTOR).max(MIN_FASTEST_FEE);
let half_hour = (half_hour + PRIORITY_FACTOR / 2.0).max(MIN_HALF_HOUR_FEE);
RecommendedFees {
fastest_fee: median_fee_for_block(stats, 0),
half_hour_fee: median_fee_for_block(stats, 2),
hour_fee: median_fee_for_block(stats, 5),
economy_fee: median_fee_for_block(stats, 7),
minimum_fee: FeeRate::MIN,
fastest_fee: fastest.round_milli(),
half_hour_fee: half_hour.round_milli(),
hour_fee: hour.round_milli(),
economy_fee: economy.round_milli(),
minimum_fee: minimum_fee.round_milli(),
}
}
/// Get the median fee rate for block N.
fn median_fee_for_block(stats: &[BlockStats], block_index: usize) -> FeeRate {
stats
.get(block_index)
.map(|s| s.median_fee_rate())
.unwrap_or_else(|| FeeRate::MIN)
/// Pick the fee for one projected block, smoothing toward the
/// previous tier and discounting partially-full final blocks.
fn optimize_median_fee(
block: &BlockStats,
next_block: Option<&BlockStats>,
previous_fee: Option<FeeRate>,
min_fee: FeeRate,
) -> FeeRate {
let median = block.median_fee_rate();
let use_fee = previous_fee.map_or(median, |prev| FeeRate::mean(median, prev));
let vsize = u64::from(block.total_vsize);
if vsize <= 500_000 || median < min_fee {
return min_fee;
}
if vsize <= 950_000 && next_block.is_none() {
let multiplier = (vsize - 500_000) as f64 / 500_000.0;
return (use_fee * multiplier).round_to(MIN_INCREMENT).max(min_fee);
}
use_fee.ceil_to(MIN_INCREMENT).max(min_fee)
}

View File

@@ -1,6 +1,6 @@
use std::hash::{DefaultHasher, Hash, Hasher};
use brk_types::RecommendedFees;
use brk_types::{FeeRate, RecommendedFees};
use super::{
super::block_builder::Package,
@@ -27,13 +27,15 @@ pub struct Snapshot {
impl Snapshot {
/// Build a snapshot from packages grouped by projected block.
pub fn build(blocks: Vec<Vec<Package>>, entries: &[Option<Entry>]) -> Self {
/// `min_fee` is bitcoind's live `mempoolminfee`, used as the floor
/// for every recommended-fee tier.
pub fn build(blocks: Vec<Vec<Package>>, entries: &[Option<Entry>], min_fee: FeeRate) -> Self {
let block_stats: Vec<BlockStats> = blocks
.iter()
.map(|block| stats::compute_block_stats(block, entries))
.collect();
let fees = fees::compute_recommended_fees(&block_stats);
let fees = fees::compute_recommended_fees(&block_stats, min_fee);
let blocks: Vec<Vec<TxIndex>> = blocks
.into_iter()

View File

@@ -1,4 +1,7 @@
use std::hash::{DefaultHasher, Hash, Hasher};
use std::{
collections::hash_map::Entry as MapEntry,
hash::{DefaultHasher, Hash, Hasher},
};
use brk_types::{AddrBytes, AddrMempoolStats, Transaction, TxOut, Txid};
use derive_more::Deref;
@@ -51,7 +54,6 @@ impl AddrTracker {
}
fn update(&mut self, tx: &Transaction, txid: &Txid, is_addition: bool) {
// Inputs: track sending
for txin in &tx.input {
let Some(prevout) = txin.prevout.as_ref() else {
continue;
@@ -59,33 +61,57 @@ impl AddrTracker {
let Some(bytes) = prevout.addr_bytes() else {
continue;
};
let (stats, txids) = self.0.entry(bytes).or_default();
if is_addition {
txids.insert(txid.clone());
stats.sending(prevout);
} else {
txids.remove(txid);
stats.sent(prevout);
}
stats.update_tx_count(txids.len() as u32);
self.apply(bytes, txid, is_addition, |stats| {
if is_addition {
stats.sending(prevout);
} else {
stats.sent(prevout);
}
});
}
// Outputs: track receiving
for txout in &tx.output {
let Some(bytes) = txout.addr_bytes() else {
continue;
};
self.apply(bytes, txid, is_addition, |stats| {
if is_addition {
stats.receiving(txout);
} else {
stats.received(txout);
}
});
}
}
let (stats, txids) = self.0.entry(bytes).or_default();
if is_addition {
txids.insert(txid.clone());
stats.receiving(txout);
} else {
txids.remove(txid);
stats.received(txout);
fn apply(
&mut self,
bytes: AddrBytes,
txid: &Txid,
is_addition: bool,
update_stats: impl FnOnce(&mut AddrMempoolStats),
) {
let mut entry = match self.0.entry(bytes) {
MapEntry::Occupied(e) => e,
MapEntry::Vacant(v) => {
if !is_addition {
return;
}
v.insert_entry(Default::default())
}
stats.update_tx_count(txids.len() as u32);
};
let (stats, txids) = entry.get_mut();
if is_addition {
txids.insert(txid.clone());
} else {
txids.remove(txid);
}
update_stats(stats);
let len = txids.len();
if len == 0 {
entry.remove();
} else {
stats.update_tx_count(len as u32);
}
}
}

View File

@@ -2,10 +2,11 @@ use std::{thread::sleep, time::Duration};
use bitcoin::{consensus::encode, hex::FromHex};
use brk_error::{Error, Result};
use brk_types::{Bitcoin, BlockHash, Height, MempoolEntryInfo, Sats, Txid, Vout};
use brk_types::{Bitcoin, BlockHash, FeeRate, Height, MempoolEntryInfo, Sats, Txid, Vout};
use corepc_types::v30::{
GetBlockCount, GetBlockHash, GetBlockHeader, GetBlockHeaderVerbose, GetBlockVerboseOne,
GetBlockVerboseZero, GetBlockchainInfo, GetRawMempool, GetRawMempoolVerbose, GetTxOut,
GetBlockVerboseZero, GetBlockchainInfo, GetMempoolInfo, GetRawMempool, GetRawMempoolVerbose,
GetTxOut,
};
use rustc_hash::FxHashMap;
use serde::Deserialize;
@@ -172,6 +173,15 @@ impl Client {
}
}
/// Live `mempoolminfee` in sat/vB, already maxed against `minrelaytxfee`
/// per Core's contract. Wallets must pay at least this rate or bitcoind
/// will reject the broadcast; rises above the relay floor when the
/// mempool is purging by fee.
pub fn get_mempool_min_fee(&self) -> Result<FeeRate> {
let r: GetMempoolInfo = self.0.call_with_retry("getmempoolinfo", &[])?;
Ok(FeeRate::from(r.mempool_min_fee * 100_000.0))
}
/// Get txids of all transactions in a memory pool
pub fn get_raw_mempool(&self) -> Result<Vec<Txid>> {
let r: GetRawMempool = self.0.call_with_retry("getrawmempool", &[])?;

View File

@@ -119,10 +119,7 @@ fn cost_basis_formatted(
FxHashMap::with_capacity_and_hasher(raw.map.len(), Default::default());
for (&price_cents, &sats) in &raw.map {
let price = Cents::from(price_cents);
let key = match agg {
UrpdAggregation::Raw => price,
_ => agg.bucket_floor(price).unwrap_or(price),
};
let key = agg.bucket_floor(price);
let entry = bucketed.entry(key).or_insert((Sats::ZERO, Dollars::ZERO));
entry.0 += sats;
if needs_realized {

View File

@@ -1,6 +1,6 @@
use std::{
cmp::Ordering,
ops::{Add, AddAssign, Div},
ops::{Add, AddAssign, Div, Mul},
};
use schemars::JsonSchema;
@@ -24,9 +24,35 @@ pub struct FeeRate(f64);
impl FeeRate {
pub const MIN: Self = Self(0.1);
pub fn new(fr: f64) -> Self {
pub const fn new(fr: f64) -> Self {
Self(fr)
}
/// Round up to the nearest multiple of `nearest`.
#[inline]
pub fn ceil_to(self, nearest: Self) -> Self {
Self((self.0 / nearest.0).ceil() * nearest.0)
}
/// Round to the nearest multiple of `nearest`.
#[inline]
pub fn round_to(self, nearest: Self) -> Self {
Self((self.0 / nearest.0).round() * nearest.0)
}
/// Round to 3 decimal places via `(x * 1000).round() / 1000`.
/// Divide-by-integer at the end is more numerically stable
/// than `round_to(0.001)`'s closing `* 0.001`.
#[inline]
pub fn round_milli(self) -> Self {
Self((self.0 * 1000.0).round() / 1000.0)
}
/// Arithmetic mean of two rates.
#[inline]
pub fn mean(a: Self, b: Self) -> Self {
Self((a.0 + b.0) / 2.0)
}
}
impl From<(Sats, VSize)> for FeeRate {
@@ -88,6 +114,20 @@ impl Div<usize> for FeeRate {
}
}
impl Div<f64> for FeeRate {
type Output = Self;
fn div(self, rhs: f64) -> Self::Output {
Self(self.0 / rhs)
}
}
impl Mul<f64> for FeeRate {
type Output = Self;
fn mul(self, rhs: f64) -> Self::Output {
Self(self.0 * rhs)
}
}
impl From<usize> for FeeRate {
#[inline]
fn from(value: usize) -> Self {

View File

@@ -1,18 +1,18 @@
use std::collections::BTreeMap;
use rustc_hash::FxHashMap;
use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
use crate::{Bitcoin, Cents, Cohort, Date, Dollars, Sats, UrpdAggregation, UrpdBucket, UrpdRaw};
use crate::{
Bitcoin, Cents, CentsSats, CentsSigned, Cohort, Date, Dollars, Sats, UrpdAggregation,
UrpdBucket, UrpdRaw,
};
/// UTXO Realized Price Distribution for a cohort on a specific date.
///
/// Supply is grouped by the close price at which each UTXO was last moved.
/// Each bucket exposes three values derived from the same `(price_floor, supply)`
/// pairs: supply in BTC, realized cap contribution in USD (`price_floor * supply`),
/// and unrealized P&L against that date's close in USD
/// (`(close - price_floor) * supply`, can be negative).
/// Each bucket exposes three values: supply in BTC, realized cap contribution
/// in USD (sum of `realized_price * supply` over the coins in the bucket), and
/// unrealized P&L in USD (`close * supply - realized_cap`, can be negative).
#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema)]
pub struct Urpd {
pub cohort: Cohort,
@@ -26,6 +26,12 @@ pub struct Urpd {
pub buckets: Vec<UrpdBucket>,
}
#[derive(Default, Clone, Copy)]
struct BucketAccum {
supply: Sats,
realized_cap: CentsSats,
}
impl Urpd {
/// Build from the raw on-disk distribution plus context.
pub fn build(
@@ -35,39 +41,44 @@ impl Urpd {
raw: &UrpdRaw,
aggregation: UrpdAggregation,
) -> Self {
let mut agg: FxHashMap<Cents, Sats> =
let mut agg: FxHashMap<Cents, BucketAccum> =
FxHashMap::with_capacity_and_hasher(raw.map.len(), Default::default());
for (&price_cents, &sats) in &raw.map {
let price = Cents::from(price_cents);
let key = match aggregation {
UrpdAggregation::Raw => price,
_ => aggregation.bucket_floor(price).unwrap_or(price),
};
*agg.entry(key).or_insert(Sats::ZERO) += sats;
let key = aggregation.bucket_floor(price);
let slot = agg.entry(key).or_default();
slot.supply += sats;
slot.realized_cap += CentsSats::from_price_sats(price, sats);
}
let sorted: BTreeMap<Cents, Sats> = agg.into_iter().collect();
let mut sorted: Vec<_> = agg.into_iter().collect();
sorted.sort_unstable_by_key(|&(price, _)| price);
let close = Dollars::from(close_cents);
let total_supply: Sats = raw.map.values().copied().sum();
let mut total_sats = Sats::ZERO;
let mut buckets = Vec::with_capacity(sorted.len());
for (price_floor_cents, supply) in sorted {
total_sats += supply;
let price_floor = Dollars::from(price_floor_cents);
buckets.push(UrpdBucket {
price_floor,
supply: Bitcoin::from(supply),
realized_cap: price_floor * supply,
unrealized_pnl: (close - price_floor) * supply,
});
}
let buckets = sorted
.into_iter()
.map(|(price_floor_cents, slot)| {
let realized_cap_cents = slot.realized_cap.to_cents();
let close_mc_cents = CentsSats::from_price_sats(close_cents, slot.supply).to_cents();
let pnl = CentsSigned::from(close_mc_cents.inner())
- CentsSigned::from(realized_cap_cents.inner());
UrpdBucket {
price_floor: Dollars::from(price_floor_cents),
supply: Bitcoin::from(slot.supply),
realized_cap: Dollars::from(realized_cap_cents),
unrealized_pnl: Dollars::from(pnl),
}
})
.collect();
Self {
cohort,
date,
aggregation,
close,
total_supply: Bitcoin::from(total_sats),
total_supply: Bitcoin::from(total_supply),
buckets,
}
}

View File

@@ -46,26 +46,24 @@ impl UrpdAggregation {
}
}
/// Compute bucket floor for a given price in cents.
/// Returns None for Raw (no bucketing).
pub fn bucket_floor(&self, price_cents: Cents) -> Option<Cents> {
/// Compute the bucket floor for a price in cents.
/// `Raw` is the identity (no bucketing).
pub fn bucket_floor(&self, price_cents: Cents) -> Cents {
match self {
Self::Raw => None,
Self::Raw => price_cents,
Self::Lin200 | Self::Lin500 | Self::Lin1000 => {
let size = self.linear_size_cents().unwrap();
Some((price_cents / size) * size)
(price_cents / size) * size
}
Self::Log10 | Self::Log50 | Self::Log100 | Self::Log200 => {
if price_cents == Cents::ZERO {
return Some(Cents::ZERO);
return Cents::ZERO;
}
let n = self.log_buckets_per_decade().unwrap();
// Bucket index = floor(n * log10(price))
// Floor = 10^(bucket_index / n)
let log_price = f64::from(price_cents).log10();
let bucket_idx = (n as f64 * log_price).floor() as i32;
let floor = 10_f64.powf(bucket_idx as f64 / n as f64);
Some(Cents::from(floor.round() as u64))
Cents::from(floor.round() as u64)
}
}
}

View File

@@ -6,12 +6,12 @@ use crate::{Bitcoin, Dollars};
/// A single bucket in a URPD snapshot.
#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema)]
pub struct UrpdBucket {
/// Inclusive lower bound of the bucket, in USD.
/// Lower bound of the bucket, in USD. Equals the exact realized price for `Raw`.
pub price_floor: Dollars,
/// Supply held with a last-move price inside this bucket, in BTC.
pub supply: Bitcoin,
/// Realized cap contribution in USD: `price_floor * supply`.
/// Realized cap contribution in USD: sum of `realized_price * supply` over the coins in this bucket.
pub realized_cap: Dollars,
/// Unrealized P&L in USD against the close on the snapshot date: `(close - price_floor) * supply`. Can be negative.
/// Unrealized P&L in USD against the close on the snapshot date: `close * supply - realized_cap`. Can be negative.
pub unrealized_pnl: Dollars,
}

View File

@@ -1187,10 +1187,9 @@ replaced it. Omitted on the root of an RBF response.
* UTXO Realized Price Distribution for a cohort on a specific date.
*
* Supply is grouped by the close price at which each UTXO was last moved.
* Each bucket exposes three values derived from the same `(price_floor, supply)`
* pairs: supply in BTC, realized cap contribution in USD (`price_floor * supply`),
* and unrealized P&L against that date's close in USD
* (`(close - price_floor) * supply`, can be negative).
* Each bucket exposes three values: supply in BTC, realized cap contribution
* in USD (sum of `realized_price * supply` over the coins in the bucket), and
* unrealized P&L in USD (`close * supply - realized_cap`, can be negative).
*
* @typedef {Object} Urpd
* @property {Cohort} cohort
@@ -1211,10 +1210,10 @@ replaced it. Omitted on the root of an RBF response.
* A single bucket in a URPD snapshot.
*
* @typedef {Object} UrpdBucket
* @property {Dollars} priceFloor - Inclusive lower bound of the bucket, in USD.
* @property {Dollars} priceFloor - Lower bound of the bucket, in USD. Equals the exact realized price for `Raw`.
* @property {Bitcoin} supply - Supply held with a last-move price inside this bucket, in BTC.
* @property {Dollars} realizedCap - Realized cap contribution in USD: `price_floor * supply`.
* @property {Dollars} unrealizedPnl - Unrealized P&L in USD against the close on the snapshot date: `(close - price_floor) * supply`. Can be negative.
* @property {Dollars} realizedCap - Realized cap contribution in USD: sum of `realized_price * supply` over the coins in this bucket.
* @property {Dollars} unrealizedPnl - Unrealized P&L in USD against the close on the snapshot date: `close * supply - realized_cap`. Can be negative.
*/
/**
* Path parameters for per-cohort URPD endpoints.
@@ -7272,7 +7271,7 @@ function createTransferPattern(client, acc) {
* @extends BrkClientBase
*/
class BrkClient extends BrkClientBase {
VERSION = "v0.3.0-beta.6";
VERSION = "v0.3.0-beta.7";
INDEXES = /** @type {const} */ ([
"minute10",

View File

@@ -40,5 +40,5 @@
"url": "git+https://github.com/bitcoinresearchkit/brk.git"
},
"type": "module",
"version": "0.3.0-beta.6"
"version": "0.3.0-beta.7"
}

View File

@@ -1573,10 +1573,10 @@ class UrpdBucket(TypedDict):
A single bucket in a URPD snapshot.
Attributes:
price_floor: Inclusive lower bound of the bucket, in USD.
price_floor: Lower bound of the bucket, in USD. Equals the exact realized price for `Raw`.
supply: Supply held with a last-move price inside this bucket, in BTC.
realized_cap: Realized cap contribution in USD: `price_floor * supply`.
unrealized_pnl: Unrealized P&L in USD against the close on the snapshot date: `(close - price_floor) * supply`. Can be negative.
realized_cap: Realized cap contribution in USD: sum of `realized_price * supply` over the coins in this bucket.
unrealized_pnl: Unrealized P&L in USD against the close on the snapshot date: `close * supply - realized_cap`. Can be negative.
"""
price_floor: Dollars
supply: Bitcoin
@@ -1588,10 +1588,9 @@ class Urpd(TypedDict):
UTXO Realized Price Distribution for a cohort on a specific date.
Supply is grouped by the close price at which each UTXO was last moved.
Each bucket exposes three values derived from the same `(price_floor, supply)`
pairs: supply in BTC, realized cap contribution in USD (`price_floor * supply`),
and unrealized P&L against that date's close in USD
(`(close - price_floor) * supply`, can be negative).
Each bucket exposes three values: supply in BTC, realized cap contribution
in USD (sum of `realized_price * supply` over the coins in the bucket), and
unrealized P&L in USD (`close * supply - realized_cap`, can be negative).
Attributes:
aggregation: Aggregation strategy applied to the buckets.
@@ -6523,7 +6522,7 @@ class SeriesTree:
class BrkClient(BrkClientBase):
"""Main BRK client with series tree and API methods."""
VERSION = "v0.3.0-beta.6"
VERSION = "v0.3.0-beta.7"
INDEXES = [
"minute10",

View File

@@ -1,6 +1,6 @@
[project]
name = "brk-client"
version = "0.3.0-beta.6"
version = "0.3.0-beta.7"
description = "Bitcoin on-chain analytics client — thousands of metrics, block explorer, and address index"
readme = "README.md"
requires-python = ">=3.9"