Skip to content

Commit ffd500c

Browse files
committed
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.
1 parent b41151f commit ffd500c

3 files changed

Lines changed: 262 additions & 1 deletion

File tree

Lines changed: 132 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,132 @@
1+
// SPDX-License-Identifier: AGPL-3.0-or-later
2+
// ---------------------------------------------------------------------------
3+
// template_capture.hpp -- the per-job template-retention seam: the PRODUCTION
4+
// captured_template_txs_fn provider that feeds the won-block reconstructor's
5+
// template_other_txs_fn through the deserialize bridge (template_other_txs.hpp,
6+
// make_template_other_txs_fn -- BTC reconstructor slice 5, not yet landed).
7+
//
8+
// WHY a capture store, not mempool re-selection:
9+
// A won share commits to the GBT template it was MINED AGAINST at job hand-
10+
// out. The reconstructed broadcast block's non-coinbase set MUST be that
11+
// template's transactions[] in template order, because the share's merkle
12+
// root was computed over [gentx] ++ those txs. A won-block path that RE-
13+
// SELECTS the live embedded mempool is merkle-consistent ONLY while the
14+
// mempool is static across the won window (true under regtest, FALSE in prod:
15+
// any mempool mutation between hand-out and won-block diverges the set ->
16+
// wrong merkle root -> daemon-rejected block -> lost reward). This store
17+
// removes that race: capture the template's transactions[] at hand-out keyed
18+
// by the resulting share_hash, replay it verbatim at won-block.
19+
//
20+
// SSOT split: this header owns ONLY the keyed retain/replay + bounded eviction.
21+
// The transactions[] -> MutableTransaction decode is template_other_txs.hpp
22+
// (BTC slice 5); compose make_template_other_txs_fn(capture.provider()) to get
23+
// the template_other_txs_fn the reconstruct closure installs.
24+
//
25+
// Miss policy: an absent share_hash yields an EMPTY transactions[] (json
26+
// ::array()) -> the bridge decodes to an empty other_txs -> a VALID coinbase-
27+
// only won block (the reconstructor's documented empty contract), NOT a fail-
28+
// closed nullopt. A won block that loses its fee txs is still a valid, network-
29+
// accepted block carrying the full subsidy; dropping it on a capture miss would
30+
// forfeit the subsidy too. Logged so a miss is never silent.
31+
//
32+
// Bounded: jobs are handed out continuously; the store keeps the most recent
33+
// `capacity` templates (FIFO eviction) so memory is O(capacity), not O(jobs).
34+
// A won share is reconstructed within a few jobs of hand-out, so a modest
35+
// capacity covers every realistic won-window.
36+
//
37+
// Per-coin isolation: src/impl/btc/ only. p2pool-merged-v36 surface: NONE.
38+
// Thread-safe: capture() runs on the job/mint path, provider() fires on the
39+
// COMPUTE thread inside the won-block closure -- guarded by one mutex.
40+
// ---------------------------------------------------------------------------
41+
#pragma once
42+
43+
#include <cstddef>
44+
#include <deque>
45+
#include <functional>
46+
#include <iostream>
47+
#include <mutex>
48+
#include <string>
49+
#include <unordered_map>
50+
#include <utility>
51+
52+
#include <nlohmann/json.hpp>
53+
54+
#include <core/uint256.hpp>
55+
56+
namespace btc::coin
57+
{
58+
59+
// Per-job captured-template store keyed by share_hash. Retains each handed-out
60+
// template's transactions[] (the conformant GBT shape: array of {data,txid,
61+
// hash,fee}) so the won-block reconstructor can replay the exact non-coinbase
62+
// set the share committed to.
63+
class TemplateCapture
64+
{
65+
public:
66+
static constexpr std::size_t DEFAULT_CAPACITY = 256;
67+
68+
explicit TemplateCapture(std::size_t capacity = DEFAULT_CAPACITY)
69+
: m_capacity(capacity == 0 ? 1 : capacity) {}
70+
71+
// Retain `transactions` (a GBT transactions[] array) for `share_hash`.
72+
// Overwrites an existing entry for the same hash WITHOUT enqueuing the key
73+
// twice (so eviction stays in true insertion order). FIFO-evicts the oldest
74+
// once a new key would exceed capacity.
75+
void capture(const uint256& share_hash, nlohmann::json transactions)
76+
{
77+
const std::string key = share_hash.GetHex();
78+
std::lock_guard<std::mutex> lk(m_mtx);
79+
auto it = m_store.find(key);
80+
if (it == m_store.end())
81+
{
82+
if (m_store.size() >= m_capacity && !m_order.empty())
83+
{
84+
m_store.erase(m_order.front());
85+
m_order.pop_front();
86+
}
87+
m_order.push_back(key);
88+
}
89+
m_store[key] = std::move(transactions);
90+
}
91+
92+
// Replay the captured transactions[] for `share_hash`. Returns an empty
93+
// json::array() on a miss (-> coinbase-only valid block), logged.
94+
nlohmann::json provide(const uint256& share_hash) const
95+
{
96+
const std::string key = share_hash.GetHex();
97+
std::lock_guard<std::mutex> lk(m_mtx);
98+
auto it = m_store.find(key);
99+
if (it == m_store.end())
100+
{
101+
std::cout << "[BTC-POOL-BLOCK] template-capture MISS for won share "
102+
<< key.substr(0, 16)
103+
<< " -- reconstructing coinbase-only (no retained template)"
104+
<< std::endl;
105+
return nlohmann::json::array();
106+
}
107+
return it->second;
108+
}
109+
110+
// The captured_template_txs_fn make_template_other_txs_fn (slice 5) consumes.
111+
// The returned closure captures `this`; the TemplateCapture MUST outlive the
112+
// reconstruct closure it is installed into (it does in main_btc: a run-loop-
113+
// scoped member outliving the tracker callback).
114+
std::function<nlohmann::json(const uint256&)> provider() const
115+
{
116+
return [this](const uint256& h) { return provide(h); };
117+
}
118+
119+
std::size_t size() const
120+
{
121+
std::lock_guard<std::mutex> lk(m_mtx);
122+
return m_store.size();
123+
}
124+
125+
private:
126+
const std::size_t m_capacity;
127+
mutable std::mutex m_mtx;
128+
std::unordered_map<std::string, nlohmann::json> m_store;
129+
std::deque<std::string> m_order;
130+
};
131+
132+
} // namespace btc::coin

src/impl/btc/test/CMakeLists.txt

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
if (BUILD_TESTING AND GTest_FOUND)
22
# btc twin of ltc share_test — uniquely named to avoid the CMP0002 target
33
# collision with src/impl/ltc/test (both subdirs build in the same tree).
4-
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)
4+
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)
55
target_link_libraries(btc_share_test PRIVATE
66
GTest::gtest_main GTest::gtest
77
core btc
Lines changed: 129 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,129 @@
1+
// SPDX-License-Identifier: AGPL-3.0-or-later
2+
// ---------------------------------------------------------------------------
3+
// btc_template_capture_test -- pins coin/template_capture.hpp, the per-job
4+
// template-retention seam: the PRODUCTION captured_template_txs_fn the won-block
5+
// reconstructor's template_other_txs_fn is built from (via the slice-5
6+
// make_template_other_txs_fn bridge, not yet landed), replacing any interim
7+
// mempool RE-SELECTION path (merkle-consistent only on a static mempool).
8+
//
9+
// BTC reconstructor slice 3/7. This is a LEAF slice: TemplateCapture is opaque
10+
// to transactions[] content (it stores/replays nlohmann::json verbatim), so the
11+
// KAT pins the store contract WITHOUT the slice-5 bridge -- keeping the slice
12+
// dependency-free. The composed bridge round-trip lands with slice 5.
13+
//
14+
// Oracle vectors are coin-agnostic GBT transactions[] shape ({data,txid,hash,
15+
// fee}) hand-built as json literals -- the capture store treats them as opaque
16+
// json, so no coin codec / mempool dependency is needed to observe its bytes.
17+
//
18+
// What it pins:
19+
// * capture(share_hash, transactions[]) -> provide(share_hash) replays the
20+
// SAME GBT transactions[] json-faithfully (json-equal), keyed by share.
21+
// * a capture MISS replays an empty array -> coinbase-only valid block (NOT
22+
// fail-closed), so a missing template never forfeits the won subsidy.
23+
// * overwrite-same-hash keeps one entry (latest wins); FIFO eviction bounds
24+
// the store and drops the OLDEST template once over capacity.
25+
//
26+
// Rides the allowlisted btc_share_test add_executable (test/CMakeLists.txt);
27+
// a new *_test.cpp MUST be in that source list or it silently NOT_BUILT (#143).
28+
// Per-coin isolation: src/impl/btc/ only.
29+
// ---------------------------------------------------------------------------
30+
#include <cstddef>
31+
#include <string>
32+
33+
#include <gtest/gtest.h>
34+
35+
#include <nlohmann/json.hpp>
36+
37+
#include <impl/btc/coin/template_capture.hpp>
38+
#include <core/uint256.hpp>
39+
40+
using btc::coin::TemplateCapture;
41+
42+
namespace {
43+
44+
uint256 hash_for(const char* hex)
45+
{
46+
uint256 h; h.SetHex(hex);
47+
return h;
48+
}
49+
50+
// A production-shaped GBT transactions[]: array of {data,txid,hash,fee}. Content
51+
// is opaque to TemplateCapture -- these literals stand in for a populated GBT.
52+
nlohmann::json sample_template_txs()
53+
{
54+
return nlohmann::json::array({
55+
{{"data", "0100000001aa"}, {"txid", "aa01"}, {"hash", "aa01"}, {"fee", 700}},
56+
{{"data", "0100000001bb"}, {"txid", "bb02"}, {"hash", "bb02"}, {"fee", 300}},
57+
});
58+
}
59+
60+
const char* H_A = "00000000000000000000000000000000000000000000000000000000000000a1";
61+
const char* H_B = "00000000000000000000000000000000000000000000000000000000000000b2";
62+
const char* H_C = "00000000000000000000000000000000000000000000000000000000000000c3";
63+
const char* H_MISS = "00000000000000000000000000000000000000000000000000000000deadbeef";
64+
65+
} // namespace
66+
67+
// --- Test 1: capture -> provide round-trips the template json-faithfully -------
68+
TEST(BtcTemplateCapture, CaptureProvideRoundTrip)
69+
{
70+
TemplateCapture cap;
71+
const auto txs = sample_template_txs();
72+
ASSERT_EQ(txs.size(), 2u);
73+
74+
cap.capture(hash_for(H_A), txs);
75+
EXPECT_EQ(cap.size(), 1u);
76+
// json-equal: the exact transactions[] handed in comes back out.
77+
EXPECT_EQ(cap.provide(hash_for(H_A)), txs);
78+
}
79+
80+
// --- Test 2: a MISS replays an empty array (coinbase-only, not fail-closed) ---
81+
TEST(BtcTemplateCapture, MissReplaysEmptyArray)
82+
{
83+
TemplateCapture cap;
84+
cap.capture(hash_for(H_A), sample_template_txs());
85+
86+
const auto miss = cap.provide(hash_for(H_MISS));
87+
ASSERT_TRUE(miss.is_array());
88+
EXPECT_TRUE(miss.empty());
89+
}
90+
91+
// --- Test 3: the provider() closure replays HIT and MISS identically ----------
92+
// Pins that provider() is a transparent view over provide(): a HIT yields the
93+
// captured template json-equal, a MISS yields an empty array. (The slice-5
94+
// make_template_other_txs_fn bridge decodes this same output; that composed
95+
// round-trip lands with slice 5.)
96+
TEST(BtcTemplateCapture, ProviderClosureMirrorsProvide)
97+
{
98+
TemplateCapture cap;
99+
const auto txs = sample_template_txs();
100+
cap.capture(hash_for(H_A), txs);
101+
102+
auto provider = cap.provider();
103+
EXPECT_EQ(provider(hash_for(H_A)), txs); // HIT: json-equal
104+
EXPECT_TRUE(provider(hash_for(H_MISS)).empty()); // MISS: empty array
105+
}
106+
107+
// --- Test 4: overwrite the same share_hash keeps one entry (latest wins) ------
108+
TEST(BtcTemplateCapture, OverwriteSameHashLatestWins)
109+
{
110+
TemplateCapture cap;
111+
cap.capture(hash_for(H_A), nlohmann::json::array()); // empty first
112+
cap.capture(hash_for(H_A), sample_template_txs()); // then 2-tx template
113+
EXPECT_EQ(cap.size(), 1u); // not duplicated
114+
EXPECT_EQ(cap.provide(hash_for(H_A)).size(), 2u); // latest content
115+
}
116+
117+
// --- Test 5: FIFO eviction bounds the store, drops the OLDEST template --------
118+
TEST(BtcTemplateCapture, BoundedFifoEvictsOldest)
119+
{
120+
TemplateCapture cap(/*capacity=*/2);
121+
cap.capture(hash_for(H_A), sample_template_txs());
122+
cap.capture(hash_for(H_B), sample_template_txs());
123+
cap.capture(hash_for(H_C), sample_template_txs()); // evicts H_A
124+
125+
EXPECT_EQ(cap.size(), 2u);
126+
EXPECT_TRUE(cap.provide(hash_for(H_A)).empty()); // oldest evicted -> miss
127+
EXPECT_EQ(cap.provide(hash_for(H_B)).size(), 2u); // newest retained
128+
EXPECT_EQ(cap.provide(hash_for(H_C)).size(), 2u);
129+
}

0 commit comments

Comments
 (0)