diff --git a/.github/workflows/bitcoin-tests.yml b/.github/workflows/bitcoin-tests.yml index 1d9ed5b18c..8d342075ae 100644 --- a/.github/workflows/bitcoin-tests.yml +++ b/.github/workflows/bitcoin-tests.yml @@ -54,6 +54,8 @@ jobs: - tests::neon_integrations::filter_low_fee_tx_integration_test - tests::neon_integrations::filter_long_runtime_tx_integration_test - tests::neon_integrations::mining_transactions_is_fair + - tests::neon_integrations::fuzzed_median_fee_rate_estimation_test_window5 + - tests::neon_integrations::fuzzed_median_fee_rate_estimation_test_window10 - tests::neon_integrations::use_latest_tip_integration_test - tests::epoch_205::test_dynamic_db_method_costs - tests::epoch_205::transition_empty_blocks diff --git a/CHANGELOG.md b/CHANGELOG.md index 41925503b9..df91491586 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,7 +5,17 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to the versioning scheme outlined in the [README.md](README.md). -## [Unreleased] +## [Upcoming] + +### Added + +- A new fee estimator intended to produce fewer over-estimates, by having less + sensitivity to outliers. Its characteristic features are: 1) use a window to + forget past estimates instead of exponential averaging, 2) use weighted + percentiles, so that bigger transactions influence the estimates more, 3) + assess empty space in blocks as having paid the "minimum fee", so that empty + space is accounted for, 4) use random "fuzz" so that in busy times we will + not have ties. ### Changed diff --git a/README.md b/README.md index 070926e428..24aa01b475 100644 --- a/README.md +++ b/README.md @@ -361,7 +361,9 @@ Fee and cost estimators can be configure via the config section `[fee_estimation ``` [fee_estimation] cost_estimator = naive_pessimistic -fee_estimator = scalar_fee_rate +fee_estimator = fuzzed_weighted_median_fee_rate +fee_rate_fuzzer_fraction = 0.1 +fee_rate_window_size = 5 cost_metric = proportion_dot_product log_error = true enabled = true @@ -378,6 +380,11 @@ are **not** consensus-critical components, but rather can be used by miners to rank transactions in the mempool or client to determine appropriate fee rates for transactions before broadcasting them. +The `fuzzed_weighted_median_fee_rate` uses a +median estimate from a window of the fees paid in the last `fee_rate_window_size` blocks. +Estimates are then randomly "fuzzed" using uniform random fuzz of size up to +`fee_rate_fuzzer_fraction` of the base estimate. + ## Non-Consensus Breaking Release Process For non-consensus breaking releases, this project uses the following release process: diff --git a/src/cost_estimates/fee_medians.rs b/src/cost_estimates/fee_medians.rs new file mode 100644 index 0000000000..a2c1bc88e6 --- /dev/null +++ b/src/cost_estimates/fee_medians.rs @@ -0,0 +1,359 @@ +use std::cmp; +use std::cmp::Ordering; +use std::convert::TryFrom; +use std::{iter::FromIterator, path::Path}; + +use rusqlite::AndThenRows; +use rusqlite::Transaction as SqlTransaction; +use rusqlite::{ + types::{FromSql, FromSqlError}, + Connection, Error as SqliteError, OptionalExtension, ToSql, +}; +use serde_json::Value as JsonValue; + +use chainstate::stacks::TransactionPayload; +use util::db::sqlite_open; +use util::db::tx_begin_immediate_sqlite; +use util::db::u64_to_sql; + +use vm::costs::ExecutionCost; + +use chainstate::stacks::db::StacksEpochReceipt; +use chainstate::stacks::events::TransactionOrigin; + +use crate::util::db::sql_pragma; +use crate::util::db::table_exists; + +use super::metrics::CostMetric; +use super::FeeRateEstimate; +use super::{EstimatorError, FeeEstimator}; + +use super::metrics::PROPORTION_RESOLUTION; +use cost_estimates::StacksTransactionReceipt; + +const CREATE_TABLE: &'static str = " +CREATE TABLE median_fee_estimator ( + measure_key INTEGER PRIMARY KEY AUTOINCREMENT, + high NUMBER NOT NULL, + middle NUMBER NOT NULL, + low NUMBER NOT NULL +)"; + +const MINIMUM_TX_FEE_RATE: f64 = 1f64; + +/// FeeRateEstimator with the following properties: +/// +/// 1) We use a "weighted" percentile approach for calculating the percentile values. Described +/// below, larger transactions contribute more to the ranking than small transactions. +/// 2) Use "windowed" decay instead of exponential decay. This allows outliers to be forgotten +/// faster, and so reduces the influence of outliers. +/// 3) "Pad" the block, so that any unused spaces is considered to have an associated fee rate of +/// 1f, the minimum. Ignoring the amount of empty space leads to over-estimates because it +/// ignores the fact that there was still space in the block. +pub struct WeightedMedianFeeRateEstimator { + db: Connection, + /// We only look back `window_size` fee rates when averaging past estimates. + window_size: u32, + /// The weight of a "full block" in abstract scalar cost units. This is the weight of + /// a block that is filled *one single* dimension. + full_block_weight: u64, + /// Use this cost metric in fee rate calculations. + metric: M, +} + +/// Convenience struct for passing around this pair. +#[derive(Debug)] +pub struct FeeRateAndWeight { + pub fee_rate: f64, + pub weight: u64, +} + +impl WeightedMedianFeeRateEstimator { + /// Open a fee rate estimator at the given db path. Creates if not existent. + pub fn open(p: &Path, metric: M, window_size: u32) -> Result { + let mut db = sqlite_open( + p, + rusqlite::OpenFlags::SQLITE_OPEN_CREATE | rusqlite::OpenFlags::SQLITE_OPEN_READ_WRITE, + false, + )?; + + // check if the db needs to be instantiated regardless of whether or not + // it was newly created: the db itself may be shared with other fee estimators, + // which would not have created the necessary table for this estimator. + let tx = tx_begin_immediate_sqlite(&mut db)?; + Self::instantiate_db(&tx)?; + tx.commit()?; + + Ok(Self { + db, + metric, + window_size, + full_block_weight: PROPORTION_RESOLUTION, + }) + } + + /// Check if the SQL database was already created. Necessary to avoid races if + /// different threads open an estimator at the same time. + fn db_already_instantiated(tx: &SqlTransaction) -> Result { + table_exists(tx, "median_fee_estimator") + } + + fn instantiate_db(tx: &SqlTransaction) -> Result<(), SqliteError> { + if !Self::db_already_instantiated(tx)? { + tx.execute(CREATE_TABLE, rusqlite::NO_PARAMS)?; + } + + Ok(()) + } + + fn get_rate_estimates_from_sql( + conn: &Connection, + window_size: u32, + ) -> Result { + let sql = + "SELECT high, middle, low FROM median_fee_estimator ORDER BY measure_key DESC LIMIT ?"; + let mut stmt = conn.prepare(sql).expect("SQLite failure"); + + // shuttle high, low, middle estimates into these lists, and then sort and find median. + let mut highs = Vec::with_capacity(window_size as usize); + let mut mids = Vec::with_capacity(window_size as usize); + let mut lows = Vec::with_capacity(window_size as usize); + let results = stmt + .query_and_then::<_, SqliteError, _, _>(&[window_size], |row| { + let high: f64 = row.get("high")?; + let middle: f64 = row.get("middle")?; + let low: f64 = row.get("low")?; + Ok((low, middle, high)) + }) + .expect("SQLite failure"); + + for result in results { + let (low, middle, high) = result.expect("SQLite failure"); + highs.push(high); + mids.push(middle); + lows.push(low); + } + + if highs.is_empty() || mids.is_empty() || lows.is_empty() { + return Err(EstimatorError::NoEstimateAvailable); + } + + fn median(len: usize, l: Vec) -> f64 { + if len % 2 == 1 { + l[len / 2] + } else { + // note, measures_len / 2 - 1 >= 0, because + // len % 2 == 0 and emptiness is checked above + (l[len / 2] + l[len / 2 - 1]) / 2f64 + } + } + + // Sort our float arrays. For float values that do not compare easily, + // treat them as equals. + highs.sort_by(|a, b| a.partial_cmp(b).unwrap_or(Ordering::Equal)); + mids.sort_by(|a, b| a.partial_cmp(b).unwrap_or(Ordering::Equal)); + lows.sort_by(|a, b| a.partial_cmp(b).unwrap_or(Ordering::Equal)); + + Ok(FeeRateEstimate { + high: median(highs.len(), highs), + middle: median(mids.len(), mids), + low: median(lows.len(), lows), + }) + } + + fn update_estimate(&mut self, new_measure: FeeRateEstimate) { + let tx = tx_begin_immediate_sqlite(&mut self.db).expect("SQLite failure"); + let insert_sql = "INSERT INTO median_fee_estimator + (high, middle, low) VALUES (?, ?, ?)"; + let deletion_sql = "DELETE FROM median_fee_estimator + WHERE measure_key <= ( + SELECT MAX(measure_key) - ? + FROM median_fee_estimator )"; + tx.execute( + insert_sql, + rusqlite::params![new_measure.high, new_measure.middle, new_measure.low,], + ) + .expect("SQLite failure"); + tx.execute(deletion_sql, rusqlite::params![self.window_size]) + .expect("SQLite failure"); + + let estimate = Self::get_rate_estimates_from_sql(&tx, self.window_size); + tx.commit().expect("SQLite failure"); + if let Ok(next_estimate) = estimate { + debug!("Updating fee rate estimate for new block"; + "new_measure_high" => new_measure.high, + "new_measure_middle" => new_measure.middle, + "new_measure_low" => new_measure.low, + "new_estimate_high" => next_estimate.high, + "new_estimate_middle" => next_estimate.middle, + "new_estimate_low" => next_estimate.low); + } + } +} + +impl FeeEstimator for WeightedMedianFeeRateEstimator { + fn notify_block( + &mut self, + receipt: &StacksEpochReceipt, + block_limit: &ExecutionCost, + ) -> Result<(), EstimatorError> { + // Calculate sorted fee rate for each transaction in the block. + let mut working_fee_rates: Vec = receipt + .tx_receipts + .iter() + .filter_map(|tx_receipt| { + fee_rate_and_weight_from_receipt(&self.metric, &tx_receipt, block_limit) + }) + .collect(); + + // If necessary, add the "minimum" fee rate to fill the block. + maybe_add_minimum_fee_rate(&mut working_fee_rates, self.full_block_weight); + + // If fee rates non-empty, then compute an update. + if working_fee_rates.len() > 0 { + // Values must be sorted. + working_fee_rates.sort_by(|a, b| { + a.fee_rate + .partial_cmp(&b.fee_rate) + .unwrap_or(Ordering::Equal) + }); + + // Compute the estimate and update. + let block_estimate = fee_rate_estimate_from_sorted_weighted_fees(&working_fee_rates); + self.update_estimate(block_estimate); + } + + Ok(()) + } + + fn get_rate_estimates(&self) -> Result { + Self::get_rate_estimates_from_sql(&self.db, self.window_size) + } +} + +/// Computes a `FeeRateEstimate` based on `sorted_fee_rates` using a "weighted percentile" method +/// described in https://en.wikipedia.org/wiki/Percentile#Weighted_percentile +/// +/// The percentiles computed are [0.05, 0.5, 0.95]. +/// +/// `sorted_fee_rates` must be non-empty. +pub fn fee_rate_estimate_from_sorted_weighted_fees( + sorted_fee_rates: &[FeeRateAndWeight], +) -> FeeRateEstimate { + assert!(!sorted_fee_rates.is_empty()); + + let mut total_weight = 0f64; + for rate_and_weight in sorted_fee_rates { + total_weight += rate_and_weight.weight as f64; + } + + assert!(total_weight > 0f64); + + let mut cumulative_weight = 0f64; + let mut percentiles = Vec::new(); + for rate_and_weight in sorted_fee_rates { + cumulative_weight += rate_and_weight.weight as f64; + let percentile_n: f64 = + (cumulative_weight as f64 - rate_and_weight.weight as f64 / 2f64) / total_weight as f64; + percentiles.push(percentile_n); + } + assert_eq!(percentiles.len(), sorted_fee_rates.len()); + + let target_percentiles = vec![0.05, 0.5, 0.95]; + let mut fees_index = 0; // index into `sorted_fee_rates` + let mut values_at_target_percentiles = Vec::new(); + for target_percentile in target_percentiles { + while fees_index < percentiles.len() && percentiles[fees_index] < target_percentile { + fees_index += 1; + } + let v = if fees_index == 0 { + sorted_fee_rates[0].fee_rate + } else if fees_index == percentiles.len() { + sorted_fee_rates.last().unwrap().fee_rate + } else { + // Notation mimics https://en.wikipedia.org/wiki/Percentile#Weighted_percentile + let vk = sorted_fee_rates[fees_index - 1].fee_rate; + let vk1 = sorted_fee_rates[fees_index].fee_rate; + let pk = percentiles[fees_index - 1]; + let pk1 = percentiles[fees_index]; + vk + (target_percentile - pk) / (pk1 - pk) * (vk1 - vk) + }; + values_at_target_percentiles.push(v); + } + + FeeRateEstimate { + high: values_at_target_percentiles[2], + middle: values_at_target_percentiles[1], + low: values_at_target_percentiles[0], + } +} + +/// If the weights in `working_rates` do not add up to `full_block_weight`, add a new entry **in +/// place** that takes up the remaining space. +fn maybe_add_minimum_fee_rate(working_rates: &mut Vec, full_block_weight: u64) { + let mut total_weight = 0u64; + for rate_and_weight in working_rates.into_iter() { + total_weight = match total_weight.checked_add(rate_and_weight.weight) { + Some(result) => result, + None => return, + }; + } + + if total_weight < full_block_weight { + let weight_remaining = full_block_weight - total_weight; + working_rates.push(FeeRateAndWeight { + fee_rate: MINIMUM_TX_FEE_RATE, + weight: weight_remaining, + }) + } +} + +/// Depending on the type of the transaction, calculate fee rate and total cost. +/// +/// Returns None if: +/// 1) There is no fee rate for the tx. +/// 2) Cacluated fee rate is infinite. +fn fee_rate_and_weight_from_receipt( + metric: &dyn CostMetric, + tx_receipt: &StacksTransactionReceipt, + block_limit: &ExecutionCost, +) -> Option { + let (payload, fee, tx_size) = match tx_receipt.transaction { + TransactionOrigin::Stacks(ref tx) => Some((&tx.payload, tx.get_tx_fee(), tx.tx_len())), + TransactionOrigin::Burn(_) => None, + }?; + let scalar_cost = match payload { + TransactionPayload::TokenTransfer(_, _, _) => { + // TokenTransfers *only* contribute tx_len, and just have an empty ExecutionCost. + metric.from_len(tx_size) + } + TransactionPayload::Coinbase(_) => { + // Coinbase txs are "free", so they don't factor into the fee market. + return None; + } + TransactionPayload::PoisonMicroblock(_, _) + | TransactionPayload::ContractCall(_) + | TransactionPayload::SmartContract(_) => { + // These transaction payload types all "work" the same: they have associated ExecutionCosts + // and contibute to the block length limit with their tx_len + metric.from_cost_and_len(&tx_receipt.execution_cost, &block_limit, tx_size) + } + }; + let denominator = cmp::max(scalar_cost, 1) as f64; + let fee_rate = fee as f64 / denominator; + + if fee_rate.is_infinite() { + warn!("fee_rate is infinite for {:?}", tx_receipt); + None + } else { + let effective_fee_rate = if fee_rate < MINIMUM_TX_FEE_RATE { + MINIMUM_TX_FEE_RATE + } else { + fee_rate + }; + Some(FeeRateAndWeight { + fee_rate: effective_fee_rate, + weight: scalar_cost, + }) + } +} diff --git a/src/cost_estimates/fee_rate_fuzzer.rs b/src/cost_estimates/fee_rate_fuzzer.rs new file mode 100644 index 0000000000..2a54ad0d05 --- /dev/null +++ b/src/cost_estimates/fee_rate_fuzzer.rs @@ -0,0 +1,92 @@ +use vm::costs::ExecutionCost; + +use super::FeeRateEstimate; +use super::{EstimatorError, FeeEstimator}; +use chainstate::stacks::db::StacksEpochReceipt; +use rand::distributions::{Distribution, Uniform}; +use rand::rngs::StdRng; +use rand::thread_rng; +use rand::RngCore; +use rand::SeedableRng; + +/// The FeeRateFuzzer wraps an underlying FeeEstimator. It passes `notify_block` calls to the +/// underlying estimator. On `get_rate_estimates` calls, it adds a random fuzz to the result coming +/// back from the underlying estimator. The fuzz applied is as a random fraction of the base value. +/// +/// Note: We currently use "uniform" random noise instead of "normal" distributed noise to avoid +/// importing a new crate just for this. +pub struct FeeRateFuzzer { + /// We will apply a random "fuzz" on top of the estimates given by this. + underlying: UnderlyingEstimator, + /// Creator function for a new random generator. For prod, use `thread_rng`. For test, + /// pass in a contrived generator. + rng_creator: Box Box>, + /// The fuzzed rate will be `R * (1 + alpha)`, where `R` is the original rate, and `alpha` is a + /// random number in `[-uniform_fuzz_fraction, uniform_fuzz_fraction]`. + /// Note: Must be `0 <= uniform_fuzz_fraction < 1`. + uniform_fuzz_fraction: f64, +} + +impl FeeRateFuzzer { + /// Constructor for production. It uses `thread_rng()` as the random number generator, + /// to get truly pseudo-random numbers. + pub fn new( + underlying: UnderlyingEstimator, + uniform_fuzz_fraction: f64, + ) -> FeeRateFuzzer { + assert!(0.0 <= uniform_fuzz_fraction && uniform_fuzz_fraction < 1.0); + let rng_creator = Box::new(|| { + let r: Box = Box::new(thread_rng()); + r + }); + Self { + underlying, + rng_creator, + uniform_fuzz_fraction, + } + } + + /// Constructor meant for test. The user can pass in a contrived random number generator + /// factory function, so that the test is repeatable. + pub fn new_custom_creator( + underlying: UnderlyingEstimator, + rng_creator: Box Box>, + uniform_fuzz_fraction: f64, + ) -> FeeRateFuzzer { + assert!(0.0 <= uniform_fuzz_fraction && uniform_fuzz_fraction < 1.0); + Self { + underlying, + rng_creator, + uniform_fuzz_fraction, + } + } + + /// Add a uniform fuzz to input. Each element is multiplied by the same random factor. + fn fuzz_estimate(&self, input: FeeRateEstimate) -> FeeRateEstimate { + if self.uniform_fuzz_fraction > 0f64 { + let mut rng = (self.rng_creator)(); + let uniform = Uniform::new(-self.uniform_fuzz_fraction, self.uniform_fuzz_fraction); + let fuzz_scale = 1f64 + uniform.sample(&mut rng); + input * fuzz_scale + } else { + input + } + } +} + +impl FeeEstimator for FeeRateFuzzer { + /// Just passes the information straight to `underlying`. + fn notify_block( + &mut self, + receipt: &StacksEpochReceipt, + block_limit: &ExecutionCost, + ) -> Result<(), EstimatorError> { + self.underlying.notify_block(receipt, block_limit) + } + + /// Call underlying estimator and add some fuzz. + fn get_rate_estimates(&self) -> Result { + let underlying_estimate = self.underlying.get_rate_estimates()?; + Ok(self.fuzz_estimate(underlying_estimate)) + } +} diff --git a/src/cost_estimates/fee_scalar.rs b/src/cost_estimates/fee_scalar.rs index e0414aff03..3b46521cba 100644 --- a/src/cost_estimates/fee_scalar.rs +++ b/src/cost_estimates/fee_scalar.rs @@ -52,27 +52,18 @@ pub struct ScalarFeeRateEstimator { impl ScalarFeeRateEstimator { /// Open a fee rate estimator at the given db path. Creates if not existent. pub fn open(p: &Path, metric: M) -> Result { - let db = - sqlite_open(p, rusqlite::OpenFlags::SQLITE_OPEN_READ_WRITE, false).or_else(|e| { - if let SqliteError::SqliteFailure(ref internal, _) = e { - if let rusqlite::ErrorCode::CannotOpen = internal.code { - let mut db = sqlite_open( - p, - rusqlite::OpenFlags::SQLITE_OPEN_CREATE - | rusqlite::OpenFlags::SQLITE_OPEN_READ_WRITE, - false, - )?; - let tx = tx_begin_immediate_sqlite(&mut db)?; - Self::instantiate_db(&tx)?; - tx.commit()?; - Ok(db) - } else { - Err(e) - } - } else { - Err(e) - } - })?; + let mut db = sqlite_open( + p, + rusqlite::OpenFlags::SQLITE_OPEN_CREATE | rusqlite::OpenFlags::SQLITE_OPEN_READ_WRITE, + false, + )?; + + // check if the db needs to be instantiated regardless of whether or not + // it was newly created: the db itself may be shared with other fee estimators, + // which would not have created the necessary table for this estimator. + let tx = tx_begin_immediate_sqlite(&mut db)?; + Self::instantiate_db(&tx)?; + tx.commit()?; Ok(Self { db, diff --git a/src/cost_estimates/mod.rs b/src/cost_estimates/mod.rs index fd481de13c..51402a7203 100644 --- a/src/cost_estimates/mod.rs +++ b/src/cost_estimates/mod.rs @@ -13,6 +13,8 @@ use vm::costs::ExecutionCost; use burnchains::Txid; use chainstate::stacks::db::StacksEpochReceipt; +pub mod fee_medians; +pub mod fee_rate_fuzzer; pub mod fee_scalar; pub mod metrics; pub mod pessimistic; diff --git a/src/cost_estimates/tests/common.rs b/src/cost_estimates/tests/common.rs new file mode 100644 index 0000000000..1b529c8d9a --- /dev/null +++ b/src/cost_estimates/tests/common.rs @@ -0,0 +1,51 @@ +use chainstate::burn::ConsensusHash; +use chainstate::stacks::db::{StacksEpochReceipt, StacksHeaderInfo}; +use chainstate::stacks::events::StacksTransactionReceipt; +use types::chainstate::{BlockHeaderHash, BurnchainHeaderHash, StacksBlockHeader, StacksWorkScore}; +use types::proof::TrieHash; +use util::hash::{to_hex, Hash160, Sha512Trunc256Sum}; +use util::vrf::VRFProof; +use vm::costs::ExecutionCost; + +use crate::chainstate::stacks::{ + CoinbasePayload, StacksTransaction, TokenTransferMemo, TransactionAuth, + TransactionContractCall, TransactionPayload, TransactionSpendingCondition, TransactionVersion, +}; +use crate::core::StacksEpochId; + +/// Make a block receipt from `tx_receipts` with some dummy values filled for test. +#[cfg(test)] +pub fn make_block_receipt(tx_receipts: Vec) -> StacksEpochReceipt { + StacksEpochReceipt { + header: StacksHeaderInfo { + anchored_header: StacksBlockHeader { + version: 1, + total_work: StacksWorkScore { burn: 1, work: 1 }, + proof: VRFProof::empty(), + parent_block: BlockHeaderHash([0; 32]), + parent_microblock: BlockHeaderHash([0; 32]), + parent_microblock_sequence: 0, + tx_merkle_root: Sha512Trunc256Sum([0; 32]), + state_index_root: TrieHash([0; 32]), + microblock_pubkey_hash: Hash160([0; 20]), + }, + microblock_tail: None, + block_height: 1, + index_root: TrieHash([0; 32]), + consensus_hash: ConsensusHash([2; 20]), + burn_header_hash: BurnchainHeaderHash([1; 32]), + burn_header_height: 2, + burn_header_timestamp: 2, + anchored_block_size: 1, + }, + tx_receipts, + matured_rewards: vec![], + matured_rewards_info: None, + parent_microblocks_cost: ExecutionCost::zero(), + anchored_block_cost: ExecutionCost::zero(), + parent_burn_block_hash: BurnchainHeaderHash([0; 32]), + parent_burn_block_height: 1, + parent_burn_block_timestamp: 1, + evaluated_epoch: StacksEpochId::Epoch20, + } +} diff --git a/src/cost_estimates/tests/cost_estimators.rs b/src/cost_estimates/tests/cost_estimators.rs index 3bebce117e..c3cef67d01 100644 --- a/src/cost_estimates/tests/cost_estimators.rs +++ b/src/cost_estimates/tests/cost_estimators.rs @@ -29,6 +29,7 @@ use crate::types::chainstate::StacksAddress; use crate::vm::types::{PrincipalData, StandardPrincipalData}; use crate::vm::Value; use core::BLOCK_LIMIT_MAINNET_20; +use cost_estimates::tests::common::*; fn instantiate_test_db() -> PessimisticEstimator { let mut path = env::temp_dir(); @@ -73,41 +74,6 @@ fn test_empty_pessimistic_estimator() { ); } -fn make_block_receipt(tx_receipts: Vec) -> StacksEpochReceipt { - StacksEpochReceipt { - header: StacksHeaderInfo { - anchored_header: StacksBlockHeader { - version: 1, - total_work: StacksWorkScore { burn: 1, work: 1 }, - proof: VRFProof::empty(), - parent_block: BlockHeaderHash([0; 32]), - parent_microblock: BlockHeaderHash([0; 32]), - parent_microblock_sequence: 0, - tx_merkle_root: Sha512Trunc256Sum([0; 32]), - state_index_root: TrieHash([0; 32]), - microblock_pubkey_hash: Hash160([0; 20]), - }, - microblock_tail: None, - block_height: 1, - index_root: TrieHash([0; 32]), - consensus_hash: ConsensusHash([2; 20]), - burn_header_hash: BurnchainHeaderHash([1; 32]), - burn_header_height: 2, - burn_header_timestamp: 2, - anchored_block_size: 1, - }, - tx_receipts, - matured_rewards: vec![], - matured_rewards_info: None, - parent_microblocks_cost: ExecutionCost::zero(), - anchored_block_cost: ExecutionCost::zero(), - parent_burn_block_hash: BurnchainHeaderHash([0; 32]), - parent_burn_block_height: 1, - parent_burn_block_timestamp: 1, - evaluated_epoch: StacksEpochId::Epoch20, - } -} - fn make_dummy_coinbase_tx() -> StacksTransactionReceipt { StacksTransactionReceipt::from_coinbase(StacksTransaction::new( TransactionVersion::Mainnet, diff --git a/src/cost_estimates/tests/fee_medians.rs b/src/cost_estimates/tests/fee_medians.rs new file mode 100644 index 0000000000..cc67423aab --- /dev/null +++ b/src/cost_estimates/tests/fee_medians.rs @@ -0,0 +1,393 @@ +use std::{env, path::PathBuf}; +use time::Instant; + +use rand::seq::SliceRandom; +use rand::Rng; + +use cost_estimates::metrics::CostMetric; +use cost_estimates::{EstimatorError, FeeEstimator}; +use vm::costs::ExecutionCost; + +use chainstate::burn::ConsensusHash; +use chainstate::stacks::db::{StacksEpochReceipt, StacksHeaderInfo}; +use chainstate::stacks::events::StacksTransactionReceipt; +use types::chainstate::{BlockHeaderHash, BurnchainHeaderHash, StacksBlockHeader, StacksWorkScore}; +use types::proof::TrieHash; +use util::hash::{to_hex, Hash160, Sha512Trunc256Sum}; +use util::vrf::VRFProof; + +use crate::chainstate::stacks::{ + CoinbasePayload, StacksTransaction, TokenTransferMemo, TransactionAuth, + TransactionContractCall, TransactionPayload, TransactionSpendingCondition, TransactionVersion, +}; +use crate::core::StacksEpochId; +use crate::cost_estimates::fee_medians::WeightedMedianFeeRateEstimator; +use crate::cost_estimates::metrics::ProportionalDotProduct; +use crate::cost_estimates::FeeRateEstimate; +use crate::types::chainstate::StacksAddress; +use crate::vm::types::{PrincipalData, StandardPrincipalData}; +use crate::vm::Value; +use cost_estimates::fee_medians::fee_rate_estimate_from_sorted_weighted_fees; +use cost_estimates::fee_medians::FeeRateAndWeight; +use cost_estimates::tests::common::*; + +/// Returns true iff `b` is within `0.1%` of `a`. +fn is_close_f64(a: f64, b: f64) -> bool { + let error = (a - b).abs() / a.abs(); + error < 0.001 +} + +/// Returns `true` iff each value in `left` "close" to its counterpart in `right`. +fn is_close(left: FeeRateEstimate, right: FeeRateEstimate) -> bool { + let is_ok = is_close_f64(left.high, right.high) + && is_close_f64(left.middle, right.middle) + && is_close_f64(left.low, right.low); + if !is_ok { + warn!("ExecutionCost's are not close. {:?} vs {:?}", left, right); + } + is_ok +} + +fn instantiate_test_db(m: CM) -> WeightedMedianFeeRateEstimator { + let mut path = env::temp_dir(); + let random_bytes = rand::thread_rng().gen::<[u8; 32]>(); + path.push(&format!("fee_db_{}.sqlite", &to_hex(&random_bytes)[0..8])); + + let window_size = 5; + WeightedMedianFeeRateEstimator::open(&path, m, window_size) + .expect("Test failure: could not open fee rate DB") +} + +fn make_dummy_coinbase_tx() -> StacksTransaction { + StacksTransaction::new( + TransactionVersion::Mainnet, + TransactionAuth::Standard(TransactionSpendingCondition::new_initial_sighash()), + TransactionPayload::Coinbase(CoinbasePayload([0; 32])), + ) +} + +fn make_dummy_cc_tx(fee: u64, execution_cost: &ExecutionCost) -> StacksTransactionReceipt { + let mut tx = StacksTransaction::new( + TransactionVersion::Mainnet, + TransactionAuth::Standard(TransactionSpendingCondition::new_initial_sighash()), + TransactionPayload::ContractCall(TransactionContractCall { + address: StacksAddress::new(0, Hash160([0; 20])), + contract_name: "cc-dummy".into(), + function_name: "func-name".into(), + function_args: vec![], + }), + ); + tx.set_tx_fee(fee); + StacksTransactionReceipt::from_contract_call( + tx, + vec![], + Value::okay(Value::Bool(true)).unwrap(), + 0, + execution_cost.clone(), + ) +} + +const block_limit: ExecutionCost = ExecutionCost { + write_length: 100, + write_count: 100, + read_length: 100, + read_count: 100, + runtime: 100, +}; + +const tenth_operation_cost: ExecutionCost = ExecutionCost { + write_length: 0, + write_count: 0, + read_length: 0, + read_count: 0, + runtime: 10, +}; + +const half_operation_cost: ExecutionCost = ExecutionCost { + write_length: 0, + write_count: 0, + read_length: 0, + read_count: 0, + runtime: 50, +}; + +// The scalar cost of `make_dummy_cc_tx(_, &tenth_operation_cost)`. +const tenth_operation_cost_basis: u64 = 1164; + +// The scalar cost of `make_dummy_cc_tx(_, &half_operation_cost)`. +const half_operation_cost_basis: u64 = 5164; + +/// Tests that we have no estimate available until we `notify`. +#[test] +fn test_empty_fee_estimator() { + let metric = ProportionalDotProduct::new(10_000); + let estimator = instantiate_test_db(metric); + assert_eq!( + estimator + .get_rate_estimates() + .expect_err("Empty rate estimator should error."), + EstimatorError::NoEstimateAvailable + ); +} + +/// If we do not have any transactions in a block, we should fill the space +/// with a transaction with fee rate 1f. This means that, for a totally empty +/// block, the fee rate should be 1f. +#[test] +fn test_empty_block_returns_minimum() { + let metric = ProportionalDotProduct::new(10_000); + let mut estimator = instantiate_test_db(metric); + + let empty_block_receipt = make_block_receipt(vec![]); + estimator + .notify_block(&empty_block_receipt, &block_limit) + .expect("Should be able to process an empty block"); + + assert!(is_close( + estimator + .get_rate_estimates() + .expect("Should be able to create estimate now"), + FeeRateEstimate { + high: 1f64, + middle: 1f64, + low: 1f64 + } + )); +} + +/// A block that is only a very small minority filled should reflect the paid value, +/// but be dominated by the padded fee rate. +#[test] +fn test_one_block_partially_filled() { + let metric = ProportionalDotProduct::new(10_000); + let mut estimator = instantiate_test_db(metric); + + let single_tx_receipt = make_block_receipt(vec![ + StacksTransactionReceipt::from_coinbase(make_dummy_coinbase_tx()), + make_dummy_cc_tx(10 * tenth_operation_cost_basis, &tenth_operation_cost), + ]); + + estimator + .notify_block(&single_tx_receipt, &block_limit) + .expect("Should be able to process block receipt"); + + // The higher fee is 10, because of the operation paying 10f per cost. + // The middle fee should be near 1, because the block is mostly empty, and dominated by the + // minimum fee rate padding. + // The lower fee is 1 because of the minimum fee rate padding. + assert!(is_close( + estimator + .get_rate_estimates() + .expect("Should be able to create estimate now"), + FeeRateEstimate { + high: 10.0f64, + middle: 2.0475999999999996f64, + low: 1f64 + } + )); +} + +/// A block that is mostly filled should create an estimate dominated by the transactions paid, and +/// the padding should only affect `low`. +#[test] +fn test_one_block_mostly_filled() { + let metric = ProportionalDotProduct::new(10_000); + let mut estimator = instantiate_test_db(metric); + + let single_tx_receipt = make_block_receipt(vec![ + StacksTransactionReceipt::from_coinbase(make_dummy_coinbase_tx()), + make_dummy_cc_tx(10 * half_operation_cost_basis, &half_operation_cost), + make_dummy_cc_tx(10 * tenth_operation_cost_basis, &tenth_operation_cost), + make_dummy_cc_tx(10 * tenth_operation_cost_basis, &tenth_operation_cost), + make_dummy_cc_tx(10 * tenth_operation_cost_basis, &tenth_operation_cost), + ]); + + estimator + .notify_block(&single_tx_receipt, &block_limit) + .expect("Should be able to process block receipt"); + + // The higher fee is 10, because that's what we paid. + // The middle fee should be 10, because the block is mostly filled. + // The lower fee is 1 because of the minimum fee rate padding. + assert!(is_close( + estimator + .get_rate_estimates() + .expect("Should be able to create estimate now"), + FeeRateEstimate { + high: 10.0f64, + middle: 10.0f64, + low: 1f64 + } + )); +} + +/// Tests the effect of adding blocks over time. We add five blocks with an easy to calculate +/// median. +/// +/// We add 5 blocks with window size 5 so none should be forgotten. +#[test] +fn test_window_size_forget_nothing() { + let metric = ProportionalDotProduct::new(10_000); + let mut estimator = instantiate_test_db(metric); + + for i in 1..6 { + let single_tx_receipt = make_block_receipt(vec![ + StacksTransactionReceipt::from_coinbase(make_dummy_coinbase_tx()), + make_dummy_cc_tx(i * 10 * half_operation_cost_basis, &half_operation_cost), + make_dummy_cc_tx(i * 10 * half_operation_cost_basis, &half_operation_cost), + ]); + + estimator + .notify_block(&single_tx_receipt, &block_limit) + .expect("Should be able to process block receipt"); + } + + // The fee should be 30, because it's the median of [10, 20, .., 50]. + assert!(is_close( + estimator + .get_rate_estimates() + .expect("Should be able to create estimate now"), + FeeRateEstimate { + high: 30f64, + middle: 30f64, + low: 30f64 + } + )); +} + +/// Tests the effect of adding blocks over time. We add five blocks with an easy to calculate +/// median. +/// +/// We add 10 blocks with window size 5 so the first 5 should be forgotten. +#[test] +fn test_window_size_forget_something() { + let metric = ProportionalDotProduct::new(10_000); + let mut estimator = instantiate_test_db(metric); + + for i in 1..11 { + let single_tx_receipt = make_block_receipt(vec![ + StacksTransactionReceipt::from_coinbase(make_dummy_coinbase_tx()), + make_dummy_cc_tx(i * 10 * half_operation_cost_basis, &half_operation_cost), + make_dummy_cc_tx(i * 10 * half_operation_cost_basis, &half_operation_cost), + ]); + + estimator + .notify_block(&single_tx_receipt, &block_limit) + .expect("Should be able to process block receipt"); + } + + // The fee should be 80, because we forgot the first five estimates. + assert!(is_close( + estimator + .get_rate_estimates() + .expect("Should be able to create estimate now"), + FeeRateEstimate { + high: 80f64, + middle: 80f64, + low: 80f64 + } + )); +} + +#[test] +fn test_fee_rate_estimate_5_vs_95() { + assert_eq!( + fee_rate_estimate_from_sorted_weighted_fees(&vec![ + FeeRateAndWeight { + fee_rate: 1f64, + weight: 5u64, + }, + FeeRateAndWeight { + fee_rate: 10f64, + weight: 95u64, + }, + ]), + FeeRateEstimate { + high: 10.0f64, + middle: 9.549999999999999f64, + low: 1.45f64 + } + ); +} + +#[test] +fn test_fee_rate_estimate_50_vs_50() { + assert_eq!( + fee_rate_estimate_from_sorted_weighted_fees(&vec![ + FeeRateAndWeight { + fee_rate: 1f64, + weight: 50u64, + }, + FeeRateAndWeight { + fee_rate: 10f64, + weight: 50u64, + }, + ]), + FeeRateEstimate { + high: 10.0f64, + middle: 5.5f64, + low: 1.0f64 + } + ); +} + +#[test] +fn test_fee_rate_estimate_95_vs_5() { + assert_eq!( + fee_rate_estimate_from_sorted_weighted_fees(&vec![ + FeeRateAndWeight { + fee_rate: 1f64, + weight: 95u64, + }, + FeeRateAndWeight { + fee_rate: 10f64, + weight: 5u64, + }, + ]), + FeeRateEstimate { + high: 9.549999999999999f64, + middle: 1.4500000000000004f64, + low: 1.0f64 + } + ); +} + +#[test] +fn test_fee_rate_estimate_20() { + let mut pairs = vec![]; + for i in 1..21 { + pairs.push(FeeRateAndWeight { + fee_rate: 1f64 * i as f64, + weight: 1u64, + }) + } + + assert_eq!( + fee_rate_estimate_from_sorted_weighted_fees(&pairs), + FeeRateEstimate { + high: 19.5f64, + middle: 10.5f64, + low: 1.5f64 + } + ); +} + +#[test] +fn test_fee_rate_estimate_100() { + let mut pairs = vec![]; + for i in 1..101 { + pairs.push(FeeRateAndWeight { + fee_rate: 1f64 * i as f64, + weight: 1u64, + }) + } + + assert_eq!( + fee_rate_estimate_from_sorted_weighted_fees(&pairs), + FeeRateEstimate { + high: 95.5f64, + middle: 50.5f64, + low: 5.5f64 + } + ); +} diff --git a/src/cost_estimates/tests/fee_rate_fuzzer.rs b/src/cost_estimates/tests/fee_rate_fuzzer.rs new file mode 100644 index 0000000000..70f1fdbf30 --- /dev/null +++ b/src/cost_estimates/tests/fee_rate_fuzzer.rs @@ -0,0 +1,153 @@ +use cost_estimates::metrics::CostMetric; +use cost_estimates::{EstimatorError, FeeEstimator}; +use vm::costs::ExecutionCost; + +use chainstate::burn::ConsensusHash; +use chainstate::stacks::db::{StacksEpochReceipt, StacksHeaderInfo}; +use chainstate::stacks::events::StacksTransactionReceipt; +use types::chainstate::{BlockHeaderHash, BurnchainHeaderHash, StacksBlockHeader, StacksWorkScore}; +use types::proof::TrieHash; +use util::hash::{to_hex, Hash160, Sha512Trunc256Sum}; +use util::vrf::VRFProof; + +use crate::chainstate::stacks::{ + CoinbasePayload, StacksTransaction, TokenTransferMemo, TransactionAuth, + TransactionContractCall, TransactionPayload, TransactionSpendingCondition, TransactionVersion, +}; +use crate::core::StacksEpochId; +use crate::cost_estimates::FeeRateEstimate; +use cost_estimates::fee_rate_fuzzer::FeeRateFuzzer; +use rand::rngs::StdRng; +use rand::thread_rng; +use rand::RngCore; +use rand::SeedableRng; + +use cost_estimates::tests::common::make_block_receipt; + +struct ConstantFeeEstimator {} + +/// Returns a constant fee rate estimate. +impl FeeEstimator for ConstantFeeEstimator { + fn notify_block( + &mut self, + receipt: &StacksEpochReceipt, + block_limit: &ExecutionCost, + ) -> Result<(), EstimatorError> { + Ok(()) + } + + fn get_rate_estimates(&self) -> Result { + Ok(FeeRateEstimate { + high: 95f64, + middle: 50f64, + low: 5f64, + }) + } +} + +/// Test the fuzzer using a fixed random seed. +#[test] +fn test_fuzzing_seed1() { + let mock_estimator = ConstantFeeEstimator {}; + let rng_creator = Box::new(|| { + let seed = [0u8; 32]; + let rng: StdRng = SeedableRng::from_seed(seed); + let r: Box = Box::new(rng); + r + }); + let fuzzed_estimator = FeeRateFuzzer::new_custom_creator(mock_estimator, rng_creator, 0.1); + + assert_eq!( + fuzzed_estimator + .get_rate_estimates() + .expect("Estimate should exist."), + FeeRateEstimate { + high: 96.20545857700169f64, + middle: 50.63445188263247f64, + low: 5.0634451882632465f64 + } + ); +} + +/// Test the fuzzer using a fixed random seed. Uses a different seed than test_fuzzing_seed1. +#[test] +fn test_fuzzing_seed2() { + let mock_estimator = ConstantFeeEstimator {}; + let rng_creator = Box::new(|| { + let seed = [1u8; 32]; + let rng: StdRng = SeedableRng::from_seed(seed); + let r: Box = Box::new(rng); + r + }); + let fuzzed_estimator = FeeRateFuzzer::new_custom_creator(mock_estimator, rng_creator, 0.1); + + assert_eq!( + fuzzed_estimator + .get_rate_estimates() + .expect("Estimate should exist."), + FeeRateEstimate { + high: 100.08112623179122f64, + middle: 52.67427696410064f64, + low: 5.267427696410064f64 + } + ); +} + +struct CountingFeeEstimator { + counter: u64, +} + +/// This class "counts" the number of times `notify_block` has been called, and returns this as the +/// estimate. +impl FeeEstimator for CountingFeeEstimator { + fn notify_block( + &mut self, + receipt: &StacksEpochReceipt, + block_limit: &ExecutionCost, + ) -> Result<(), EstimatorError> { + self.counter += 1; + Ok(()) + } + + fn get_rate_estimates(&self) -> Result { + Ok(FeeRateEstimate { + high: self.counter as f64, + middle: self.counter as f64, + low: self.counter as f64, + }) + } +} + +/// Tests that the receipt is passed through in `notify_block`. +#[test] +fn test_notify_pass_through() { + let mock_estimator = CountingFeeEstimator { counter: 0 }; + let rng_creator = Box::new(|| { + let seed = [1u8; 32]; + let rng: StdRng = SeedableRng::from_seed(seed); + let r: Box = Box::new(rng); + r + }); + let mut fuzzed_estimator = FeeRateFuzzer::new_custom_creator(mock_estimator, rng_creator, 0.1); + + let receipt = make_block_receipt(vec![]); + fuzzed_estimator + .notify_block(&receipt, &ExecutionCost::max_value()) + .expect("notify_block should succeed here."); + fuzzed_estimator + .notify_block(&receipt, &ExecutionCost::max_value()) + .expect("notify_block should succeed here."); + + // We've called `notify_block` twice, so the values returned are 2f, with some noise from the + // fuzzer. + assert_eq!( + fuzzed_estimator + .get_rate_estimates() + .expect("Estimate should exist."), + FeeRateEstimate { + high: 2.1069710785640257f64, + middle: 2.1069710785640257f64, + low: 2.1069710785640257f64 + }, + ); +} diff --git a/src/cost_estimates/tests/fee_scalar.rs b/src/cost_estimates/tests/fee_scalar.rs index f440384c76..5ad4566586 100644 --- a/src/cost_estimates/tests/fee_scalar.rs +++ b/src/cost_estimates/tests/fee_scalar.rs @@ -27,6 +27,8 @@ use crate::types::chainstate::StacksAddress; use crate::vm::types::{PrincipalData, StandardPrincipalData}; use crate::vm::Value; +use cost_estimates::tests::common::make_block_receipt; + fn instantiate_test_db(m: CM) -> ScalarFeeRateEstimator { let mut path = env::temp_dir(); let random_bytes = rand::thread_rng().gen::<[u8; 32]>(); @@ -71,41 +73,6 @@ fn test_empty_fee_estimator() { ); } -fn make_block_receipt(tx_receipts: Vec) -> StacksEpochReceipt { - StacksEpochReceipt { - header: StacksHeaderInfo { - anchored_header: StacksBlockHeader { - version: 1, - total_work: StacksWorkScore { burn: 1, work: 1 }, - proof: VRFProof::empty(), - parent_block: BlockHeaderHash([0; 32]), - parent_microblock: BlockHeaderHash([0; 32]), - parent_microblock_sequence: 0, - tx_merkle_root: Sha512Trunc256Sum([0; 32]), - state_index_root: TrieHash([0; 32]), - microblock_pubkey_hash: Hash160([0; 20]), - }, - microblock_tail: None, - block_height: 1, - index_root: TrieHash([0; 32]), - consensus_hash: ConsensusHash([2; 20]), - burn_header_hash: BurnchainHeaderHash([1; 32]), - burn_header_height: 2, - burn_header_timestamp: 2, - anchored_block_size: 1, - }, - tx_receipts, - matured_rewards: vec![], - matured_rewards_info: None, - parent_microblocks_cost: ExecutionCost::zero(), - anchored_block_cost: ExecutionCost::zero(), - parent_burn_block_hash: BurnchainHeaderHash([0; 32]), - parent_burn_block_height: 1, - parent_burn_block_timestamp: 1, - evaluated_epoch: StacksEpochId::Epoch20, - } -} - fn make_dummy_coinbase_tx() -> StacksTransaction { StacksTransaction::new( TransactionVersion::Mainnet, diff --git a/src/cost_estimates/tests/mod.rs b/src/cost_estimates/tests/mod.rs index 28ade005c5..8b5ce592b1 100644 --- a/src/cost_estimates/tests/mod.rs +++ b/src/cost_estimates/tests/mod.rs @@ -1,6 +1,9 @@ use cost_estimates::FeeRateEstimate; +pub mod common; pub mod cost_estimators; +pub mod fee_medians; +pub mod fee_rate_fuzzer; pub mod fee_scalar; pub mod metrics; diff --git a/testnet/stacks-node/src/config.rs b/testnet/stacks-node/src/config.rs index 6a4bfa4b78..b8ef749ccb 100644 --- a/testnet/stacks-node/src/config.rs +++ b/testnet/stacks-node/src/config.rs @@ -14,6 +14,8 @@ use stacks::core::StacksEpoch; use stacks::core::{ CHAIN_ID_MAINNET, CHAIN_ID_TESTNET, PEER_VERSION_MAINNET, PEER_VERSION_TESTNET, }; +use stacks::cost_estimates::fee_medians::WeightedMedianFeeRateEstimator; +use stacks::cost_estimates::fee_rate_fuzzer::FeeRateFuzzer; use stacks::cost_estimates::fee_scalar::ScalarFeeRateEstimator; use stacks::cost_estimates::metrics::CostMetric; use stacks::cost_estimates::metrics::ProportionalDotProduct; @@ -1040,6 +1042,7 @@ pub enum CostEstimatorName { #[derive(Clone, Debug)] pub enum FeeEstimatorName { ScalarFeeRate, + FuzzedWeightedMedianFeeRate, } #[derive(Clone, Debug)] @@ -1082,6 +1085,8 @@ impl FeeEstimatorName { fn panic_parse(s: String) -> FeeEstimatorName { if &s.to_lowercase() == "scalar_fee_rate" { FeeEstimatorName::ScalarFeeRate + } else if &s.to_lowercase() == "fuzzed_weighted_median_fee_rate" { + FeeEstimatorName::FuzzedWeightedMedianFeeRate } else { panic!( "Bad fee estimator name supplied in configuration file: {}", @@ -1107,6 +1112,12 @@ pub struct FeeEstimationConfig { pub fee_estimator: Option, pub cost_metric: Option, pub log_error: bool, + /// If using FeeRateFuzzer, the amount of random noise, as a percentage of the base value (in + /// [0, 1]) to add for fuzz. See comments on FeeRateFuzzer. + pub fee_rate_fuzzer_fraction: f64, + /// If using WeightedMedianFeeRateEstimator, the window size to use. See comments on + /// WeightedMedianFeeRateEstimator. + pub fee_rate_window_size: u64, } impl Default for FeeEstimationConfig { @@ -1116,6 +1127,8 @@ impl Default for FeeEstimationConfig { fee_estimator: Some(FeeEstimatorName::default()), cost_metric: Some(CostMetricName::default()), log_error: false, + fee_rate_fuzzer_fraction: 0.1f64, + fee_rate_window_size: 5u64, } } } @@ -1128,6 +1141,8 @@ impl From for FeeEstimationConfig { fee_estimator: None, cost_metric: None, log_error: false, + fee_rate_fuzzer_fraction: 0f64, + fee_rate_window_size: 0u64, }; } let cost_estimator = f @@ -1148,6 +1163,8 @@ impl From for FeeEstimationConfig { fee_estimator: Some(fee_estimator), cost_metric: Some(cost_metric), log_error, + fee_rate_fuzzer_fraction: f.fee_rate_fuzzer_fraction.unwrap_or(0.1f64), + fee_rate_window_size: f.fee_rate_window_size.unwrap_or(5u64), } } } @@ -1178,10 +1195,12 @@ impl Config { pub fn make_fee_estimator(&self) -> Option> { let metric = self.make_cost_metric()?; let fee_estimator: Box = match self.estimation.fee_estimator.as_ref()? { - FeeEstimatorName::ScalarFeeRate => Box::new( - self.estimation - .make_scalar_fee_estimator(self.get_chainstate_path(), metric), - ), + FeeEstimatorName::ScalarFeeRate => self + .estimation + .make_scalar_fee_estimator(self.get_chainstate_path(), metric), + FeeEstimatorName::FuzzedWeightedMedianFeeRate => self + .estimation + .make_fuzzed_weighted_median_fee_estimator(self.get_chainstate_path(), metric), }; Some(fee_estimator) @@ -1202,19 +1221,47 @@ impl FeeEstimationConfig { } } - pub fn make_scalar_fee_estimator( + pub fn make_scalar_fee_estimator( &self, mut chainstate_path: PathBuf, metric: CM, - ) -> ScalarFeeRateEstimator { + ) -> Box { if let Some(FeeEstimatorName::ScalarFeeRate) = self.fee_estimator.as_ref() { chainstate_path.push("fee_estimator_scalar_rate.sqlite"); - ScalarFeeRateEstimator::open(&chainstate_path, metric) - .expect("Error opening fee estimator") + Box::new( + ScalarFeeRateEstimator::open(&chainstate_path, metric) + .expect("Error opening fee estimator"), + ) } else { panic!("BUG: Expected to configure a scalar fee estimator"); } } + + // Creates a fuzzed WeightedMedianFeeRateEstimator with window_size 5. The fuzz + // is uniform with bounds [+/- 0.5]. + pub fn make_fuzzed_weighted_median_fee_estimator( + &self, + mut chainstate_path: PathBuf, + metric: CM, + ) -> Box { + if let Some(FeeEstimatorName::FuzzedWeightedMedianFeeRate) = self.fee_estimator.as_ref() { + chainstate_path.push("fee_fuzzed_weighted_median.sqlite"); + let underlying_estimator = WeightedMedianFeeRateEstimator::open( + &chainstate_path, + metric, + self.fee_rate_window_size + .try_into() + .expect("Configured fee rate window size out of bounds."), + ) + .expect("Error opening fee estimator"); + Box::new(FeeRateFuzzer::new( + underlying_estimator, + self.fee_rate_fuzzer_fraction, + )) + } else { + panic!("BUG: Expected to configure a weighted median fee estimator"); + } + } } impl NodeConfig { @@ -1427,6 +1474,8 @@ pub struct FeeEstimationConfigFile { pub cost_metric: Option, pub disabled: Option, pub log_error: Option, + pub fee_rate_fuzzer_fraction: Option, + pub fee_rate_window_size: Option, } impl Default for FeeEstimationConfigFile { @@ -1437,6 +1486,8 @@ impl Default for FeeEstimationConfigFile { cost_metric: None, disabled: None, log_error: None, + fee_rate_fuzzer_fraction: None, + fee_rate_window_size: None, } } } diff --git a/testnet/stacks-node/src/tests/neon_integrations.rs b/testnet/stacks-node/src/tests/neon_integrations.rs index d49ad71235..6dd8d09718 100644 --- a/testnet/stacks-node/src/tests/neon_integrations.rs +++ b/testnet/stacks-node/src/tests/neon_integrations.rs @@ -15,21 +15,22 @@ use stacks::burnchains::bitcoin::address::{BitcoinAddress, BitcoinAddressType}; use stacks::burnchains::bitcoin::BitcoinNetworkType; use stacks::burnchains::Txid; use stacks::chainstate::burn::operations::{BlockstackOperationType, PreStxOp, TransferStxOp}; +use stacks::chainstate::stacks::TokenTransferMemo; use stacks::clarity::vm_execute as execute; use stacks::codec::StacksMessageCodec; use stacks::core; use stacks::core::CHAIN_ID_TESTNET; use stacks::net::atlas::{AtlasConfig, AtlasDB, MAX_ATTACHMENT_INV_PAGES_PER_REQUEST}; use stacks::net::{ - AccountEntryResponse, ContractSrcResponse, GetAttachmentResponse, GetAttachmentsInvResponse, - PostTransactionRequestBody, RPCPeerInfoData, + AccountEntryResponse, ContractSrcResponse, FeeRateEstimateRequestBody, GetAttachmentResponse, + GetAttachmentsInvResponse, PostTransactionRequestBody, RPCPeerInfoData, }; use stacks::types::chainstate::{ BlockHeaderHash, BurnchainHeaderHash, StacksAddress, StacksBlockHeader, StacksBlockId, StacksMicroblockHeader, }; use stacks::util::hash::Hash160; -use stacks::util::hash::{bytes_to_hex, hex_bytes}; +use stacks::util::hash::{bytes_to_hex, hex_bytes, to_hex}; use stacks::util::secp256k1::Secp256k1PublicKey; use stacks::util::{get_epoch_time_ms, get_epoch_time_secs, sleep_ms}; use stacks::vm::database::ClarityDeserializable; @@ -46,7 +47,7 @@ use stacks::{ use stacks::{ chainstate::stacks::{ db::StacksChainState, StacksBlock, StacksPrivateKey, StacksPublicKey, StacksTransaction, - TransactionPayload, + TransactionContractCall, TransactionPayload, }, net::RPCPoxInfoData, util::db::query_row_columns, @@ -75,6 +76,12 @@ use stacks::chainstate::stacks::miner::{ TransactionErrorEvent, TransactionEvent, TransactionSkippedEvent, TransactionSuccessEvent, }; +use crate::config::FeeEstimatorName; +use stacks::net::RPCFeeEstimateResponse; +use stacks::vm::ClarityName; +use stacks::vm::ContractName; +use std::convert::TryFrom; + pub fn neon_integration_test_conf() -> (Config, StacksAddress) { let mut conf = super::new_test_conf(); @@ -344,6 +351,37 @@ pub fn next_block_and_wait( true } +/// This function will call `next_block_and_wait` until the burnchain height underlying `BitcoinRegtestController` +/// reaches *exactly* `target_height`. +/// +/// Returns `false` if `next_block_and_wait` times out. +fn run_until_burnchain_height( + btc_regtest_controller: &mut BitcoinRegtestController, + blocks_processed: &Arc, + target_height: u64, + conf: &Config, +) -> bool { + let tip_info = get_chain_info(&conf); + let mut current_height = tip_info.burn_block_height; + + while current_height < target_height { + eprintln!( + "run_until_burnchain_height: Issuing block at {}, current_height burnchain height is ({})", + get_epoch_time_secs(), + current_height + ); + let next_result = next_block_and_wait(btc_regtest_controller, &blocks_processed); + if !next_result { + return false; + } + let tip_info = get_chain_info(&conf); + current_height = tip_info.burn_block_height; + } + + assert_eq!(current_height, target_height); + true +} + pub fn wait_for_runloop(blocks_processed: &Arc) { let start = Instant::now(); while blocks_processed.load(Ordering::SeqCst) == 0 { @@ -465,6 +503,12 @@ fn find_microblock_privkey( return None; } +/// Returns true iff `b` is within `0.1%` of `a`. +fn is_close_f64(a: f64, b: f64) -> bool { + let error = (a - b).abs() / a.abs(); + error < 0.001 +} + #[test] #[ignore] fn bitcoind_integration_test() { @@ -3999,14 +4043,14 @@ fn near_full_block_integration_test() { ); let spender_sk = StacksPrivateKey::new(); - let addr = to_addr(&spender_sk); + let spender_addr = to_addr(&spender_sk); let tx = make_contract_publish(&spender_sk, 0, 59070, "max", &max_contract_src); let (mut conf, miner_account) = neon_integration_test_conf(); conf.initial_balances.push(InitialBalance { - address: addr.clone().into(), + address: spender_addr.clone().into(), amount: 10000000, }); @@ -4054,7 +4098,7 @@ fn near_full_block_integration_test() { assert_eq!(account.nonce, 1); assert_eq!(account.balance, 0); - let account = get_account(&http_origin, &addr); + let account = get_account(&http_origin, &spender_addr); assert_eq!(account.nonce, 0); assert_eq!(account.balance, 10000000); @@ -4066,7 +4110,7 @@ fn near_full_block_integration_test() { next_block_and_wait(&mut btc_regtest_controller, &blocks_processed); - let res = get_account(&http_origin, &addr); + let res = get_account(&http_origin, &spender_addr); assert_eq!(res.nonce, 1); test_observer::clear(); @@ -4350,7 +4394,7 @@ fn pox_integration_test() { // let's stack with spender 2 and spender 3... - // now let's have sender_2 and sender_3 stack to pox addr 2 in + // now let's have sender_2 and sender_3 stack to pox spender_addr 2 in // two different txs, and make sure that they sum together in the reward set. let tx = make_contract_call( @@ -6102,6 +6146,189 @@ fn atlas_stress_integration_test() { test_observer::clear(); } +/// Run a fixed contract 20 times. Linearly increase the amount paid each time. The cost of the +/// contract should stay the same, and the fee rate paid should monotonically grow. The value +/// should grow faster for lower values of `window_size`, because a bigger window slows down the +/// growth. +fn fuzzed_median_fee_rate_estimation_test(window_size: u64, expected_final_value: f64) { + if env::var("BITCOIND_TEST") != Ok("1".into()) { + return; + } + + let max_contract_src = r#" +;; define counter variable +(define-data-var counter int 0) + +;; increment method +(define-public (increment) + (begin + (var-set counter (+ (var-get counter) 1)) + (ok (var-get counter)))) + + (define-public (increment-many) + (begin + (unwrap! (increment) (err u1)) + (unwrap! (increment) (err u1)) + (unwrap! (increment) (err u1)) + (unwrap! (increment) (err u1)) + (ok (var-get counter)))) + "# + .to_string(); + + let spender_sk = StacksPrivateKey::new(); + let spender_addr = to_addr(&spender_sk); + + let (mut conf, _) = neon_integration_test_conf(); + + // Set this estimator as special. + conf.estimation.fee_estimator = Some(FeeEstimatorName::FuzzedWeightedMedianFeeRate); + // Use randomness of 0 to keep test constant. Randomness is tested in unit tests. + conf.estimation.fee_rate_fuzzer_fraction = 0f64; + conf.estimation.fee_rate_window_size = window_size; + + conf.initial_balances.push(InitialBalance { + address: spender_addr.clone().into(), + amount: 10000000000, + }); + test_observer::spawn(); + conf.events_observers.push(EventObserverConfig { + endpoint: format!("localhost:{}", test_observer::EVENT_OBSERVER_PORT), + events_keys: vec![EventKeyType::AnyEvent], + }); + + let mut btcd_controller = BitcoinCoreController::new(conf.clone()); + btcd_controller + .start_bitcoind() + .map_err(|_e| ()) + .expect("Failed starting bitcoind"); + + let mut btc_regtest_controller = BitcoinRegtestController::new(conf.clone(), None); + let http_origin = format!("http://{}", &conf.node.rpc_bind); + + btc_regtest_controller.bootstrap_chain(200); + + eprintln!("Chain bootstrapped..."); + + let mut run_loop = neon::RunLoop::new(conf.clone()); + let blocks_processed = run_loop.get_blocks_processed_arc(); + + let channel = run_loop.get_coordinator_channel().unwrap(); + + thread::spawn(move || run_loop.start(None, 0)); + + wait_for_runloop(&blocks_processed); + run_until_burnchain_height(&mut btc_regtest_controller, &blocks_processed, 210, &conf); + + submit_tx( + &http_origin, + &make_contract_publish( + &spender_sk, + 0, + 110000, + "increment-contract", + &max_contract_src, + ), + ); + run_until_burnchain_height(&mut btc_regtest_controller, &blocks_processed, 212, &conf); + + // Loop 20 times. Each time, execute the same transaction, but increase the amount *paid*. + // This will exercise the window size. + let mut response_estimated_costs = vec![]; + let mut response_top_fee_rates = vec![]; + for i in 1..21 { + submit_tx( + &http_origin, + &make_contract_call( + &spender_sk, + i, // nonce + i * 100000, // payment + &spender_addr.into(), + "increment-contract", + "increment-many", + &[], + ), + ); + run_until_burnchain_height( + &mut btc_regtest_controller, + &blocks_processed, + 212 + 2 * i, + &conf, + ); + + { + // Read from the fee estimation endpoin. + let path = format!("{}/v2/fees/transaction", &http_origin); + + let tx_payload = TransactionPayload::ContractCall(TransactionContractCall { + address: spender_addr.clone().into(), + contract_name: ContractName::try_from("increment-contract").unwrap(), + function_name: ClarityName::try_from("increment-many").unwrap(), + function_args: vec![], + }); + + let payload_data = tx_payload.serialize_to_vec(); + let payload_hex = format!("0x{}", to_hex(&payload_data)); + + let body = json!({ "transaction_payload": payload_hex.clone() }); + + let client = reqwest::blocking::Client::new(); + let fee_rate_result = client + .post(&path) + .json(&body) + .send() + .expect("Should be able to post") + .json::() + .expect("Failed to parse result into JSON"); + + response_estimated_costs.push(fee_rate_result.estimated_cost_scalar); + response_top_fee_rates.push(fee_rate_result.estimations.last().unwrap().fee_rate); + } + } + + // Wait two extra blocks to be sure. + next_block_and_wait(&mut btc_regtest_controller, &blocks_processed); + next_block_and_wait(&mut btc_regtest_controller, &blocks_processed); + + assert_eq!(response_estimated_costs.len(), response_top_fee_rates.len()); + + // Check that: + // 1) The cost is always the same. + // 2) Fee rate grows monotonically. + for i in 1..response_estimated_costs.len() { + let curr_cost = response_estimated_costs[i]; + let last_cost = response_estimated_costs[i - 1]; + assert_eq!(curr_cost, last_cost); + + let curr_rate = response_top_fee_rates[i] as f64; + let last_rate = response_top_fee_rates[i - 1] as f64; + assert!(curr_rate >= last_rate); + } + + // Check the final value is near input parameter. + assert!(is_close_f64( + *response_top_fee_rates.last().unwrap(), + expected_final_value + )); + + channel.stop_chains_coordinator(); +} + +/// Test the FuzzedWeightedMedianFeeRate with window size 5 and randomness 0. We increase the +/// amount paid linearly each time. This estimate should grow *faster* than with window size 10. +#[test] +#[ignore] +fn fuzzed_median_fee_rate_estimation_test_window5() { + fuzzed_median_fee_rate_estimation_test(5, 202680.0992) +} + +/// Test the FuzzedWeightedMedianFeeRate with window size 10 and randomness 0. We increase the +/// amount paid linearly each time. This estimate should grow *slower* than with window size 5. +#[test] +#[ignore] +fn fuzzed_median_fee_rate_estimation_test_window10() { + fuzzed_median_fee_rate_estimation_test(10, 90080.5496) +} + #[test] #[ignore] fn use_latest_tip_integration_test() {