Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
132 changes: 132 additions & 0 deletions src/impl/btc/coin/template_capture.hpp
Original file line number Diff line number Diff line change
@@ -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 <cstddef>
#include <deque>
#include <functional>
#include <iostream>
#include <mutex>
#include <string>
#include <unordered_map>
#include <utility>

#include <nlohmann/json.hpp>

#include <core/uint256.hpp>

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<std::mutex> 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<std::mutex> 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<nlohmann::json(const uint256&)> provider() const
{
return [this](const uint256& h) { return provide(h); };
}

std::size_t size() const
{
std::lock_guard<std::mutex> lk(m_mtx);
return m_store.size();
}

private:
const std::size_t m_capacity;
mutable std::mutex m_mtx;
std::unordered_map<std::string, nlohmann::json> m_store;
std::deque<std::string> m_order;
};

} // namespace btc::coin
2 changes: 1 addition & 1 deletion src/impl/btc/test/CMakeLists.txt
Original file line number Diff line number Diff line change
@@ -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
Expand Down
129 changes: 129 additions & 0 deletions src/impl/btc/test/template_capture_test.cpp
Original file line number Diff line number Diff line change
@@ -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 <cstddef>
#include <string>

#include <gtest/gtest.h>

#include <nlohmann/json.hpp>

#include <impl/btc/coin/template_capture.hpp>
#include <core/uint256.hpp>

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);
}
Loading