Compare commits

...

1 Commits

Author SHA1 Message Date
junderw
ae05c22b2f
WIP 2026-02-14 14:15:45 +09:00
10 changed files with 1250 additions and 30 deletions

View File

@ -130,6 +130,18 @@ impl Network {
}
}
#[cfg(not(feature = "liquid"))]
#[inline(always)]
pub const fn is_liquid(self) -> bool {
false
}
#[cfg(feature = "liquid")]
#[inline(always)]
pub const fn is_liquid(self) -> bool {
true
}
#[cfg(feature = "liquid")]
pub fn address_params(self) -> &'static address::AddressParams {
// Liquid regtest uses elements's address params

View File

@ -110,6 +110,8 @@ pub struct BlockchainInfo {
#[derive(Serialize, Deserialize, Debug)]
pub struct MempoolInfo {
pub loaded: bool,
#[serde(default)]
pub mempoolminfee: f64, // in BTC/kB
}
#[derive(Serialize, Deserialize, Debug)]
@ -531,7 +533,7 @@ impl Daemon {
from_value(info).chain_err(|| "invalid blockchain info")
}
fn getmempoolinfo(&self) -> Result<MempoolInfo> {
pub fn getmempoolinfo(&self) -> Result<MempoolInfo> {
let info: Value = self.request("getmempoolinfo", json!([]))?;
from_value(info).chain_err(|| "invalid mempool info")
}

View File

@ -21,12 +21,19 @@ use crate::new_index::{
compute_script_hash, schema::FullHash, ChainQuery, FundingInfo, ScriptStats, SpendingInfo,
SpendingInput, TxHistoryInfo, Utxo,
};
use crate::util::fee_estimation::{FeeEstimator, RecommendedFees};
use crate::util::fees::{make_fee_histogram, TxFeeInfo};
use crate::util::gbt::{
build_projected_blocks, GbtTransaction, MempoolBlock, DEFAULT_BLOCK_WEIGHT,
};
use crate::util::{extract_tx_prevouts, full_hash, has_prevout, is_spendable, Bytes};
#[cfg(feature = "liquid")]
use crate::elements::asset;
/// Maximum number of projected blocks to build for fee estimation
pub const MAX_PROJECTED_BLOCKS: usize = 8;
pub struct Mempool {
chain: Arc<ChainQuery>,
config: Arc<Config>,
@ -36,6 +43,9 @@ pub struct Mempool {
edges: HashMap<OutPoint, (Txid, u32)>, // OutPoint -> (spending_txid, spending_vin)
recent: BoundedVecDeque<TxOverview>, // The N most recent txs to enter the mempool
backlog_stats: (BacklogStats, Instant),
projected_blocks: (Vec<MempoolBlock>, Instant), // Cached projected blocks
recommended_fees: (RecommendedFees, Instant), // Cached recommended fees
mempool_min_fee: f64, // Cached mempoolminfee in BTC/kB
// monitoring
latency: HistogramVec, // mempool requests latency
@ -61,6 +71,7 @@ pub struct TxOverview {
impl Mempool {
pub fn new(chain: Arc<ChainQuery>, metrics: &Metrics, config: Arc<Config>) -> Self {
let ttl = config.mempool_backlog_stats_ttl;
Mempool {
chain,
txstore: BTreeMap::new(),
@ -70,8 +81,14 @@ impl Mempool {
recent: BoundedVecDeque::new(config.mempool_recent_txs_size),
backlog_stats: (
BacklogStats::default(),
Instant::now() - Duration::from_secs(config.mempool_backlog_stats_ttl),
Instant::now() - Duration::from_secs(ttl),
),
projected_blocks: (Vec::new(), Instant::now() - Duration::from_secs(ttl)),
recommended_fees: (
RecommendedFees::default(),
Instant::now() - Duration::from_secs(ttl),
),
mempool_min_fee: 1.0, // Default: 1 sat/vB
latency: metrics.histogram_vec(
HistogramOpts::new("mempool_latency", "Mempool requests latency (in seconds)"),
&["part"],
@ -386,6 +403,16 @@ impl Mempool {
&self.backlog_stats.0
}
/// Get the projected mempool blocks
pub fn projected_blocks(&self) -> &[MempoolBlock] {
&self.projected_blocks.0
}
/// Get the recommended fees based on projected blocks
pub fn recommended_fees(&self) -> &RecommendedFees {
&self.recommended_fees.0
}
pub fn unique_txids(&self) -> HashSet<Txid> {
HashSet::from_iter(self.txstore.keys().cloned())
}
@ -409,6 +436,13 @@ impl Mempool {
let txids_to_remove: HashSet<&Txid> = old_txids.difference(&all_txids).collect();
let txids_to_add: Vec<&Txid> = all_txids.difference(&old_txids).collect();
// Get mempoolminfee for fee estimation
// [LOCK] No lock taken. Wait for RPC request.
let mempool_min_fee = daemon
.getmempoolinfo()
.map(|info| info.mempoolminfee * 100_000.0) // Convert from BTC/kB to sat/vB
.unwrap_or(1.0); // Default: 1 sat/vB
// 3. Remove missing transactions. Even if we are unable to download new transactions from
// the daemon, we still want to remove the transactions that are no longer in the mempool.
// [LOCK] Write lock is released at the end of the call to remove().
@ -420,7 +454,7 @@ impl Mempool {
.gettransactions(&txids_to_add)
.chain_err(|| format!("failed to get {} transactions", txids_to_add.len()))?;
// 4. Update local mempool to match daemon's state
// 5. Update local mempool to match daemon's state
// [LOCK] Takes Write lock for whole scope.
{
let mut mempool = mempool.write().unwrap();
@ -429,15 +463,17 @@ impl Mempool {
debug!("Mempool update added less transactions than expected");
}
// Update mempoolminfee
mempool.mempool_min_fee = mempool_min_fee;
mempool
.count
.with_label_values(&["txs"])
.set(mempool.txstore.len() as f64);
// Update cached backlog stats (if expired)
if mempool.backlog_stats.1.elapsed()
> Duration::from_secs(mempool.config.mempool_backlog_stats_ttl)
{
let ttl = Duration::from_secs(mempool.config.mempool_backlog_stats_ttl);
if mempool.backlog_stats.1.elapsed() > ttl {
let _timer = mempool
.latency
.with_label_values(&["update_backlog_stats"])
@ -445,6 +481,15 @@ impl Mempool {
mempool.backlog_stats = (BacklogStats::new(&mempool.feeinfo), Instant::now());
}
// Update projected blocks and recommended fees (if expired)
if mempool.projected_blocks.1.elapsed() > ttl {
let _timer = mempool
.latency
.with_label_values(&["update_projected_blocks"])
.start_timer();
mempool.update_projected_blocks();
}
Ok(())
}
}
@ -692,6 +737,56 @@ impl Mempool {
.retain(|_outpoint, (txid, _vin)| !to_remove.contains(txid));
}
/// Build projected mempool blocks and calculate recommended fees.
///
/// This method builds block templates using the GBT algorithm and
/// calculates recommended transaction fees based on the projected blocks.
fn update_projected_blocks(&mut self) {
// Build GBT transactions from mempool
let gbt_txs: Vec<GbtTransaction> = self
.txstore
.iter()
.filter_map(|(txid, tx)| {
let fee_info = self.feeinfo.get(txid)?;
// Get parent txids (inputs that are unconfirmed)
let parents: Vec<Txid> = tx
.input
.iter()
.filter(|txin| has_prevout(txin))
.filter_map(|txin| {
let parent_txid = txin.previous_output.txid;
if self.txstore.contains_key(&parent_txid) {
Some(parent_txid)
} else {
None
}
})
.collect();
Some(GbtTransaction::new(*txid, fee_info, parents))
})
.collect();
// Build projected blocks (up to MAX_PROJECTED_BLOCKS blocks)
let result = build_projected_blocks(&gbt_txs, DEFAULT_BLOCK_WEIGHT, MAX_PROJECTED_BLOCKS);
self.projected_blocks = (result.block_stats, Instant::now());
// Calculate recommended fees using cached mempoolminfee
let estimator = FeeEstimator::for_network(self.config.network_type);
let fees =
estimator.calculate_recommended_fees(&self.projected_blocks.0, self.mempool_min_fee);
self.recommended_fees = (fees, Instant::now());
debug!(
"Updated projected blocks: {} blocks, recommended fastest fee: {} sat/vB",
self.projected_blocks.0.len(),
self.recommended_fees.0.fastest_fee
);
}
#[cfg(feature = "liquid")]
pub fn asset_history(&self, asset_id: &AssetId, limit: usize) -> Vec<Transaction> {
let _timer = self

View File

@ -8,6 +8,7 @@ pub mod schema;
pub use self::db::{DBRow, DB};
pub use self::fetch::{BlockEntry, FetchFrom};
pub use self::mempool::Mempool;
pub use self::mempool::MAX_PROJECTED_BLOCKS;
pub use self::query::Query;
pub use self::schema::{
compute_script_hash, parse_hash, ChainQuery, FundingInfo, Indexer, ScriptStats, SpendingInfo,

View File

@ -210,26 +210,39 @@ impl Query {
.copied()
}
pub fn estimate_fee_map(&self) -> HashMap<u16, f64> {
if let (ref cache, Some(cache_time)) = *self.cached_estimates.read().unwrap() {
if cache_time.elapsed() < Duration::from_secs(FEE_ESTIMATES_TTL) {
return cache.clone();
}
}
self.update_fee_estimates();
self.cached_estimates.read().unwrap().0.clone()
}
fn update_fee_estimates(&self) {
match self.daemon.estimatesmartfee_batch(&CONF_TARGETS) {
Ok(estimates) => {
*self.cached_estimates.write().unwrap() = (estimates, Some(Instant::now()));
}
Err(err) => {
warn!("failed estimating feerates: {:?}", err);
let mempool = self.mempool.read().unwrap();
let projected_blocks = mempool.projected_blocks();
if projected_blocks.is_empty() {
// Fallback to Bitcoin Core RPC if no projected blocks available
drop(mempool);
match self.daemon.estimatesmartfee_batch(&CONF_TARGETS) {
Ok(estimates) => {
*self.cached_estimates.write().unwrap() = (estimates, Some(Instant::now()));
}
Err(err) => {
warn!("failed estimating feerates: {:?}", err);
}
}
return;
}
let mut estimates: HashMap<u16, f64> = HashMap::with_capacity(CONF_TARGETS.len());
let last_block_fee = projected_blocks.last().map(|b| b.median_fee).unwrap_or(1.0);
for target in CONF_TARGETS {
let fee = if (target as usize) <= projected_blocks.len() {
// Use the median fee from the corresponding projected block (target-1 for 0-indexed)
projected_blocks[(target as usize) - 1].median_fee
} else {
// For targets beyond available blocks, use the last block's fee
last_block_fee
};
estimates.insert(target, fee);
}
*self.cached_estimates.write().unwrap() = (estimates, Some(Instant::now()));
}
pub fn get_relayfee(&self) -> Result<f64> {

View File

@ -1771,8 +1771,16 @@ fn handle_request(
json_response(recent, TTL_MEMPOOL_RECENT)
}
(&Method::GET, Some(&"fee-estimates"), None, None, None, None) => {
json_response(query.estimate_fee_map(), TTL_SHORT)
// Recommended fees endpoint (mempool-style fee estimation)
(&Method::GET, Some(&"v1"), Some(&"fees"), Some(&"recommended"), None, None) => {
let mempool = query.mempool();
json_response(mempool.recommended_fees(), TTL_SHORT)
}
// Mempool blocks endpoint (projected blocks)
(&Method::GET, Some(&"v1"), Some(&"fees"), Some(&"mempool-blocks"), None, None) => {
let mempool = query.mempool();
json_response(mempool.projected_blocks(), TTL_SHORT)
}
#[cfg(feature = "liquid")]

376
src/util/fee_estimation.rs Normal file
View File

@ -0,0 +1,376 @@
//! Fee estimation based on projected mempool blocks.
//!
//! This module calculates recommended transaction fees based on the fee statistics
//! of projected mempool blocks (created by the GBT algorithm).
//!
//! Ported from mempool's fee-api.ts.
use crate::chain::Network;
use crate::util::gbt::MempoolBlock;
/// Recommended fee rates for different confirmation time targets
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct RecommendedFees {
/// Fee rate for confirmation in the next block (sat/vB)
pub fastest_fee: f64,
/// Fee rate for confirmation within ~30 minutes / 3 blocks (sat/vB)
pub half_hour_fee: f64,
/// Fee rate for confirmation within ~1 hour / 6 blocks (sat/vB)
pub hour_fee: f64,
/// Economy fee rate (sat/vB)
pub economy_fee: f64,
/// Minimum relay fee rate (sat/vB)
pub minimum_fee: f64,
}
impl Default for RecommendedFees {
fn default() -> Self {
Self {
fastest_fee: 1.0,
half_hour_fee: 1.0,
hour_fee: 1.0,
economy_fee: 1.0,
minimum_fee: 1.0,
}
}
}
/// Fee estimation configuration
#[derive(Debug, Clone)]
pub struct FeeEstimationConfig {
/// Minimum fee increment for rounding (sat/vB)
pub minimum_increment: f64,
/// Minimum fastest fee (sat/vB)
pub min_fastest_fee: f64,
/// Minimum half hour fee (sat/vB)
pub min_half_hour_fee: f64,
/// Priority factor added to highest priority recommendations (sat/vB)
pub priority_factor: f64,
}
impl FeeEstimationConfig {
/// Configuration for Bitcoin mainnet/testnet
pub fn bitcoin() -> Self {
Self {
minimum_increment: 1.0,
min_fastest_fee: 1.0,
min_half_hour_fee: 0.5,
priority_factor: 0.5,
}
}
/// Configuration for Liquid network
pub fn liquid() -> Self {
Self {
minimum_increment: 0.1,
min_fastest_fee: 0.1,
min_half_hour_fee: 0.1,
priority_factor: 0.0,
}
}
/// Create configuration based on network type
pub fn for_network(network: Network) -> Self {
if network.is_liquid() {
Self::liquid()
} else {
Self::bitcoin()
}
}
}
/// Fee estimator that calculates recommended fees from projected blocks
pub struct FeeEstimator {
config: FeeEstimationConfig,
}
impl FeeEstimator {
pub fn new(config: FeeEstimationConfig) -> Self {
Self { config }
}
/// Create a fee estimator for the given network
pub fn for_network(network: Network) -> Self {
Self::new(FeeEstimationConfig::for_network(network))
}
/// Calculate recommended fees from projected mempool blocks.
///
/// # Arguments
/// * `projected_blocks` - Projected mempool blocks from GBT algorithm
/// * `mempool_min_fee` - Minimum fee to get into mempool (BTC/kvB from getmempoolinfo)
pub fn calculate_recommended_fees(
&self,
projected_blocks: &[MempoolBlock],
mempool_min_fee: f64,
) -> RecommendedFees {
self.calculate_recommended_fees_with_increment(
projected_blocks,
mempool_min_fee,
self.config.minimum_increment,
)
}
/// Calculate precise recommended fees with sub-satoshi precision.
///
/// # Arguments
/// * `projected_blocks` - Projected mempool blocks from GBT algorithm
/// * `mempool_min_fee` - Minimum fee to get into mempool (BTC/kvB from getmempoolinfo)
pub fn calculate_precise_recommended_fees(
&self,
projected_blocks: &[MempoolBlock],
mempool_min_fee: f64,
) -> RecommendedFees {
// Use 0.001 sat/vB precision (minimum non-zero minrelaytxfee/incrementalrelayfee)
let mut recommendations = self.calculate_recommended_fees_with_increment(
projected_blocks,
mempool_min_fee,
0.001,
);
// Enforce floor & offset for highest priority recommendations
recommendations.fastest_fee = (recommendations.fastest_fee + self.config.priority_factor)
.max(self.config.min_fastest_fee);
recommendations.half_hour_fee = (recommendations.half_hour_fee
+ self.config.priority_factor / 2.0)
.max(self.config.min_half_hour_fee);
// Round to 3 decimal places
RecommendedFees {
fastest_fee: (recommendations.fastest_fee * 1000.0).round() / 1000.0,
half_hour_fee: (recommendations.half_hour_fee * 1000.0).round() / 1000.0,
hour_fee: (recommendations.hour_fee * 1000.0).round() / 1000.0,
economy_fee: (recommendations.economy_fee * 1000.0).round() / 1000.0,
minimum_fee: (recommendations.minimum_fee * 1000.0).round() / 1000.0,
}
}
/// Internal fee calculation with configurable increment.
fn calculate_recommended_fees_with_increment(
&self,
projected_blocks: &[MempoolBlock],
mempool_min_fee: f64,
min_increment: f64,
) -> RecommendedFees {
let purge_rate = round_up_to_nearest(mempool_min_fee, min_increment);
let minimum_fee = purge_rate.max(min_increment);
if projected_blocks.is_empty() {
return RecommendedFees {
fastest_fee: minimum_fee,
half_hour_fee: minimum_fee,
hour_fee: minimum_fee,
economy_fee: minimum_fee,
minimum_fee,
};
}
// Calculate median fees for first 3 blocks
let first_median_fee = self.optimize_median_fee(
&projected_blocks[0],
projected_blocks.get(1),
None,
minimum_fee,
min_increment,
);
let second_median_fee = projected_blocks.get(1).map_or(minimum_fee, |block| {
self.optimize_median_fee(
block,
projected_blocks.get(2),
Some(first_median_fee),
minimum_fee,
min_increment,
)
});
let third_median_fee = projected_blocks.get(2).map_or(minimum_fee, |block| {
self.optimize_median_fee(
block,
projected_blocks.get(3),
Some(second_median_fee),
minimum_fee,
min_increment,
)
});
// Enforce minimum fee on all recommendations
let mut fastest_fee = first_median_fee.max(minimum_fee);
let mut half_hour_fee = second_median_fee.max(minimum_fee);
let mut hour_fee = third_median_fee.max(minimum_fee);
let economy_fee = (2.0 * minimum_fee).min(third_median_fee).max(minimum_fee);
// Ensure recommendations always increase with priority
fastest_fee = fastest_fee
.max(half_hour_fee)
.max(hour_fee)
.max(economy_fee);
half_hour_fee = half_hour_fee.max(hour_fee).max(economy_fee);
hour_fee = hour_fee.max(economy_fee);
RecommendedFees {
fastest_fee: round_to_nearest(fastest_fee, min_increment),
half_hour_fee: round_to_nearest(half_hour_fee, min_increment),
hour_fee: round_to_nearest(hour_fee, min_increment),
economy_fee: round_to_nearest(economy_fee, min_increment),
minimum_fee: round_to_nearest(minimum_fee, min_increment),
}
}
/// Optimize median fee based on block fullness.
///
/// For partially full blocks, the fee is scaled down proportionally.
fn optimize_median_fee(
&self,
block: &MempoolBlock,
next_block: Option<&MempoolBlock>,
previous_fee: Option<f64>,
min_fee: f64,
min_increment: f64,
) -> f64 {
let use_fee = match previous_fee {
Some(prev) => (block.median_fee + prev) / 2.0,
None => block.median_fee,
};
// If block is less than half full or median fee is below minimum, use minimum
if block.block_vsize <= 500_000.0 || block.median_fee < min_fee {
return min_fee;
}
// If block is between 50-95% full and there's no next block,
// scale the fee proportionally
if block.block_vsize <= 950_000.0 && next_block.is_none() {
let multiplier = (block.block_vsize - 500_000.0) / 500_000.0;
return round_to_nearest(use_fee * multiplier, min_increment).max(min_fee);
}
round_up_to_nearest(use_fee, min_increment).max(min_fee)
}
}
/// Round up to the nearest increment
fn round_up_to_nearest(value: f64, nearest: f64) -> f64 {
if nearest != 0.0 {
(value / nearest).ceil() * nearest
} else {
value
}
}
/// Round to the nearest increment
fn round_to_nearest(value: f64, nearest: f64) -> f64 {
if nearest != 0.0 {
(value / nearest).round() * nearest
} else {
value
}
}
#[cfg(test)]
mod tests {
use super::*;
fn create_test_block(vsize: f64, median_fee: f64) -> MempoolBlock {
MempoolBlock {
block_size: vsize as u64,
block_vsize: vsize,
n_tx: 1000,
total_fees: 1000000,
median_fee,
fee_range: vec![1.0, 2.0, 3.0, median_fee, 5.0, 6.0, 7.0],
}
}
#[test]
fn test_empty_mempool() {
let estimator = FeeEstimator::new(FeeEstimationConfig::bitcoin());
let fees = estimator.calculate_recommended_fees(&[], 0.00001);
assert_eq!(fees.fastest_fee, 1.0);
assert_eq!(fees.half_hour_fee, 1.0);
assert_eq!(fees.hour_fee, 1.0);
assert_eq!(fees.economy_fee, 1.0);
assert_eq!(fees.minimum_fee, 1.0);
}
#[test]
fn test_sub_sat_mempool() {
let estimator = FeeEstimator::new(FeeEstimationConfig::bitcoin());
// Use median fee slightly above 1.0 (like the real mempool data: 1.002...)
// This tests the rounding behavior
let blocks = vec![
create_test_block(997953.25, 1.002), // Rounds up to 2
create_test_block(997963.0, 0.6),
create_test_block(997821.25, 0.52),
];
let fees = estimator.calculate_recommended_fees(&blocks, 0.000001);
assert_eq!(fees.fastest_fee, 2.0);
assert_eq!(fees.half_hour_fee, 1.0);
assert_eq!(fees.hour_fee, 1.0);
assert_eq!(fees.economy_fee, 1.0);
assert_eq!(fees.minimum_fee, 1.0);
}
#[test]
fn test_low_fee_mempool() {
let estimator = FeeEstimator::new(FeeEstimationConfig::bitcoin());
let blocks = vec![
create_test_block(997953.25, 2.0),
create_test_block(997963.0, 1.5),
create_test_block(997821.25, 1.0),
];
let fees = estimator.calculate_recommended_fees(&blocks, 0.00001);
assert_eq!(fees.fastest_fee, 2.0);
assert_eq!(fees.half_hour_fee, 2.0);
assert_eq!(fees.hour_fee, 2.0);
assert_eq!(fees.economy_fee, 2.0);
assert_eq!(fees.minimum_fee, 1.0);
}
#[test]
fn test_partially_full_block() {
let estimator = FeeEstimator::new(FeeEstimationConfig::bitcoin());
// Block that's 75% full (750000 vsize)
let blocks = vec![create_test_block(750_000.0, 10.0)];
let fees = estimator.calculate_recommended_fees(&blocks, 0.00001);
// Fee should be scaled down because block isn't full and there's no next block
// multiplier = (750000 - 500000) / 500000 = 0.5
// So fee should be 10 * 0.5 = 5, rounded to 5
assert_eq!(fees.fastest_fee, 5.0);
}
#[test]
fn test_liquid_config() {
let estimator = FeeEstimator::new(FeeEstimationConfig::liquid());
let fees = estimator.calculate_recommended_fees(&[], 0.000001);
// Liquid uses 0.1 as minimum
assert_eq!(fees.minimum_fee, 0.1);
}
#[test]
fn test_round_up_to_nearest() {
assert_eq!(round_up_to_nearest(1.1, 1.0), 2.0);
assert_eq!(round_up_to_nearest(1.0, 1.0), 1.0);
assert_eq!(round_up_to_nearest(0.15, 0.1), 0.2);
assert_eq!(round_up_to_nearest(5.0, 0.0), 5.0);
}
#[test]
fn test_round_to_nearest() {
assert_eq!(round_to_nearest(1.4, 1.0), 1.0);
assert_eq!(round_to_nearest(1.6, 1.0), 2.0);
assert_eq!(round_to_nearest(0.14, 0.1), 0.1);
assert_eq!(round_to_nearest(0.16, 0.1), 0.2);
}
}

View File

@ -1,11 +1,14 @@
use crate::chain::{Network, Transaction, TxOut};
use crate::util::transaction::sigops::transaction_sigop_count;
use std::collections::HashMap;
const VSIZE_BIN_WIDTH: u32 = 50_000; // in vbytes
pub struct TxFeeInfo {
pub fee: u64, // in satoshis
pub vsize: u32, // in virtual bytes (= weight/4)
pub fee: u64, // in satoshis
pub vsize: u32, // in virtual bytes (= weight/4)
pub weight: u32, // transaction weight
pub sigops: u32, // signature operations count
pub fee_per_vbyte: f32,
}
@ -13,13 +16,19 @@ impl TxFeeInfo {
pub fn new(tx: &Transaction, prevouts: &HashMap<u32, &TxOut>, network: Network) -> Self {
let fee = get_tx_fee(tx, prevouts, network);
#[cfg(not(feature = "liquid"))]
let vsize = tx.weight().to_wu() / 4;
let weight = tx.weight().to_wu() as u32;
#[cfg(feature = "liquid")]
let vsize = tx.weight() / 4;
let weight = tx.weight() as u32;
let vsize = weight / 4;
// Calculate sigops, defaulting to 0 on error (e.g., coinbase)
let sigops = transaction_sigop_count(tx, prevouts).unwrap_or(0) as u32;
TxFeeInfo {
fee,
vsize: vsize as u32,
vsize,
weight,
sigops,
fee_per_vbyte: fee as f32 / vsize as f32,
}
}

702
src/util/gbt.rs Normal file
View File

@ -0,0 +1,702 @@
//! Block template construction (GetBlockTemplate) algorithm.
//!
//! This module implements an approximation of the transaction selection algorithm
//! from Bitcoin Core's BlockAssembler to create projected mempool blocks.
//!
//! Ported from mempool's Rust GBT implementation.
use std::collections::{BinaryHeap, HashMap, HashSet};
use crate::chain::Txid;
use crate::util::fees::TxFeeInfo;
/// Default block weight limit (4MB weight = 1MB vsize for worst case)
pub const DEFAULT_BLOCK_WEIGHT: u32 = 4_000_000;
/// Maximum sigops per block
const BLOCK_SIGOPS: u32 = 80_000;
/// Reserved weight for coinbase
const BLOCK_RESERVED_WEIGHT: u32 = 4_000;
/// Reserved sigops for coinbase
const BLOCK_RESERVED_SIGOPS: u32 = 400;
/// A projected mempool block with fee statistics
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct MempoolBlock {
/// Total size of transactions in bytes
pub block_size: u64,
/// Total virtual size of transactions (weight/4)
pub block_vsize: f64,
/// Number of transactions
pub n_tx: usize,
/// Total fees in satoshis
pub total_fees: u64,
/// Median fee rate in sat/vB
pub median_fee: f64,
/// Fee rate range [min, 10th, 25th, 50th, 75th, 90th, max] in sat/vB
pub fee_range: Vec<f64>,
}
impl Default for MempoolBlock {
fn default() -> Self {
Self {
block_size: 0,
block_vsize: 0.0,
n_tx: 0,
total_fees: 0,
median_fee: 0.0,
fee_range: vec![0.0; 7],
}
}
}
/// Transaction data for block template construction
#[derive(Debug, Clone)]
pub struct GbtTransaction {
pub txid: Txid,
pub fee: u64,
pub weight: u32,
pub sigops: u32,
/// Indices of parent transactions in the mempool (by txid)
pub parents: Vec<Txid>,
}
impl GbtTransaction {
pub fn new(txid: Txid, fee_info: &TxFeeInfo, parents: Vec<Txid>) -> Self {
Self {
txid,
fee: fee_info.fee,
weight: fee_info.weight,
sigops: fee_info.sigops,
parents,
}
}
#[inline]
pub fn vsize(&self) -> u32 {
self.weight.div_ceil(4)
}
#[inline]
pub fn fee_rate(&self) -> f64 {
self.fee as f64 / self.vsize() as f64
}
/// Calculate sigop-adjusted vsize (rounded up)
#[inline]
pub fn sigop_adjusted_vsize(&self) -> u32 {
self.vsize().max(self.sigops * 5)
}
/// Calculate sigop-adjusted weight
#[inline]
pub fn sigop_adjusted_weight(&self) -> u32 {
self.weight.max(self.sigops * 20)
}
}
/// Internal audit transaction for GBT algorithm
#[derive(Debug, Clone)]
struct AuditTransaction {
fee: u64,
weight: u32,
sigop_adjusted_weight: u32,
sigop_adjusted_vsize: u32,
sigops: u32,
effective_fee_per_vsize: f64,
parents: Vec<Txid>,
ancestors: HashSet<Txid>,
children: HashSet<Txid>,
ancestor_fee: u64,
ancestor_sigop_adjusted_weight: u32,
ancestor_sigop_adjusted_vsize: u32,
ancestor_sigops: u32,
score: f64,
used: bool,
modified: bool,
}
impl AuditTransaction {
fn from_gbt_tx(tx: &GbtTransaction) -> Self {
let sigop_adjusted_vsize = tx.sigop_adjusted_vsize();
let sigop_adjusted_weight = tx.sigop_adjusted_weight();
let fee_per_vsize = tx.fee_rate();
Self {
fee: tx.fee,
weight: tx.weight,
sigop_adjusted_weight,
sigop_adjusted_vsize,
sigops: tx.sigops,
effective_fee_per_vsize: fee_per_vsize,
parents: tx.parents.clone(),
ancestors: HashSet::new(),
children: HashSet::new(),
ancestor_fee: tx.fee,
ancestor_sigop_adjusted_weight: sigop_adjusted_weight,
ancestor_sigop_adjusted_vsize: sigop_adjusted_vsize,
ancestor_sigops: tx.sigops,
score: fee_per_vsize,
used: false,
modified: false,
}
}
#[inline]
fn ancestor_score(&self) -> f64 {
if self.ancestor_sigop_adjusted_vsize == 0 {
0.0
} else {
self.ancestor_fee as f64 / self.ancestor_sigop_adjusted_vsize as f64
}
}
fn update_score(&mut self) {
self.score = self.ancestor_score();
}
}
/// Priority entry for the modified transactions queue
#[derive(Debug, Clone)]
struct TxPriority {
txid: Txid,
score: f64,
}
impl PartialEq for TxPriority {
fn eq(&self, other: &Self) -> bool {
self.txid == other.txid
}
}
impl Eq for TxPriority {}
impl PartialOrd for TxPriority {
fn partial_cmp(&self, other: &Self) -> Option<std::cmp::Ordering> {
Some(self.cmp(other))
}
}
impl Ord for TxPriority {
fn cmp(&self, other: &Self) -> std::cmp::Ordering {
// Higher score = higher priority (reverse order for BinaryHeap)
self.score
.partial_cmp(&other.score)
.unwrap_or(std::cmp::Ordering::Equal)
}
}
/// Result of the GBT algorithm
#[derive(Debug)]
pub struct GbtResult {
/// Projected blocks, each containing transaction IDs
pub blocks: Vec<Vec<Txid>>,
/// Statistics for each projected block
pub block_stats: Vec<MempoolBlock>,
}
/// Build projected mempool blocks using an approximation of Bitcoin Core's transaction selection.
///
/// Returns up to `max_blocks` projected blocks with their fee statistics.
pub fn build_projected_blocks(
transactions: &[GbtTransaction],
max_block_weight: u32,
max_blocks: usize,
) -> GbtResult {
if transactions.is_empty() || max_blocks == 0 {
return GbtResult {
blocks: vec![],
block_stats: vec![],
};
}
// Build audit pool indexed by txid
let mut audit_pool: HashMap<Txid, AuditTransaction> = transactions
.iter()
.map(|tx| (tx.txid, AuditTransaction::from_gbt_tx(tx)))
.collect();
// Set up ancestor/descendant relationships
let txids: Vec<Txid> = audit_pool.keys().cloned().collect();
for txid in &txids {
set_relatives(txid, &mut audit_pool);
}
// Sort by descending ancestor score
let mut mempool_stack: Vec<Txid> = txids;
mempool_stack.sort_by(|a, b| {
let score_a = audit_pool.get(a).map(|tx| tx.score).unwrap_or(0.0);
let score_b = audit_pool.get(b).map(|tx| tx.score).unwrap_or(0.0);
score_b
.partial_cmp(&score_a)
.unwrap_or(std::cmp::Ordering::Equal)
});
// Build blocks
let mut blocks: Vec<Vec<Txid>> = Vec::new();
let mut block_fee_rates: Vec<Vec<f64>> = Vec::new();
let mut current_block: Vec<Txid> = Vec::new();
let mut current_fee_rates: Vec<f64> = Vec::new();
let mut block_weight: u32 = BLOCK_RESERVED_WEIGHT;
let mut block_sigops: u32 = BLOCK_RESERVED_SIGOPS;
#[allow(unused_variables)]
let mut block_fees: u64 = 0;
let mut modified: BinaryHeap<TxPriority> = BinaryHeap::new();
let mut overflow: Vec<Txid> = Vec::new();
let mut failures = 0;
while (!mempool_stack.is_empty() || !modified.is_empty()) && blocks.len() < max_blocks {
// Get next best transaction from either stack or modified queue
let next_txid = get_next_tx(&mut mempool_stack, &mut modified, &audit_pool);
let next_txid = match next_txid {
Some(txid) => txid,
None => break,
};
let (ancestor_weight, ancestor_sigops, _ancestor_fee, ancestor_score) = {
let tx = match audit_pool.get(&next_txid) {
Some(tx) if !tx.used => tx,
_ => continue,
};
(
tx.ancestor_sigop_adjusted_weight,
tx.ancestor_sigops,
tx.ancestor_fee,
tx.score,
)
};
// Check if this package fits in the current block
if blocks.len() < max_blocks - 1
&& (block_weight + ancestor_weight >= max_block_weight - BLOCK_RESERVED_WEIGHT
|| block_sigops + ancestor_sigops > BLOCK_SIGOPS)
{
overflow.push(next_txid);
failures += 1;
} else {
// Add the package (ancestors + this transaction) to the block
let package = get_package(&next_txid, &audit_pool);
for pkg_txid in &package {
if let Some(tx) = audit_pool.get_mut(pkg_txid) {
if !tx.used {
tx.used = true;
current_block.push(*pkg_txid);
current_fee_rates.push(tx.effective_fee_per_vsize);
block_weight += tx.sigop_adjusted_weight;
block_sigops += tx.sigops;
block_fees += tx.fee;
}
}
}
// Update descendants
update_descendants(&next_txid, &mut audit_pool, &mut modified, ancestor_score);
failures = 0;
}
// Check if block is full
let exceeded_tries =
failures > 1000 && block_weight > (max_block_weight - BLOCK_RESERVED_WEIGHT - 4_000);
let queues_empty = mempool_stack.is_empty() && modified.is_empty();
if (exceeded_tries || queues_empty)
&& blocks.len() < max_blocks - 1
&& !current_block.is_empty()
{
blocks.push(std::mem::take(&mut current_block));
block_fee_rates.push(std::mem::take(&mut current_fee_rates));
block_weight = BLOCK_RESERVED_WEIGHT;
block_sigops = BLOCK_RESERVED_SIGOPS;
block_fees = 0;
failures = 0;
// Move overflow back to processing
overflow.reverse();
for txid in overflow.drain(..) {
if let Some(tx) = audit_pool.get(&txid) {
if tx.modified {
modified.push(TxPriority {
txid,
score: tx.score,
});
} else {
mempool_stack.push(txid);
}
}
}
}
}
// Add final block if not empty
if !current_block.is_empty() {
blocks.push(current_block);
block_fee_rates.push(current_fee_rates);
}
// Calculate block statistics
let block_stats: Vec<MempoolBlock> = blocks
.iter()
.zip(block_fee_rates.iter())
.map(|(block_txids, fee_rates)| calculate_block_stats(block_txids, fee_rates, &audit_pool))
.collect();
GbtResult {
blocks,
block_stats,
}
}
fn get_next_tx(
mempool_stack: &mut Vec<Txid>,
modified: &mut BinaryHeap<TxPriority>,
audit_pool: &HashMap<Txid, AuditTransaction>,
) -> Option<Txid> {
loop {
// Get candidates from both queues
let stack_candidate = mempool_stack.last().and_then(|txid| {
audit_pool
.get(txid)
.filter(|tx| !tx.used && !tx.modified)
.map(|tx| (*txid, tx.score))
});
let modified_candidate = modified.peek().and_then(|priority| {
audit_pool
.get(&priority.txid)
.filter(|tx| !tx.used)
.map(|tx| (priority.txid, tx.score))
});
match (stack_candidate, modified_candidate) {
(Some((stack_txid, stack_score)), Some((mod_txid, mod_score))) => {
if mod_score >= stack_score {
modified.pop();
return Some(mod_txid);
} else {
mempool_stack.pop();
return Some(stack_txid);
}
}
(Some((txid, _)), None) => {
mempool_stack.pop();
return Some(txid);
}
(None, Some((txid, _))) => {
modified.pop();
return Some(txid);
}
(None, None) => {
// Try to clean up invalid entries
if mempool_stack.pop().is_some() {
continue;
}
if modified.pop().is_some() {
continue;
}
return None;
}
}
}
}
fn set_relatives(txid: &Txid, audit_pool: &mut HashMap<Txid, AuditTransaction>) {
// Get parents for this transaction
let parents: Vec<Txid> = match audit_pool.get(txid) {
Some(tx) => tx
.parents
.iter()
.filter(|p| audit_pool.contains_key(*p))
.cloned()
.collect(),
None => return,
};
// Recursively set relatives for parents first
for parent_txid in &parents {
if audit_pool
.get(parent_txid)
.map(|tx| tx.ancestors.is_empty() && !tx.parents.is_empty())
.unwrap_or(false)
{
set_relatives(parent_txid, audit_pool);
}
}
// Collect ancestor info
let mut ancestors: HashSet<Txid> = HashSet::new();
let mut total_fee: u64 = 0;
let mut total_sigop_adjusted_weight: u32 = 0;
let mut total_sigop_adjusted_vsize: u32 = 0;
let mut total_sigops: u32 = 0;
for parent_txid in &parents {
if let Some(parent) = audit_pool.get(parent_txid) {
ancestors.insert(*parent_txid);
for ancestor in &parent.ancestors {
ancestors.insert(*ancestor);
}
total_fee += parent.fee;
total_sigop_adjusted_weight += parent.sigop_adjusted_weight;
total_sigop_adjusted_vsize += parent.sigop_adjusted_vsize;
total_sigops += parent.sigops;
}
}
// Add ancestor stats from indirect ancestors
for ancestor_txid in &ancestors {
if !parents.contains(ancestor_txid) {
if let Some(ancestor) = audit_pool.get(ancestor_txid) {
total_fee += ancestor.fee;
total_sigop_adjusted_weight += ancestor.sigop_adjusted_weight;
total_sigop_adjusted_vsize += ancestor.sigop_adjusted_vsize;
total_sigops += ancestor.sigops;
}
}
}
// Update the transaction
if let Some(tx) = audit_pool.get_mut(txid) {
tx.ancestors = ancestors;
tx.ancestor_fee += total_fee;
tx.ancestor_sigop_adjusted_weight += total_sigop_adjusted_weight;
tx.ancestor_sigop_adjusted_vsize += total_sigop_adjusted_vsize;
tx.ancestor_sigops += total_sigops;
tx.update_score();
}
// Update children of parents
for parent_txid in parents {
if let Some(parent) = audit_pool.get_mut(&parent_txid) {
parent.children.insert(*txid);
}
}
}
fn get_package(txid: &Txid, audit_pool: &HashMap<Txid, AuditTransaction>) -> Vec<Txid> {
let mut package: Vec<(Txid, usize)> = Vec::new();
if let Some(tx) = audit_pool.get(txid) {
// Add ancestors first, sorted by ancestor count (so parents come before children)
for ancestor_txid in &tx.ancestors {
if let Some(ancestor) = audit_pool.get(ancestor_txid) {
if !ancestor.used {
package.push((*ancestor_txid, ancestor.ancestors.len()));
}
}
}
package.sort_by_key(|(_, count)| *count);
// Add the transaction itself
package.push((*txid, tx.ancestors.len()));
}
package.into_iter().map(|(txid, _)| txid).collect()
}
fn update_descendants(
root_txid: &Txid,
audit_pool: &mut HashMap<Txid, AuditTransaction>,
modified: &mut BinaryHeap<TxPriority>,
cluster_rate: f64,
) {
let (root_fee, root_sigop_adjusted_weight, root_sigop_adjusted_vsize, root_sigops, children) = {
match audit_pool.get(root_txid) {
Some(tx) => (
tx.fee,
tx.sigop_adjusted_weight,
tx.sigop_adjusted_vsize,
tx.sigops,
tx.children.clone(),
),
None => return,
}
};
let mut visited: HashSet<Txid> = HashSet::new();
let mut stack: Vec<Txid> = children.into_iter().collect();
while let Some(desc_txid) = stack.pop() {
if visited.contains(&desc_txid) {
continue;
}
visited.insert(desc_txid);
let children_to_add: Vec<Txid>;
let old_score: f64;
let new_score: f64;
{
let descendant = match audit_pool.get_mut(&desc_txid) {
Some(tx) => tx,
None => continue,
};
old_score = descendant.score;
// Remove root from ancestors
descendant.ancestors.remove(root_txid);
descendant.ancestor_fee = descendant.ancestor_fee.saturating_sub(root_fee);
descendant.ancestor_sigop_adjusted_weight = descendant
.ancestor_sigop_adjusted_weight
.saturating_sub(root_sigop_adjusted_weight);
descendant.ancestor_sigop_adjusted_vsize = descendant
.ancestor_sigop_adjusted_vsize
.saturating_sub(root_sigop_adjusted_vsize);
descendant.ancestor_sigops = descendant.ancestor_sigops.saturating_sub(root_sigops);
// Update effective fee rate based on cluster rate
if cluster_rate < descendant.effective_fee_per_vsize {
descendant.effective_fee_per_vsize = cluster_rate;
}
descendant.update_score();
new_score = descendant.score;
children_to_add = descendant.children.iter().cloned().collect();
}
// Add to modified queue if score changed
if (new_score - old_score).abs() > f64::EPSILON {
if let Some(tx) = audit_pool.get_mut(&desc_txid) {
tx.modified = true;
}
modified.push(TxPriority {
txid: desc_txid,
score: new_score,
});
}
// Add children to stack
for child in children_to_add {
if !visited.contains(&child) {
stack.push(child);
}
}
}
}
fn calculate_block_stats(
txids: &[Txid],
fee_rates: &[f64],
audit_pool: &HashMap<Txid, AuditTransaction>,
) -> MempoolBlock {
if txids.is_empty() {
return MempoolBlock::default();
}
let mut total_size: u64 = 0;
let mut total_weight: u64 = 0;
let mut total_fees: u64 = 0;
for txid in txids {
if let Some(tx) = audit_pool.get(txid) {
total_weight += tx.weight as u64;
total_size += tx.weight as u64 / 4; // Approximate size
total_fees += tx.fee;
}
}
let mut sorted_rates = fee_rates.to_vec();
sorted_rates.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
let n = sorted_rates.len();
let median_fee = if n == 0 {
0.0
} else if n % 2 == 0 {
(sorted_rates[n / 2 - 1] + sorted_rates[n / 2]) / 2.0
} else {
sorted_rates[n / 2]
};
// Calculate percentiles for fee range: [min, 10th, 25th, 50th, 75th, 90th, max]
let fee_range = if n == 0 {
vec![0.0; 7]
} else {
vec![
sorted_rates[0],
sorted_rates[(n as f64 * 0.1) as usize],
sorted_rates[(n as f64 * 0.25) as usize],
sorted_rates[(n as f64 * 0.5) as usize],
sorted_rates[((n as f64 * 0.75) as usize).min(n - 1)],
sorted_rates[((n as f64 * 0.9) as usize).min(n - 1)],
sorted_rates[n - 1],
]
};
MempoolBlock {
block_size: total_size,
block_vsize: total_weight as f64 / 4.0,
n_tx: txids.len(),
total_fees,
median_fee,
fee_range,
}
}
#[cfg(test)]
mod tests {
use super::*;
use bitcoin::hashes::Hash;
fn make_txid(n: u8) -> Txid {
let mut bytes = [0u8; 32];
bytes[0] = n;
Txid::from_slice(&bytes).unwrap()
}
#[test]
fn test_empty_mempool() {
let result = build_projected_blocks(&[], DEFAULT_BLOCK_WEIGHT, 8);
assert!(result.blocks.is_empty());
assert!(result.block_stats.is_empty());
}
#[test]
fn test_single_transaction() {
let txid = make_txid(1);
let tx = GbtTransaction {
txid,
fee: 1000,
weight: 400,
sigops: 1,
parents: vec![],
};
let result = build_projected_blocks(&[tx], DEFAULT_BLOCK_WEIGHT, 8);
assert_eq!(result.blocks.len(), 1);
assert_eq!(result.blocks[0].len(), 1);
assert_eq!(result.blocks[0][0], txid);
}
#[test]
fn test_parent_child_relationship() {
let parent_txid = make_txid(1);
let child_txid = make_txid(2);
let parent = GbtTransaction {
txid: parent_txid,
fee: 500,
weight: 400,
sigops: 1,
parents: vec![],
};
let child = GbtTransaction {
txid: child_txid,
fee: 1000,
weight: 400,
sigops: 1,
parents: vec![parent_txid],
};
let result = build_projected_blocks(&[parent, child], DEFAULT_BLOCK_WEIGHT, 8);
assert_eq!(result.blocks.len(), 1);
assert_eq!(result.blocks[0].len(), 2);
// Parent should come before child
let parent_pos = result.blocks[0].iter().position(|&t| t == parent_txid);
let child_pos = result.blocks[0].iter().position(|&t| t == child_txid);
assert!(parent_pos < child_pos);
}
}

View File

@ -4,7 +4,9 @@ mod transaction;
pub mod bincode_util;
pub mod electrum_merkle;
pub mod fee_estimation;
pub mod fees;
pub mod gbt;
pub use self::block::{BlockHeaderMeta, BlockId, BlockMeta, BlockStatus, HeaderEntry, HeaderList};
pub use self::fees::get_tx_fee;