From ffd500cfcdaabb10e6bcf5bd531b0b3ba045565b Mon Sep 17 00:00:00 2001 From: frstrtr Date: Fri, 24 Jul 2026 19:51:36 +0000 Subject: [PATCH] btc(#744): template_capture SSOT -- per-job GBT tx-set retention (reconstructor slice 3/7) Add btc::coin::TemplateCapture: a bounded FIFO store keyed by share_hash that retains each handed-out GBT transactions[] so the won-block reconstructor replays the EXACT non-coinbase set the share committed to, not a live-mempool re-selection (merkle-consistent only on a static mempool -> daemon-rejected block -> lost reward in prod). Miss replays an empty array -> valid coinbase-only block, never fail-closed. Body-identical to src/impl/dgb/coin/template_capture.hpp (namespace + log tag swap only). Leaf slice: opaque to transactions[] content, so pinned WITHOUT the slice-5 make_template_other_txs_fn bridge. New template_capture_test.cpp (5 KATs) rides the allowlisted btc_share_test. Local btc_share_test 80/80. btc-tree-only. --- src/impl/btc/coin/template_capture.hpp | 132 ++++++++++++++++++++ src/impl/btc/test/CMakeLists.txt | 2 +- src/impl/btc/test/template_capture_test.cpp | 129 +++++++++++++++++++ 3 files changed, 262 insertions(+), 1 deletion(-) create mode 100644 src/impl/btc/coin/template_capture.hpp create mode 100644 src/impl/btc/test/template_capture_test.cpp diff --git a/src/impl/btc/coin/template_capture.hpp b/src/impl/btc/coin/template_capture.hpp new file mode 100644 index 000000000..178e86b5f --- /dev/null +++ b/src/impl/btc/coin/template_capture.hpp @@ -0,0 +1,132 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later +// --------------------------------------------------------------------------- +// template_capture.hpp -- the per-job template-retention seam: the PRODUCTION +// captured_template_txs_fn provider that feeds the won-block reconstructor's +// template_other_txs_fn through the deserialize bridge (template_other_txs.hpp, +// make_template_other_txs_fn -- BTC reconstructor slice 5, not yet landed). +// +// WHY a capture store, not mempool re-selection: +// A won share commits to the GBT template it was MINED AGAINST at job hand- +// out. The reconstructed broadcast block's non-coinbase set MUST be that +// template's transactions[] in template order, because the share's merkle +// root was computed over [gentx] ++ those txs. A won-block path that RE- +// SELECTS the live embedded mempool is merkle-consistent ONLY while the +// mempool is static across the won window (true under regtest, FALSE in prod: +// any mempool mutation between hand-out and won-block diverges the set -> +// wrong merkle root -> daemon-rejected block -> lost reward). This store +// removes that race: capture the template's transactions[] at hand-out keyed +// by the resulting share_hash, replay it verbatim at won-block. +// +// SSOT split: this header owns ONLY the keyed retain/replay + bounded eviction. +// The transactions[] -> MutableTransaction decode is template_other_txs.hpp +// (BTC slice 5); compose make_template_other_txs_fn(capture.provider()) to get +// the template_other_txs_fn the reconstruct closure installs. +// +// Miss policy: an absent share_hash yields an EMPTY transactions[] (json +// ::array()) -> the bridge decodes to an empty other_txs -> a VALID coinbase- +// only won block (the reconstructor's documented empty contract), NOT a fail- +// closed nullopt. A won block that loses its fee txs is still a valid, network- +// accepted block carrying the full subsidy; dropping it on a capture miss would +// forfeit the subsidy too. Logged so a miss is never silent. +// +// Bounded: jobs are handed out continuously; the store keeps the most recent +// `capacity` templates (FIFO eviction) so memory is O(capacity), not O(jobs). +// A won share is reconstructed within a few jobs of hand-out, so a modest +// capacity covers every realistic won-window. +// +// Per-coin isolation: src/impl/btc/ only. p2pool-merged-v36 surface: NONE. +// Thread-safe: capture() runs on the job/mint path, provider() fires on the +// COMPUTE thread inside the won-block closure -- guarded by one mutex. +// --------------------------------------------------------------------------- +#pragma once + +#include +#include +#include +#include +#include +#include +#include +#include + +#include + +#include + +namespace btc::coin +{ + +// Per-job captured-template store keyed by share_hash. Retains each handed-out +// template's transactions[] (the conformant GBT shape: array of {data,txid, +// hash,fee}) so the won-block reconstructor can replay the exact non-coinbase +// set the share committed to. +class TemplateCapture +{ +public: + static constexpr std::size_t DEFAULT_CAPACITY = 256; + + explicit TemplateCapture(std::size_t capacity = DEFAULT_CAPACITY) + : m_capacity(capacity == 0 ? 1 : capacity) {} + + // Retain `transactions` (a GBT transactions[] array) for `share_hash`. + // Overwrites an existing entry for the same hash WITHOUT enqueuing the key + // twice (so eviction stays in true insertion order). FIFO-evicts the oldest + // once a new key would exceed capacity. + void capture(const uint256& share_hash, nlohmann::json transactions) + { + const std::string key = share_hash.GetHex(); + std::lock_guard lk(m_mtx); + auto it = m_store.find(key); + if (it == m_store.end()) + { + if (m_store.size() >= m_capacity && !m_order.empty()) + { + m_store.erase(m_order.front()); + m_order.pop_front(); + } + m_order.push_back(key); + } + m_store[key] = std::move(transactions); + } + + // Replay the captured transactions[] for `share_hash`. Returns an empty + // json::array() on a miss (-> coinbase-only valid block), logged. + nlohmann::json provide(const uint256& share_hash) const + { + const std::string key = share_hash.GetHex(); + std::lock_guard lk(m_mtx); + auto it = m_store.find(key); + if (it == m_store.end()) + { + std::cout << "[BTC-POOL-BLOCK] template-capture MISS for won share " + << key.substr(0, 16) + << " -- reconstructing coinbase-only (no retained template)" + << std::endl; + return nlohmann::json::array(); + } + return it->second; + } + + // The captured_template_txs_fn make_template_other_txs_fn (slice 5) consumes. + // The returned closure captures `this`; the TemplateCapture MUST outlive the + // reconstruct closure it is installed into (it does in main_btc: a run-loop- + // scoped member outliving the tracker callback). + std::function provider() const + { + return [this](const uint256& h) { return provide(h); }; + } + + std::size_t size() const + { + std::lock_guard lk(m_mtx); + return m_store.size(); + } + +private: + const std::size_t m_capacity; + mutable std::mutex m_mtx; + std::unordered_map m_store; + std::deque m_order; +}; + +} // namespace btc::coin diff --git a/src/impl/btc/test/CMakeLists.txt b/src/impl/btc/test/CMakeLists.txt index c113eee95..8cf681a4f 100644 --- a/src/impl/btc/test/CMakeLists.txt +++ b/src/impl/btc/test/CMakeLists.txt @@ -1,7 +1,7 @@ if (BUILD_TESTING AND GTest_FOUND) # btc twin of ltc share_test — uniquely named to avoid the CMP0002 target # collision with src/impl/ltc/test (both subdirs build in the same tree). - add_executable(btc_share_test share_test.cpp f11_donation_invariance_test.cpp f10b_version_punish_removal_test.cpp g01_share_format_parity_test.cpp auto_ratchet_sim_test.cpp block_broadcast_guard_test.cpp block_broadcast_connect_test.cpp regtest_sharechain_isolation_test.cpp stratum_merkle_branch_test.cpp witness_commitment_merkle_test.cpp finder_fee_integer_exact_test.cpp rpc_conf_test.cpp genesis_check_test.cpp won_share_dualpath_test.cpp gentx_unpack_test.cpp block_assembly_test.cpp gentx_coinbase_test.cpp) + add_executable(btc_share_test share_test.cpp f11_donation_invariance_test.cpp f10b_version_punish_removal_test.cpp g01_share_format_parity_test.cpp auto_ratchet_sim_test.cpp block_broadcast_guard_test.cpp block_broadcast_connect_test.cpp regtest_sharechain_isolation_test.cpp stratum_merkle_branch_test.cpp witness_commitment_merkle_test.cpp finder_fee_integer_exact_test.cpp rpc_conf_test.cpp genesis_check_test.cpp won_share_dualpath_test.cpp gentx_unpack_test.cpp block_assembly_test.cpp gentx_coinbase_test.cpp template_capture_test.cpp) target_link_libraries(btc_share_test PRIVATE GTest::gtest_main GTest::gtest core btc diff --git a/src/impl/btc/test/template_capture_test.cpp b/src/impl/btc/test/template_capture_test.cpp new file mode 100644 index 000000000..67fdf9c03 --- /dev/null +++ b/src/impl/btc/test/template_capture_test.cpp @@ -0,0 +1,129 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later +// --------------------------------------------------------------------------- +// btc_template_capture_test -- pins coin/template_capture.hpp, the per-job +// template-retention seam: the PRODUCTION captured_template_txs_fn the won-block +// reconstructor's template_other_txs_fn is built from (via the slice-5 +// make_template_other_txs_fn bridge, not yet landed), replacing any interim +// mempool RE-SELECTION path (merkle-consistent only on a static mempool). +// +// BTC reconstructor slice 3/7. This is a LEAF slice: TemplateCapture is opaque +// to transactions[] content (it stores/replays nlohmann::json verbatim), so the +// KAT pins the store contract WITHOUT the slice-5 bridge -- keeping the slice +// dependency-free. The composed bridge round-trip lands with slice 5. +// +// Oracle vectors are coin-agnostic GBT transactions[] shape ({data,txid,hash, +// fee}) hand-built as json literals -- the capture store treats them as opaque +// json, so no coin codec / mempool dependency is needed to observe its bytes. +// +// What it pins: +// * capture(share_hash, transactions[]) -> provide(share_hash) replays the +// SAME GBT transactions[] json-faithfully (json-equal), keyed by share. +// * a capture MISS replays an empty array -> coinbase-only valid block (NOT +// fail-closed), so a missing template never forfeits the won subsidy. +// * overwrite-same-hash keeps one entry (latest wins); FIFO eviction bounds +// the store and drops the OLDEST template once over capacity. +// +// Rides the allowlisted btc_share_test add_executable (test/CMakeLists.txt); +// a new *_test.cpp MUST be in that source list or it silently NOT_BUILT (#143). +// Per-coin isolation: src/impl/btc/ only. +// --------------------------------------------------------------------------- +#include +#include + +#include + +#include + +#include +#include + +using btc::coin::TemplateCapture; + +namespace { + +uint256 hash_for(const char* hex) +{ + uint256 h; h.SetHex(hex); + return h; +} + +// A production-shaped GBT transactions[]: array of {data,txid,hash,fee}. Content +// is opaque to TemplateCapture -- these literals stand in for a populated GBT. +nlohmann::json sample_template_txs() +{ + return nlohmann::json::array({ + {{"data", "0100000001aa"}, {"txid", "aa01"}, {"hash", "aa01"}, {"fee", 700}}, + {{"data", "0100000001bb"}, {"txid", "bb02"}, {"hash", "bb02"}, {"fee", 300}}, + }); +} + +const char* H_A = "00000000000000000000000000000000000000000000000000000000000000a1"; +const char* H_B = "00000000000000000000000000000000000000000000000000000000000000b2"; +const char* H_C = "00000000000000000000000000000000000000000000000000000000000000c3"; +const char* H_MISS = "00000000000000000000000000000000000000000000000000000000deadbeef"; + +} // namespace + +// --- Test 1: capture -> provide round-trips the template json-faithfully ------- +TEST(BtcTemplateCapture, CaptureProvideRoundTrip) +{ + TemplateCapture cap; + const auto txs = sample_template_txs(); + ASSERT_EQ(txs.size(), 2u); + + cap.capture(hash_for(H_A), txs); + EXPECT_EQ(cap.size(), 1u); + // json-equal: the exact transactions[] handed in comes back out. + EXPECT_EQ(cap.provide(hash_for(H_A)), txs); +} + +// --- Test 2: a MISS replays an empty array (coinbase-only, not fail-closed) --- +TEST(BtcTemplateCapture, MissReplaysEmptyArray) +{ + TemplateCapture cap; + cap.capture(hash_for(H_A), sample_template_txs()); + + const auto miss = cap.provide(hash_for(H_MISS)); + ASSERT_TRUE(miss.is_array()); + EXPECT_TRUE(miss.empty()); +} + +// --- Test 3: the provider() closure replays HIT and MISS identically ---------- +// Pins that provider() is a transparent view over provide(): a HIT yields the +// captured template json-equal, a MISS yields an empty array. (The slice-5 +// make_template_other_txs_fn bridge decodes this same output; that composed +// round-trip lands with slice 5.) +TEST(BtcTemplateCapture, ProviderClosureMirrorsProvide) +{ + TemplateCapture cap; + const auto txs = sample_template_txs(); + cap.capture(hash_for(H_A), txs); + + auto provider = cap.provider(); + EXPECT_EQ(provider(hash_for(H_A)), txs); // HIT: json-equal + EXPECT_TRUE(provider(hash_for(H_MISS)).empty()); // MISS: empty array +} + +// --- Test 4: overwrite the same share_hash keeps one entry (latest wins) ------ +TEST(BtcTemplateCapture, OverwriteSameHashLatestWins) +{ + TemplateCapture cap; + cap.capture(hash_for(H_A), nlohmann::json::array()); // empty first + cap.capture(hash_for(H_A), sample_template_txs()); // then 2-tx template + EXPECT_EQ(cap.size(), 1u); // not duplicated + EXPECT_EQ(cap.provide(hash_for(H_A)).size(), 2u); // latest content +} + +// --- Test 5: FIFO eviction bounds the store, drops the OLDEST template -------- +TEST(BtcTemplateCapture, BoundedFifoEvictsOldest) +{ + TemplateCapture cap(/*capacity=*/2); + cap.capture(hash_for(H_A), sample_template_txs()); + cap.capture(hash_for(H_B), sample_template_txs()); + cap.capture(hash_for(H_C), sample_template_txs()); // evicts H_A + + EXPECT_EQ(cap.size(), 2u); + EXPECT_TRUE(cap.provide(hash_for(H_A)).empty()); // oldest evicted -> miss + EXPECT_EQ(cap.provide(hash_for(H_B)).size(), 2u); // newest retained + EXPECT_EQ(cap.provide(hash_for(H_C)).size(), 2u); +}