diff --git a/Makefile.am b/Makefile.am index d4bffa1b..8aba8035 100644 --- a/Makefile.am +++ b/Makefile.am @@ -39,6 +39,7 @@ src_libbitcoin_node_la_SOURCES = \ src/block_memory.cpp \ src/configuration.cpp \ src/error.cpp \ + src/estimator.cpp \ src/full_node.cpp \ src/settings.cpp \ src/channels/channel_peer.cpp \ @@ -46,6 +47,7 @@ src_libbitcoin_node_la_SOURCES = \ src/chasers/chaser_block.cpp \ src/chasers/chaser_check.cpp \ src/chasers/chaser_confirm.cpp \ + src/chasers/chaser_estimate.cpp \ src/chasers/chaser_header.cpp \ src/chasers/chaser_snapshot.cpp \ src/chasers/chaser_storage.cpp \ @@ -89,6 +91,7 @@ test_libbitcoin_node_test_SOURCES = \ test/channel_peer.cpp \ test/configuration.cpp \ test/error.cpp \ + test/estimator.cpp \ test/full_node.cpp \ test/main.cpp \ test/settings.cpp \ @@ -98,6 +101,7 @@ test_libbitcoin_node_test_SOURCES = \ test/chasers/chaser_block.cpp \ test/chasers/chaser_check.cpp \ test/chasers/chaser_confirm.cpp \ + test/chasers/chaser_estimate.cpp \ test/chasers/chaser_header.cpp \ test/chasers/chaser_template.cpp \ test/chasers/chaser_transaction.cpp \ @@ -121,6 +125,7 @@ include_bitcoin_node_HEADERS = \ include/bitcoin/node/configuration.hpp \ include/bitcoin/node/define.hpp \ include/bitcoin/node/error.hpp \ + include/bitcoin/node/estimator.hpp \ include/bitcoin/node/events.hpp \ include/bitcoin/node/full_node.hpp \ include/bitcoin/node/settings.hpp \ @@ -138,6 +143,7 @@ include_bitcoin_node_chasers_HEADERS = \ include/bitcoin/node/chasers/chaser_block.hpp \ include/bitcoin/node/chasers/chaser_check.hpp \ include/bitcoin/node/chasers/chaser_confirm.hpp \ + include/bitcoin/node/chasers/chaser_estimate.hpp \ include/bitcoin/node/chasers/chaser_header.hpp \ include/bitcoin/node/chasers/chaser_organize.hpp \ include/bitcoin/node/chasers/chaser_snapshot.hpp \ diff --git a/builds/msvc/vs2022/libbitcoin-node-test/libbitcoin-node-test.vcxproj b/builds/msvc/vs2022/libbitcoin-node-test/libbitcoin-node-test.vcxproj index 9e00a736..1442f090 100644 --- a/builds/msvc/vs2022/libbitcoin-node-test/libbitcoin-node-test.vcxproj +++ b/builds/msvc/vs2022/libbitcoin-node-test/libbitcoin-node-test.vcxproj @@ -125,12 +125,14 @@ + + diff --git a/builds/msvc/vs2022/libbitcoin-node-test/libbitcoin-node-test.vcxproj.filters b/builds/msvc/vs2022/libbitcoin-node-test/libbitcoin-node-test.vcxproj.filters index f792e48a..3e9441ba 100644 --- a/builds/msvc/vs2022/libbitcoin-node-test/libbitcoin-node-test.vcxproj.filters +++ b/builds/msvc/vs2022/libbitcoin-node-test/libbitcoin-node-test.vcxproj.filters @@ -42,6 +42,9 @@ src\chasers + + src\chasers + src\chasers @@ -60,6 +63,9 @@ src + + src + src diff --git a/builds/msvc/vs2022/libbitcoin-node/libbitcoin-node.vcxproj b/builds/msvc/vs2022/libbitcoin-node/libbitcoin-node.vcxproj index 6d595fe3..98d88eed 100644 --- a/builds/msvc/vs2022/libbitcoin-node/libbitcoin-node.vcxproj +++ b/builds/msvc/vs2022/libbitcoin-node/libbitcoin-node.vcxproj @@ -128,6 +128,7 @@ + @@ -136,6 +137,7 @@ + @@ -172,6 +174,7 @@ + @@ -183,6 +186,7 @@ + diff --git a/builds/msvc/vs2022/libbitcoin-node/libbitcoin-node.vcxproj.filters b/builds/msvc/vs2022/libbitcoin-node/libbitcoin-node.vcxproj.filters index 6b4100d0..5b8a5dde 100644 --- a/builds/msvc/vs2022/libbitcoin-node/libbitcoin-node.vcxproj.filters +++ b/builds/msvc/vs2022/libbitcoin-node/libbitcoin-node.vcxproj.filters @@ -84,6 +84,9 @@ src\chasers + + src\chasers + src\chasers @@ -108,6 +111,9 @@ src + + src + src @@ -212,6 +218,9 @@ include\bitcoin\node\chasers + + include\bitcoin\node\chasers + include\bitcoin\node\chasers @@ -245,6 +254,9 @@ include\bitcoin\node + + include\bitcoin\node + include\bitcoin\node diff --git a/builds/msvc/vs2026/libbitcoin-node-test/libbitcoin-node-test.vcxproj b/builds/msvc/vs2026/libbitcoin-node-test/libbitcoin-node-test.vcxproj index 961f605d..584293ee 100644 --- a/builds/msvc/vs2026/libbitcoin-node-test/libbitcoin-node-test.vcxproj +++ b/builds/msvc/vs2026/libbitcoin-node-test/libbitcoin-node-test.vcxproj @@ -125,12 +125,14 @@ + + diff --git a/builds/msvc/vs2026/libbitcoin-node-test/libbitcoin-node-test.vcxproj.filters b/builds/msvc/vs2026/libbitcoin-node-test/libbitcoin-node-test.vcxproj.filters index f792e48a..3e9441ba 100644 --- a/builds/msvc/vs2026/libbitcoin-node-test/libbitcoin-node-test.vcxproj.filters +++ b/builds/msvc/vs2026/libbitcoin-node-test/libbitcoin-node-test.vcxproj.filters @@ -42,6 +42,9 @@ src\chasers + + src\chasers + src\chasers @@ -60,6 +63,9 @@ src + + src + src diff --git a/builds/msvc/vs2026/libbitcoin-node/libbitcoin-node.vcxproj b/builds/msvc/vs2026/libbitcoin-node/libbitcoin-node.vcxproj index 277bb8e9..d6bb716e 100644 --- a/builds/msvc/vs2026/libbitcoin-node/libbitcoin-node.vcxproj +++ b/builds/msvc/vs2026/libbitcoin-node/libbitcoin-node.vcxproj @@ -128,6 +128,7 @@ + @@ -136,6 +137,7 @@ + @@ -172,6 +174,7 @@ + @@ -183,6 +186,7 @@ + diff --git a/builds/msvc/vs2026/libbitcoin-node/libbitcoin-node.vcxproj.filters b/builds/msvc/vs2026/libbitcoin-node/libbitcoin-node.vcxproj.filters index 6b4100d0..5b8a5dde 100644 --- a/builds/msvc/vs2026/libbitcoin-node/libbitcoin-node.vcxproj.filters +++ b/builds/msvc/vs2026/libbitcoin-node/libbitcoin-node.vcxproj.filters @@ -84,6 +84,9 @@ src\chasers + + src\chasers + src\chasers @@ -108,6 +111,9 @@ src + + src + src @@ -212,6 +218,9 @@ include\bitcoin\node\chasers + + include\bitcoin\node\chasers + include\bitcoin\node\chasers @@ -245,6 +254,9 @@ include\bitcoin\node + + include\bitcoin\node + include\bitcoin\node diff --git a/include/bitcoin/node.hpp b/include/bitcoin/node.hpp index cef48d1b..2befa366 100644 --- a/include/bitcoin/node.hpp +++ b/include/bitcoin/node.hpp @@ -22,6 +22,7 @@ #include #include #include +#include #include #include #include @@ -33,6 +34,7 @@ #include #include #include +#include #include #include #include diff --git a/include/bitcoin/node/chasers/chaser_estimate.hpp b/include/bitcoin/node/chasers/chaser_estimate.hpp new file mode 100644 index 00000000..52fb6a83 --- /dev/null +++ b/include/bitcoin/node/chasers/chaser_estimate.hpp @@ -0,0 +1,52 @@ +/** + * Copyright (c) 2011-2026 libbitcoin developers (see AUTHORS) + * + * This file is part of libbitcoin. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +#ifndef LIBBITCOIN_NODE_CHASERS_CHASER_ESTIMATE_HPP +#define LIBBITCOIN_NODE_CHASERS_CHASER_ESTIMATE_HPP + +#include +#include + +namespace libbitcoin { +namespace node { + +class full_node; + +/// Maintain a running fee estimate cache. +class BCN_API chaser_estimate + : public chaser +{ +public: + DELETE_COPY_MOVE_DESTRUCT(chaser_estimate); + + chaser_estimate(full_node& node) NOEXCEPT; + + code start() NOEXCEPT override; + +protected: + virtual bool handle_chase(const code& ec, chase event_, + event_value value) NOEXCEPT; + + virtual void do_organized(header_t value) NOEXCEPT; + virtual void do_reorganized(header_t value) NOEXCEPT; +}; + +} // namespace node +} // namespace libbitcoin + +#endif diff --git a/include/bitcoin/node/chasers/chasers.hpp b/include/bitcoin/node/chasers/chasers.hpp index a3187493..4ecc20d0 100644 --- a/include/bitcoin/node/chasers/chasers.hpp +++ b/include/bitcoin/node/chasers/chasers.hpp @@ -23,6 +23,7 @@ #include #include #include +#include #include #include #include diff --git a/include/bitcoin/node/define.hpp b/include/bitcoin/node/define.hpp index ac433205..59229b45 100644 --- a/include/bitcoin/node/define.hpp +++ b/include/bitcoin/node/define.hpp @@ -116,6 +116,7 @@ using type_id = network::messages::peer::inventory_item::type_id; // Other directory common includes are not internally chained. // Each header includes only its required common headers. +// estimator : define // settings : define // configuration : define settings // parser : define configuration diff --git a/include/bitcoin/node/estimator.hpp b/include/bitcoin/node/estimator.hpp new file mode 100644 index 00000000..c1d8a453 --- /dev/null +++ b/include/bitcoin/node/estimator.hpp @@ -0,0 +1,166 @@ +/** + * Copyright (c) 2011-2026 libbitcoin developers (see AUTHORS) + * + * This file is part of libbitcoin. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +#ifndef LIBBITCOIN_NODE_ESTIMATOR_HPP +#define LIBBITCOIN_NODE_ESTIMATOR_HPP + +#include +#include + +namespace libbitcoin { +namespace node { + +/// Fee estimator with contained accumulator. +/// Accumulator is typically too large for stack creation. +/// Thread safe, blocking calls to estimate() during updates. +/// Initialize on chain current coalesce after snapshot/prune. +/// If chain falls > 1008/2 (?) blocks behind, reset and wait for coalesce. +/// When validating still use query-based fee rate population (compact blocks). +class BCN_API estimator +{ +public: + typedef std::shared_ptr ptr; + static constexpr size_t maximum_horizon = 1008; + + DELETE_COPY_MOVE_DESTRUCT(estimator); + + /// Estimation modes. + enum class mode + { + basic, + geometric, + economical, + conservative + }; + + /// Construct (use heap allocation). + estimator() NOEXCEPT {}; + + /// Fee estimation in satoshis / transaction virtual size. + /// Pass zero to target next block for confirmation, range:0..1007. + uint64_t estimate(size_t target, mode mode) const NOEXCEPT; + + /// Populate accumulator with count blocks up to the top confirmed block. + bool initialize(std::atomic_bool& cancel, const query& query, + size_t count=maximum_horizon) NOEXCEPT; + + /// Update accumulator. + bool push(const query& query) NOEXCEPT; + bool pop(const query& query) NOEXCEPT; + + /// Top height of accumulator. + size_t top_height() const NOEXCEPT; + +protected: + using rates = database::fee_rates; + using rate_sets = database::fee_rate_sets; + + /// Bucket depth sizing parameters (number of blocks). + enum horizon : size_t + { + /// 2 hrs × 60 mins/hr / 10 mins/block = 12 blocks. + small = 12, + + /// 8 hrs × 60 mins/hr / 10 mins/block = 48 blocks. + medium = 48, + + /// 7 days * 24 hrs/day × 60 mins/hr / 10 mins/block = 1008 blocks. + large = maximum_horizon + }; + + /// Bucket count sizing parameters. + struct sizing + { + static constexpr double min = 0.1; + static constexpr double max = 100'000.0; + static constexpr double step = 1.05; + + /// Derived from min/max/step above. + static constexpr size_t count = 283; + }; + + /// Estimation confidences. + struct confidence + { + static constexpr double low = 0.60; + static constexpr double mid = 0.85; + static constexpr double high = 0.95; + }; + + /// Accumulator (persistent, decay-weighted counters). + struct accumulator + { + template + struct bucket + { + /// Total scaled txs in bucket. + size_t total{}; + + /// confirmed[n]: scaled txs confirmed in > n blocks. + std::array confirmed; + }; + + /// Current block height of accumulated state. + size_t top_height{}; + + /// Accumulated scaled fee in decayed buckets by horizon. + /// Array count is the half life of the decay it implies. + std::array, sizing::count> small{}; + std::array, sizing::count> medium{}; + std::array, sizing::count> large{}; + }; + + // C++23: make consteval. + static inline double decay_rate() NOEXCEPT + { + static const auto rate = std::pow(0.5, 1.0 / sizing::count); + return rate; + } + + // C++23: make constexpr. + static inline double to_scale_term(size_t age) NOEXCEPT + { + return system::power(decay_rate(), age); + } + + // C++23: make constexpr. + static inline double to_scale_factor(bool push) NOEXCEPT + { + return std::pow(decay_rate(), push ? +1.0 : -1.0); + } + + accumulator& history() NOEXCEPT; + const accumulator& history() const NOEXCEPT; + bool initialize(const rate_sets& blocks) NOEXCEPT; + bool push(const rates& block) NOEXCEPT; + bool pop(const rates& block) NOEXCEPT; + uint64_t compute(size_t target, double confidence, + bool geometric=false) const NOEXCEPT; + +private: + bool update(const rates& block, size_t height, bool push) NOEXCEPT; + void decay(auto& buckets, double factor) NOEXCEPT; + void decay(bool push) NOEXCEPT; + + accumulator fees_{}; +}; + +} // namespace node +} // namespace libbitcoin + +#endif diff --git a/include/bitcoin/node/full_node.hpp b/include/bitcoin/node/full_node.hpp index 9ad408aa..54464d25 100644 --- a/include/bitcoin/node/full_node.hpp +++ b/include/bitcoin/node/full_node.hpp @@ -193,6 +193,7 @@ class BCN_API full_node chaser_confirm chaser_confirm_; chaser_transaction chaser_transaction_; chaser_template chaser_template_; + chaser_estimate chaser_estimate_; chaser_snapshot chaser_snapshot_; chaser_storage chaser_storage_; event_subscriber event_subscriber_{}; diff --git a/src/chasers/chaser_estimate.cpp b/src/chasers/chaser_estimate.cpp new file mode 100644 index 00000000..89b9f480 --- /dev/null +++ b/src/chasers/chaser_estimate.cpp @@ -0,0 +1,102 @@ +/** + * Copyright (c) 2011-2026 libbitcoin developers (see AUTHORS) + * + * This file is part of libbitcoin. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +#include + +#include +#include +#include + +namespace libbitcoin { +namespace node { + +#define CLASS chaser_estimate + +using namespace system; +using namespace std::placeholders; + +BC_PUSH_WARNING(NO_THROW_IN_NOEXCEPT) + +chaser_estimate::chaser_estimate(full_node& node) NOEXCEPT + : chaser(node) +{ +} + +// start +// ---------------------------------------------------------------------------- + +code chaser_estimate::start() NOEXCEPT +{ + SUBSCRIBE_CHASE(handle_chase, _1, _2, _3); + return error::success; +} + +// event handlers +// ---------------------------------------------------------------------------- + +bool chaser_estimate::handle_chase(const code&, chase event_, + event_value value) NOEXCEPT +{ + if (closed()) + return false; + + // Keep updating the fee accumulator (blocks continue organizing). + ////if (suspended()) + //// return true; + + switch (event_) + { + case chase::organized: + { + BC_ASSERT(std::holds_alternative(value)); + POST(do_organized, std::get(value)); + break; + } + case chase::reorganized: + { + BC_ASSERT(std::holds_alternative(value)); + POST(do_reorganized, std::get(value)); + break; + } + case chase::stop: + { + return false; + } + default: + { + break; + } + } + + return true; +} + +void chaser_estimate::do_organized(header_t) NOEXCEPT +{ + BC_ASSERT(stranded()); +} + +void chaser_estimate::do_reorganized(header_t) NOEXCEPT +{ + BC_ASSERT(stranded()); +} + +BC_POP_WARNING() + +} // namespace node +} // namespace libbitcoin diff --git a/src/estimator.cpp b/src/estimator.cpp new file mode 100644 index 00000000..0623f38f --- /dev/null +++ b/src/estimator.cpp @@ -0,0 +1,304 @@ +/** + * Copyright (c) 2011-2026 libbitcoin developers (see AUTHORS) + * + * This file is part of libbitcoin. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +#include + +#include +#include +#include +#include +#include + +namespace libbitcoin { +namespace node { + +using namespace system; + +// public +// ---------------------------------------------------------------------------- + +uint64_t estimator::estimate(size_t target, mode mode) const NOEXCEPT +{ + // max_uint64 is failure sentinel (and unachievable/invalid as a fee). + auto estimate = max_uint64; + constexpr size_t large = horizon::large; + if (target >= large) + return estimate; + + // Valid results are effectively limited to at least 1 sat/vb. + // threshold_fee is thread safe but values are affected during update. + switch (mode) + { + case mode::basic: + { + estimate = compute(target, confidence::high); + break; + } + case mode::geometric: + { + estimate = compute(target, confidence::high, true); + break; + } + case mode::economical: + { + const auto target1 = to_half(target); + const auto target2 = std::min(one, target); + const auto target3 = std::min(large, two * target); + const auto fee1 = compute(target1, confidence::low); + const auto fee2 = compute(target2, confidence::mid); + const auto fee3 = compute(target3, confidence::high); + estimate = std::max({ fee1, fee2, fee3 }); + break; + } + case mode::conservative: + { + const auto target1 = to_half(target); + const auto target2 = std::min(one, target); + const auto target3 = std::min(large, two * target); + const auto fee1 = compute(target1, confidence::low); + const auto fee2 = compute(target2, confidence::mid); + const auto fee3 = compute(target3, confidence::high); + estimate = std::max({ fee1, fee2, fee3 }); + break; + } + } + + return estimate; +} + +bool estimator::initialize(std::atomic_bool& cancel, const query& query, + size_t count) NOEXCEPT +{ + if (is_zero(count)) + return true; + + const auto top = query.get_top_confirmed(); + if (sub1(count) > top) + return false; + + rate_sets blocks{}; + const auto start = top - sub1(count); + return query.get_branch_fees(cancel, blocks, start, count) && + initialize(blocks); +} + +bool estimator::push(const query& query) NOEXCEPT +{ + if (is_add_overflow(top_height(), one)) + return false; + + rates block{}; + const auto link = query.to_confirmed(add1(top_height())); + return query.get_block_fees(block, link) && push(block); +} + +bool estimator::pop(const query& query) NOEXCEPT +{ + if (is_subtract_overflow(top_height(), one)) + return false; + + rates block{}; + const auto link = query.to_confirmed(sub1(top_height())); + return query.get_block_fees(block, link) && pop(block); +} + +size_t estimator::top_height() const NOEXCEPT +{ + return fees_.top_height; +} + +// protected +// ---------------------------------------------------------------------------- + +estimator::accumulator& estimator::history() NOEXCEPT +{ + return fees_; +} + +const estimator::accumulator& estimator::history() const NOEXCEPT +{ + return fees_; +} + +bool estimator::initialize(const rate_sets& blocks) NOEXCEPT +{ + const auto count = blocks.size(); + if (is_zero(count)) + return true; + + if (system::is_add_overflow(fees_.top_height, sub1(count))) + return false; + + auto height = fees_.top_height; + fees_.top_height += sub1(count); + + // 3-4 secs slower when parallel at 1008 blocks. + for (const auto& block: blocks) + if (!update(block, height++, true)) + return false; + + return true; +} + +// Blocks must be pushed in order (but independent of chain index). +bool estimator::push(const rates& block) NOEXCEPT +{ + decay(true); + return update(block, ++fees_.top_height, true); +} + +// Blocks must be pushed in order (but independent of chain index). +bool estimator::pop(const rates& block) NOEXCEPT +{ + const auto result = update(block, fees_.top_height, false); + decay(false); + --fees_.top_height; + return result; +} + +uint64_t estimator::compute(size_t target, double confidence, + bool geometric) const NOEXCEPT +{ + const auto threshold = [](double part, double total, size_t) NOEXCEPT + { + return part / total; + }; + + // Geometric distribution approximation, not a full Markov process. + const auto markov = [](double part, double total, size_t target) NOEXCEPT + { + return power(part / total, target); + }; + + const auto call = [&](const auto& buckets) NOEXCEPT + { + BC_PUSH_WARNING(NO_UNGUARDED_POINTERS) + const auto& contribution = geometric ? markov : threshold; + BC_POP_WARNING() + + constexpr auto magic_number = 2u; + const auto at_least_four = magic_number * add1(target); + double total{}, part{}; + auto index = buckets.size(); + auto found = index; + for (const auto& bucket: std::views::reverse(buckets)) + { + --index; + total += to_floating(bucket.total); + part += to_floating(bucket.confirmed.at(target)); + if (total < at_least_four) + continue; + + if (contribution(part, total, target) > (1.0 - confidence)) + break; + + found = index; + } + + if (found == buckets.size()) + return max_uint64; + + const auto minimum = sizing::min * std::pow(sizing::step, found); + return to_ceilinged_integer(minimum); + }; + + if (target < horizon::small) return call(fees_.small); + if (target < horizon::medium) return call(fees_.medium); + if (target < horizon::large) return call(fees_.large); + return max_uint64; +} + +// private +// ---------------------------------------------------------------------------- + +void estimator::decay(bool push) NOEXCEPT +{ + const auto factor = to_scale_factor(push); + decay(fees_.large, factor); + decay(fees_.medium, factor); + decay(fees_.small, factor); +} + +void estimator::decay(auto& buckets, double factor) NOEXCEPT +{ + for (auto& bucket: buckets) + { + bucket.total = to_floored_integer(bucket.total * factor); + for (auto& count: bucket.confirmed) + count = to_floored_integer(count * factor); + } +} + +bool estimator::update(const rates& block, size_t height, bool push) NOEXCEPT +{ + // std::log (replace static with constexpr in c++26). + static const auto growth = std::log(sizing::step); + std::array counts{}; + + for (const auto& tx: block) + { + if (is_zero(tx.bytes)) + return false; + + if (is_zero(tx.fee)) + continue; + + const auto rate = to_floating(tx.fee) / tx.bytes; + if (rate < sizing::min) + continue; + + // Clamp overflow to last bin. + const auto bin = std::log(rate / sizing::min) / growth; + ++counts.at(std::min(to_floored_integer(bin), sub1(sizing::count))); + } + + // At age zero scale term is one. + const auto age = top_height() - height; + const auto scale = to_scale_term(age); + const auto call = [&](auto& buckets) NOEXCEPT + { + // The array count of the buckets element type. + const auto horizon = buckets.front().confirmed.size(); + + size_t bin{}; + for (const auto count: counts) + { + if (is_zero(count)) + { + ++bin; + continue; + } + + auto& bucket = buckets.at(bin++); + const auto scaled = to_floored_integer(count * scale); + const auto signed_term = push ? scaled : twos_complement(scaled); + + bucket.total += signed_term; + for (auto target = age; target < horizon; ++target) + bucket.confirmed.at(target) += signed_term; + } + }; + + call(fees_.large); + call(fees_.medium); + call(fees_.small); + return true; +} + +} // namespace node +} // namespace libbitcoin diff --git a/src/full_node.cpp b/src/full_node.cpp index bd9d36a1..2cc19d3a 100644 --- a/src/full_node.cpp +++ b/src/full_node.cpp @@ -47,6 +47,7 @@ full_node::full_node(query& query, const configuration& configuration, chaser_confirm_(*this), chaser_transaction_(*this), chaser_template_(*this), + chaser_estimate_(*this), chaser_snapshot_(*this), chaser_storage_(*this) { @@ -84,6 +85,7 @@ void full_node::do_start(const result_handler& handler) NOEXCEPT ((ec = chaser_confirm_.start())) || ((ec = chaser_transaction_.start())) || ((ec = chaser_template_.start())) || + ((ec = chaser_estimate_.start())) || ((ec = chaser_snapshot_.start())) || ((ec = chaser_storage_.start()))) { @@ -131,6 +133,7 @@ void full_node::close() NOEXCEPT chaser_confirm_.stop(); chaser_transaction_.stop(); chaser_template_.stop(); + chaser_estimate_.stop(); chaser_snapshot_.stop(); chaser_storage_.stop(); } @@ -148,6 +151,7 @@ void full_node::do_close() NOEXCEPT chaser_confirm_.stopping(network::error::service_stopped); chaser_transaction_.stopping(network::error::service_stopped); chaser_template_.stopping(network::error::service_stopped); + chaser_estimate_.stopping(network::error::service_stopped); chaser_snapshot_.stopping(network::error::service_stopped); chaser_storage_.stopping(network::error::service_stopped); diff --git a/test/chasers/chaser_estimate.cpp b/test/chasers/chaser_estimate.cpp new file mode 100644 index 00000000..a6747d3a --- /dev/null +++ b/test/chasers/chaser_estimate.cpp @@ -0,0 +1,28 @@ +/** + * Copyright (c) 2011-2026 libbitcoin developers (see AUTHORS) + * + * This file is part of libbitcoin. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +#include "../test.hpp" + +BOOST_AUTO_TEST_SUITE(chaser_estimate_tests) + +BOOST_AUTO_TEST_CASE(chaser_estimate_test) +{ + BOOST_REQUIRE(true); +} + +BOOST_AUTO_TEST_SUITE_END() diff --git a/test/estimator.cpp b/test/estimator.cpp new file mode 100644 index 00000000..161b3dec --- /dev/null +++ b/test/estimator.cpp @@ -0,0 +1,419 @@ +/** + * Copyright (c) 2011-2026 libbitcoin developers (see AUTHORS) + * + * This file is part of libbitcoin. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +#include "test.hpp" + +BOOST_AUTO_TEST_SUITE(estimator_tests) + +using namespace system; + +struct acessor + : node::estimator +{ + typedef std::shared_ptr ptr; + + static acessor::ptr create() NOEXCEPT + { + return { new acessor(),[](acessor* ptr) NOEXCEPT { delete ptr; } }; + } + + using rates = estimator::rates; + using rate_sets = estimator::rate_sets; + using confidence = estimator::confidence; + using horizon = estimator::horizon; + using sizing = estimator::sizing; + using estimator::decay_rate; + using estimator::to_scale_term; + using estimator::to_scale_factor; + using estimator::history; + using estimator::initialize; + using estimator::push; + using estimator::pop; + using estimator::compute; +}; + +// decay_rate + +BOOST_AUTO_TEST_CASE(estimator__decay_rate__invoke__expected) +{ + const auto expected = std::pow(0.5, 1.0 / acessor::sizing::count); + BOOST_REQUIRE_CLOSE(acessor::decay_rate(), expected, 0.000001); +} + +// to_scale_term + +BOOST_AUTO_TEST_CASE(estimator__to_scale_term__zero__one) +{ + BOOST_REQUIRE_EQUAL(acessor::to_scale_term(0u), 1.0); +} + +BOOST_AUTO_TEST_CASE(estimator__to_scale_term__non_zero__expected) +{ + const auto rate = acessor::decay_rate(); + constexpr auto age = 42u; + const auto expected = std::pow(rate, age); + BOOST_REQUIRE_CLOSE(acessor::to_scale_term(age), expected, 0.000001); +} + +// to_scale_factor + +BOOST_AUTO_TEST_CASE(estimator__to_scale_factor__push_true__decay_rate) +{ + const auto rate = acessor::decay_rate(); + const auto expected = std::pow(rate, +1.0); + BOOST_REQUIRE_CLOSE(acessor::to_scale_factor(true), expected, 0.000001); +} + +BOOST_AUTO_TEST_CASE(estimator__to_scale_factor__push_false__inverse_decay_rate) +{ + const auto rate = acessor::decay_rate(); + const auto expected = std::pow(rate, -1.0); + BOOST_REQUIRE_CLOSE(acessor::to_scale_factor(false), expected, 0.000001); +} + +// top_height + +BOOST_AUTO_TEST_CASE(estimator__top_height__default__zero) +{ + const auto instance = acessor::create(); + BOOST_REQUIRE_EQUAL(instance->top_height(), 0u); +} + +BOOST_AUTO_TEST_CASE(estimator__top_height__non_default__expected) +{ + const auto instance = acessor::create(); + instance->history().top_height = 42u; + BOOST_REQUIRE_EQUAL(instance->top_height(), 42u); +} + +// initialize + +BOOST_AUTO_TEST_CASE(estimator__initialize__empty__true_height_unchanged) +{ + const auto instance = acessor::create(); + const acessor::rate_sets empty{}; + BOOST_REQUIRE(instance->initialize(empty)); + BOOST_REQUIRE_EQUAL(instance->top_height(), 0u); + BOOST_REQUIRE_EQUAL(instance->history().small[0].total, 0u); + + +}BOOST_AUTO_TEST_CASE(estimator__initialize__overflow__false_height_unchanged) +{ + const auto instance = acessor::create(); + instance->history().top_height = sub1(max_size_t); + acessor::rate_sets blocks(3); + BOOST_REQUIRE(!instance->initialize(blocks)); + BOOST_REQUIRE_EQUAL(instance->top_height(), sub1(max_size_t)); +} + +BOOST_AUTO_TEST_CASE(estimator__initialize__two_blocks__true_height_updated) +{ + const auto instance = acessor::create(); + acessor::rate_sets blocks(2); + BOOST_REQUIRE(instance->initialize(blocks)); + BOOST_REQUIRE_EQUAL(instance->top_height(), 1u); +} + +BOOST_AUTO_TEST_CASE(estimator__initialize__single_block__populates_expected) +{ + const auto instance = acessor::create(); + + // rate of 1/10 (0.1) in bin 0. + const acessor::rates block{ { 10u, 1u } }; + const acessor::rate_sets blocks{ block }; + BOOST_REQUIRE(instance->initialize(blocks)); + + constexpr size_t age{}; + const auto scale = acessor::to_scale_term(age); + const auto scaled = to_floored_integer(1u * scale); + const auto& small0 = instance->history().small.at(0); + + BOOST_REQUIRE_EQUAL(small0.total, scaled); + BOOST_REQUIRE_EQUAL(small0.confirmed[0], scaled); + BOOST_REQUIRE_EQUAL(small0.confirmed[1], scaled); + BOOST_REQUIRE_EQUAL(small0.confirmed[2], scaled); + BOOST_REQUIRE_EQUAL(small0.confirmed[3], scaled); + BOOST_REQUIRE_EQUAL(small0.confirmed[4], scaled); + BOOST_REQUIRE_EQUAL(small0.confirmed[5], scaled); + BOOST_REQUIRE_EQUAL(small0.confirmed[6], scaled); + BOOST_REQUIRE_EQUAL(small0.confirmed[7], scaled); + BOOST_REQUIRE_EQUAL(small0.confirmed[8], scaled); + BOOST_REQUIRE_EQUAL(small0.confirmed[9], scaled); + BOOST_REQUIRE_EQUAL(small0.confirmed[10], scaled); + BOOST_REQUIRE_EQUAL(small0.confirmed[11], scaled); + + const auto& medium0 = instance->history().medium.at(0); + BOOST_REQUIRE_EQUAL(medium0.total, scaled); + BOOST_REQUIRE_EQUAL(medium0.confirmed[0], scaled); + BOOST_REQUIRE_EQUAL(medium0.confirmed[1], scaled); + BOOST_REQUIRE_EQUAL(medium0.confirmed[2], scaled); + BOOST_REQUIRE_EQUAL(medium0.confirmed[45], scaled); + BOOST_REQUIRE_EQUAL(medium0.confirmed[46], scaled); + BOOST_REQUIRE_EQUAL(medium0.confirmed[47], scaled); + + const auto& large0 = instance->history().large.at(0); + BOOST_REQUIRE_EQUAL(large0.total, scaled); + BOOST_REQUIRE_EQUAL(large0.confirmed[0], scaled); + BOOST_REQUIRE_EQUAL(large0.confirmed[1], scaled); + BOOST_REQUIRE_EQUAL(large0.confirmed[2], scaled); + BOOST_REQUIRE_EQUAL(large0.confirmed[1005], scaled); + BOOST_REQUIRE_EQUAL(large0.confirmed[1006], scaled); + BOOST_REQUIRE_EQUAL(large0.confirmed[1007], scaled); +} + +BOOST_AUTO_TEST_CASE(estimator__initialize__two_blocks_with_data__expected) +{ + // 1 tx, rate=0.1, bin=0 + // 2 tx, rate=0.1, bin=0 + // Expected total: floor(1 * decay_rate) + floor(2 * 1.0) = 0 + 2 = 2. + const auto instance = acessor::create(); + const acessor::rates oldest{ { 10u, 1u } }; + const acessor::rates newest{ { 10u, 1u }, { 10u, 1u } }; + acessor::rate_sets blocks{ oldest, newest }; + BOOST_REQUIRE(instance->initialize(blocks)); + BOOST_REQUIRE_EQUAL(instance->top_height(), 1u); + BOOST_REQUIRE_EQUAL(instance->history().small.at(0).total, 2u); +} + +// push + +BOOST_AUTO_TEST_CASE(estimator__push__empty_block__decays_and_increments) +{ + const auto instance = acessor::create(); + constexpr auto initial = 100u; + instance->history().small[0].total = initial; + const auto factor = acessor::to_scale_factor(true); + const auto expected = to_floored_integer(initial * factor); + const acessor::rates empty{}; + BOOST_REQUIRE(instance->push(empty)); + BOOST_REQUIRE_EQUAL(instance->top_height(), 1u); + BOOST_REQUIRE_EQUAL(instance->history().small[0].total, expected); +} + +BOOST_AUTO_TEST_CASE(estimator__push__single_tx__populates_expected) +{ + const auto instance = acessor::create(); + + // rate of 1/10 (0.1) in bin 0. + const acessor::rates block{ { 10u, 1u } }; + BOOST_REQUIRE(instance->push(block)); + BOOST_REQUIRE_EQUAL(instance->top_height(), 1u); + + constexpr size_t age{}; + const auto scale = acessor::to_scale_term(age); + const auto scaled = to_floored_integer(1u * scale); + const auto& small0 = instance->history().small.at(0); + + BOOST_REQUIRE_EQUAL(small0.total, scaled); + BOOST_REQUIRE_EQUAL(small0.confirmed[0], scaled); + BOOST_REQUIRE_EQUAL(small0.confirmed[1], scaled); + BOOST_REQUIRE_EQUAL(small0.confirmed[2], scaled); + BOOST_REQUIRE_EQUAL(small0.confirmed[3], scaled); + BOOST_REQUIRE_EQUAL(small0.confirmed[4], scaled); + BOOST_REQUIRE_EQUAL(small0.confirmed[5], scaled); + BOOST_REQUIRE_EQUAL(small0.confirmed[6], scaled); + BOOST_REQUIRE_EQUAL(small0.confirmed[7], scaled); + BOOST_REQUIRE_EQUAL(small0.confirmed[8], scaled); + BOOST_REQUIRE_EQUAL(small0.confirmed[9], scaled); + BOOST_REQUIRE_EQUAL(small0.confirmed[10], scaled); + BOOST_REQUIRE_EQUAL(small0.confirmed[11], scaled); + + const auto& medium0 = instance->history().medium.at(0); + BOOST_REQUIRE_EQUAL(medium0.total, scaled); + BOOST_REQUIRE_EQUAL(medium0.confirmed[0], scaled); + BOOST_REQUIRE_EQUAL(medium0.confirmed[1], scaled); + BOOST_REQUIRE_EQUAL(medium0.confirmed[2], scaled); + BOOST_REQUIRE_EQUAL(medium0.confirmed[45], scaled); + BOOST_REQUIRE_EQUAL(medium0.confirmed[46], scaled); + BOOST_REQUIRE_EQUAL(medium0.confirmed[47], scaled); + + const auto& large0 = instance->history().large.at(0); + BOOST_REQUIRE_EQUAL(large0.total, scaled); + BOOST_REQUIRE_EQUAL(large0.confirmed[0], scaled); + BOOST_REQUIRE_EQUAL(large0.confirmed[1], scaled); + BOOST_REQUIRE_EQUAL(large0.confirmed[2], scaled); + BOOST_REQUIRE_EQUAL(large0.confirmed[1005], scaled); + BOOST_REQUIRE_EQUAL(large0.confirmed[1006], scaled); + BOOST_REQUIRE_EQUAL(large0.confirmed[1007], scaled); +} + +// pop + +BOOST_AUTO_TEST_CASE(estimator__pop__empty_block__decays_and_decrements) +{ + const auto instance = acessor::create(); + instance->history().top_height = 1u; + constexpr auto initial = 100u; + instance->history().small[0].total = initial; + const auto factor = acessor::to_scale_factor(false); + const auto expected = to_floored_integer(initial * factor); + const acessor::rates empty{}; + BOOST_REQUIRE(instance->pop(empty)); + BOOST_REQUIRE_EQUAL(instance->top_height(), 0u); + BOOST_REQUIRE_EQUAL(instance->history().small[0].total, expected); +} + +BOOST_AUTO_TEST_CASE(estimator__pop__reverses_push__restores_state) +{ + const auto instance = acessor::create(); + + // rate of 1/10 (0.1) in bin 0. + const acessor::rates block{ { 10u, 1u } }; + BOOST_REQUIRE(instance->push(block)); + BOOST_REQUIRE(instance->pop(block)); + BOOST_REQUIRE_EQUAL(instance->top_height(), 0u); + + const auto& small0 = instance->history().small.at(0); + + BOOST_REQUIRE_EQUAL(small0.total, 0u); + BOOST_REQUIRE_EQUAL(small0.confirmed[0], 0u); + BOOST_REQUIRE_EQUAL(small0.confirmed[1], 0u); + BOOST_REQUIRE_EQUAL(small0.confirmed[2], 0u); + BOOST_REQUIRE_EQUAL(small0.confirmed[3], 0u); + BOOST_REQUIRE_EQUAL(small0.confirmed[4], 0u); + BOOST_REQUIRE_EQUAL(small0.confirmed[5], 0u); + BOOST_REQUIRE_EQUAL(small0.confirmed[6], 0u); + BOOST_REQUIRE_EQUAL(small0.confirmed[7], 0u); + BOOST_REQUIRE_EQUAL(small0.confirmed[8], 0u); + BOOST_REQUIRE_EQUAL(small0.confirmed[9], 0u); + BOOST_REQUIRE_EQUAL(small0.confirmed[10], 0u); + BOOST_REQUIRE_EQUAL(small0.confirmed[11], 0u); + + const auto& medium0 = instance->history().medium.at(0); + BOOST_REQUIRE_EQUAL(medium0.total, 0u); + BOOST_REQUIRE_EQUAL(medium0.confirmed[0], 0u); + BOOST_REQUIRE_EQUAL(medium0.confirmed[1], 0u); + BOOST_REQUIRE_EQUAL(medium0.confirmed[2], 0u); + BOOST_REQUIRE_EQUAL(medium0.confirmed[45], 0u); + BOOST_REQUIRE_EQUAL(medium0.confirmed[46], 0u); + BOOST_REQUIRE_EQUAL(medium0.confirmed[47], 0u); + + const auto& large0 = instance->history().large.at(0); + BOOST_REQUIRE_EQUAL(large0.total, 0u); + BOOST_REQUIRE_EQUAL(large0.confirmed[0], 0u); + BOOST_REQUIRE_EQUAL(large0.confirmed[1], 0u); + BOOST_REQUIRE_EQUAL(large0.confirmed[2], 0u); + BOOST_REQUIRE_EQUAL(large0.confirmed[1005], 0u); + BOOST_REQUIRE_EQUAL(large0.confirmed[1006], 0u); + BOOST_REQUIRE_EQUAL(large0.confirmed[1007], 0u); +} + +// compute + +BOOST_AUTO_TEST_CASE(estimator__compute__default_state__max_uint64) +{ + const auto instance = acessor::create(); + BOOST_REQUIRE_EQUAL(instance->compute(0, acessor::confidence::high), max_uint64); + BOOST_REQUIRE_EQUAL(instance->compute(1, acessor::confidence::mid, true), max_uint64); + BOOST_REQUIRE_EQUAL(instance->compute(50, acessor::confidence::low), max_uint64); +} + +BOOST_AUTO_TEST_CASE(estimator__compute__insufficient_total__max_uint64) +{ + const auto instance = acessor::create(); + constexpr auto bin = 0u; + + // < at_least_four=2 for target=0. + constexpr auto value = 1u; + instance->history().small[bin].total = value; + instance->history().small[bin].confirmed[0] = value; + BOOST_REQUIRE_EQUAL(instance->compute(0, acessor::confidence::high), max_uint64); +} + +BOOST_AUTO_TEST_CASE(estimator__compute__low_failure_basic__expected_fee) +{ + const auto instance = acessor::create(); + constexpr auto bin = 0u; + constexpr auto total = 10u; + + // 0/10 = 0 <= 0.05. + constexpr auto failure = 0u; + instance->history().small[bin].total = total; + instance->history().small[bin].confirmed[0] = failure; + const auto fee = to_ceilinged_integer(acessor::sizing::min * std::pow(acessor::sizing::step, bin)); + BOOST_REQUIRE_EQUAL(instance->compute(0, acessor::confidence::high), fee); +} + +BOOST_AUTO_TEST_CASE(estimator__compute__high_failure_basic__max_uint64) +{ + const auto instance = acessor::create(); + constexpr auto bin = 0u; + constexpr auto total = 10u; + + // 1/10 = 0.1 > 0.05. + constexpr auto failure = 1u; + instance->history().small[bin].total = total; + instance->history().small[bin].confirmed[0] = failure; + BOOST_REQUIRE_EQUAL(instance->compute(0, acessor::confidence::high), max_uint64); +} + +BOOST_AUTO_TEST_CASE(estimator__compute__multi_bin_basic__expected_fee) +{ + const auto instance = acessor::create(); + constexpr auto low_bin = 0u; + constexpr auto high_bin = 1u; + constexpr auto total = 10u; + + // high failure in low bin. + constexpr auto low_failure = 10u; + + // low failure in high bin. + constexpr auto high_failure = 0u; + instance->history().small[low_bin].total = total; + instance->history().small[low_bin].confirmed[0] = low_failure; + instance->history().small[high_bin].total = total; + instance->history().small[high_bin].confirmed[0] = high_failure; + + // Cumulative at high_bin: 0/10 = 0 <= 0.05, then at low_bin: 10/20 = 0.5 > 0.05, found=1. + const auto fee = to_ceilinged_integer(acessor::sizing::min * std::pow(acessor::sizing::step, high_bin)); + BOOST_REQUIRE_EQUAL(instance->compute(0, acessor::confidence::high), fee); +} + +BOOST_AUTO_TEST_CASE(estimator__compute__geometric_target_one__matches_basic) +{ + const auto instance = acessor::create(); + constexpr auto bin = 0u; + constexpr auto total = 10u; + constexpr auto failure = 0u; + instance->history().small[bin].total = total; + instance->history().small[bin].confirmed[1] = failure; + const auto fee = to_ceilinged_integer(acessor::sizing::min * std::pow(acessor::sizing::step, bin)); + const auto basic = instance->compute(1, acessor::confidence::high, false); + const auto geometric = instance->compute(1, acessor::confidence::high, true); + BOOST_REQUIRE_EQUAL(basic, fee); + BOOST_REQUIRE_EQUAL(geometric, fee); +} + +BOOST_AUTO_TEST_CASE(estimator__compute__geometric_high_target__expected) +{ + const auto instance = acessor::create(); + constexpr auto bin = 0u; + constexpr auto total = 10u; + + // p=0.1, pow(0.1,2)=0.01 < 0.05, so found=0. + constexpr auto failure = 1u; + instance->history().small[bin].total = total; + instance->history().small[bin].confirmed[2] = failure; + const auto fee = to_ceilinged_integer(acessor::sizing::min * std::pow(acessor::sizing::step, bin)); + BOOST_REQUIRE_EQUAL(instance->compute(2, acessor::confidence::high, true), fee); + + // Contrast with basic: 0.1 > 0.05, would be max_uint64. + BOOST_REQUIRE_EQUAL(instance->compute(2, acessor::confidence::high, false), max_uint64); +} + +BOOST_AUTO_TEST_SUITE_END()