Skip to content

Commit 083071d

Browse files
authored
fix(btc): prepend coinbase placeholder leaf in stratum merkle branches (#570)
* fix(btc): prepend coinbase placeholder leaf in stratum merkle branches get_stratum_merkle_branches treated wd->m_hashes[0] as the coinbase placeholder, but the producers (rpc.cpp, coin/template_builder.hpp) fill m_hashes as the pure tx-hash list [tx1..txN] with NO coinbase slot. For any populated (>=1 mempool tx) block this folded tx2 as the first sibling and dropped tx1, so the stratum header merkle root diverged from the serialized body -> bad-txnmrklroot rejection. Coinbase-only blocks (empty m_hashes) early-return {} and collapse to coinbase_txid, so the bug was latent until the first populated won block. Fix (fenced to stratum/work_source.cpp, zero coinbase-byte reshape): - get_stratum_merkle_branches: prepend uint256::ZERO coinbase placeholder leaf before the branch fold; empty-mempool early return unchanged. - mining_submit: non-fatal body-vs-header merkle self-check recomputing compute_merkle_root([coinbase_txid, tx1..txN]) and logging OK/DIVERGENCE before submit, so the leaf-set class is caught loudly not silently. Mirrors bch-embedded-steward fenced fix; per-coin isolation preserved. btc_share_test 39/39 green; make btc exit 0. * test(btc): KAT pinning stratum merkle coinbase leaf-0 contract (#570) Extract the stratum coinbase branch fold into a pure SSOT helper btc::coin::stratum_merkle_siblings() (template_builder.hpp) and delegate get_stratum_merkle_branches() to it, so the wire path, the mining_submit self-check, and tests all exercise one fold. Add btc_share_test gtest stratum_merkle_branch_test.cpp (CI-bound, 39->42): - FoldReconstructsBodyRoot: production siblings folded against a coinbase txid equal compute_merkle_root([coinbase, tx1..txN]) for N in {0,1,2,3,4,5,7,16} (covers the N=1 case the latent bug corrupted + odd last-element duplication). - CoinbaseOnlyHasNoBranches: empty template -> no branches, coinbase IS root. - PreFixBuilderDivergesOnPopulated: the pre-#570 builder (no leaf-0 placeholder) diverges from the body root on every populated template -> flip-RED proof the guard is not blind. No consensus change; test + refactor only, BTC-fenced. --------- Co-authored-by: frstrtr <frstrtr@users.noreply.github.com>
1 parent 6bf6d29 commit 083071d

4 files changed

Lines changed: 193 additions & 19 deletions

File tree

src/impl/btc/coin/template_builder.hpp

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -88,6 +88,48 @@ inline uint256 compute_merkle_root(std::vector<uint256> hashes) {
8888
return hashes[0];
8989
}
9090

91+
/// Stratum coinbase merkle branch siblings for a populated template.
92+
///
93+
/// PRODUCER/CONSUMER CONTRACT: `tx_hashes` is the pure tx-hash list
94+
/// [tx1..txN] with NO coinbase slot (template_builder / GBT transactions[]).
95+
/// The stratum merkle tree has the coinbase as leaf 0, so we prepend a
96+
/// placeholder leaf before folding. At each level we emit the SIBLING of the
97+
/// left-most (coinbase-descended) node -- exactly the branch list a miner
98+
/// folds against its own coinbase txid to rebuild the header merkle root:
99+
/// acc = coinbase_txid
100+
/// for b in siblings: acc = merkle_hash_pair(acc, b)
101+
/// acc == compute_merkle_root([coinbase_txid, tx1..txN])
102+
///
103+
/// SSOT for both BTCWorkSource::get_stratum_merkle_branches() (which hex-
104+
/// encodes these for the wire) and the merkle self-check in mining_submit.
105+
/// Without the leaf-0 prepend, tx1 is dropped and the folded root diverges
106+
/// from the serialized body -> bad-txnmrklroot on any populated won block.
107+
/// Returns {} for an empty (coinbase-only) template.
108+
inline std::vector<uint256> stratum_merkle_siblings(const std::vector<uint256>& tx_hashes) {
109+
if (tx_hashes.empty()) return {};
110+
111+
std::vector<uint256> level;
112+
level.reserve(tx_hashes.size() + 1);
113+
level.push_back(uint256::ZERO); // coinbase placeholder leaf (leaf 0)
114+
level.insert(level.end(), tx_hashes.begin(), tx_hashes.end());
115+
116+
std::vector<uint256> siblings;
117+
while (level.size() > 1) {
118+
siblings.push_back(level[1]); // right-sibling of the coinbase node
119+
120+
std::vector<uint256> next;
121+
next.reserve((level.size() + 2) / 2);
122+
next.push_back(uint256::ZERO); // placeholder for combo of (cb, level[1])
123+
for (size_t i = 2; i < level.size(); i += 2) {
124+
const uint256& l = level[i];
125+
const uint256& r = (i + 1 < level.size()) ? level[i + 1] : level[i];
126+
next.push_back(merkle_hash_pair(l, r));
127+
}
128+
level = std::move(next);
129+
}
130+
return siblings;
131+
}
132+
91133
// ─── CoinNodeInterface ────────────────────────────────────────────────────────
92134

93135
/// Abstract interface for obtaining work and submitting blocks.

src/impl/btc/stratum/work_source.cpp

Lines changed: 44 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -275,12 +275,22 @@ std::vector<std::string> BTCWorkSource::get_stratum_merkle_branches() const
275275
auto wd = cached_template();
276276
if (!wd || wd->m_hashes.empty()) return {};
277277

278-
// wd->m_hashes[0] is the coinbase placeholder — the actual coinbase
279-
// hash doesn't matter for branch computation since the miner provides
280-
// their own coinbase. We only need the structure.
281-
std::vector<uint256> level = wd->m_hashes;
278+
// PRODUCER/CONSUMER CONTRACT: wd->m_hashes is the pure tx-hash list
279+
// [tx1..txN] with NO coinbase slot (filled by rpc.cpp / template_builder.hpp
280+
// straight from GBT transactions[]). The stratum merkle tree, however, has
281+
// the coinbase as leaf 0. Prepend a placeholder leaf for it before folding
282+
// — the actual coinbase hash doesn't matter for branch computation since
283+
// the miner provides their own coinbase; we only need the leaf STRUCTURE.
284+
// Without this prepend, level[1] below would be tx2 (not tx1), tx1 would be
285+
// dropped, and the header merkle root would diverge from the serialized
286+
// body → bad-txnmrklroot rejection on any populated (>=1 tx) won block.
287+
// SSOT: branch sibling structure lives in btc::coin::stratum_merkle_siblings
288+
// (template_builder.hpp) so the merkle self-check in mining_submit and any
289+
// KAT exercise the SAME fold as the wire path. We only hex-encode here.
290+
std::vector<uint256> level = btc::coin::stratum_merkle_siblings(wd->m_hashes);
282291
std::vector<std::string> branches;
283-
while (level.size() > 1) {
292+
branches.reserve(level.size());
293+
for (const auto& sib : level) {
284294
// Right-sibling of the left-most node = level[1].
285295
// Wire encoding: hex of LE-internal bytes (NOT GetHex() which is
286296
// BE display). Matches cgminer convention + LTC's working
@@ -289,19 +299,7 @@ std::vector<std::string> BTCWorkSource::get_stratum_merkle_branches() const
289299
// the bytes on the wire MUST be the same LE-internal bytes the
290300
// pool used to build the merkle tree. GetHex() reverses them and
291301
// produces a totally different merkle root in the miner's view.
292-
branches.push_back(HexStr(std::span<const unsigned char>(level[1].data(), 32)));
293-
294-
// Ascend: place a placeholder for the next-level coinbase combo,
295-
// then hash subsequent pairs (duplicate last on odd count).
296-
std::vector<uint256> next;
297-
next.reserve((level.size() + 2) / 2);
298-
next.push_back(uint256::ZERO); // placeholder for combo of (cb, level[1])
299-
for (size_t i = 2; i < level.size(); i += 2) {
300-
const uint256& l = level[i];
301-
const uint256& r = (i + 1 < level.size()) ? level[i + 1] : level[i];
302-
next.push_back(btc::coin::merkle_hash_pair(l, r));
303-
}
304-
level = std::move(next);
302+
branches.push_back(HexStr(std::span<const unsigned char>(sib.data(), 32)));
305303
}
306304
return branches;
307305
}
@@ -761,6 +759,34 @@ nlohmann::json BTCWorkSource::mining_submit(
761759

762760
uint256 pow_hash = Hash(std::span<const uint8_t>(header.data(), header.size()));
763761

762+
// ── Non-fatal merkle self-check (producer/consumer contract guard) ──
763+
// Recompute the body merkle root over the full leaf set
764+
// [coinbase_txid, tx1..txN] and compare it to the branch-folded header
765+
// root above. If get_stratum_merkle_branches and the serialized body ever
766+
// disagree on the leaf structure (the latent bad-txnmrklroot class), this
767+
// logs a loud DIVERGENCE BEFORE the block is submitted and rejected. Uses
768+
// the current cached template's leaf set; a template roll between job
769+
// freeze and submit can produce a benign mismatch, so this is advisory
770+
// only and never rejects the share.
771+
if (auto wd_chk = cached_template()) {
772+
std::vector<uint256> body_leaves;
773+
body_leaves.reserve(wd_chk->m_hashes.size() + 1);
774+
body_leaves.push_back(coinbase_txid);
775+
body_leaves.insert(body_leaves.end(), wd_chk->m_hashes.begin(), wd_chk->m_hashes.end());
776+
uint256 body_root = btc::coin::compute_merkle_root(body_leaves);
777+
if (body_root == merkle_root) {
778+
LOG_DEBUG_OTHER << "[BTC-STRATUM] merkle self-check OK: body root matches header"
779+
<< " (" << body_leaves.size() << " leaves)";
780+
} else {
781+
LOG_WARNING << "[BTC-STRATUM] merkle self-check DIVERGENCE: header root="
782+
<< HexStr(std::span<const uint8_t>(merkle_root.data(), 32))
783+
<< " body root="
784+
<< HexStr(std::span<const uint8_t>(body_root.data(), 32))
785+
<< " over " << body_leaves.size() << " leaves"
786+
<< " — block would be rejected bad-txnmrklroot (template roll or leaf-set bug)";
787+
}
788+
}
789+
764790
// Decode share target (separate from block target — pre-this-fix we
765791
// were comparing pow_hash against block target which is network
766792
// difficulty, so every share got rejected as low-diff. The miner

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)
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)
55
target_link_libraries(btc_share_test PRIVATE
66
GTest::gtest_main GTest::gtest
77
core btc
Lines changed: 106 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,106 @@
1+
// Producer/consumer merkle-branch contract KAT for the BTC stratum work source.
2+
//
3+
// Regression guard for the latent bad-txnmrklroot defect fixed in PR #570.
4+
// BTCWorkSource::get_stratum_merkle_branches() folds wd->m_hashes (the pure
5+
// [tx1..txN] list, with NO coinbase slot) into stratum coinbase branches. The
6+
// miner rebuilds the header merkle root by folding ITS coinbase txid against
7+
// those branches; that reconstruction MUST equal
8+
// compute_merkle_root([coinbase, tx1..txN]) — the exact root the serialized
9+
// block body commits to. Pre-#570 the branch builder omitted the leaf-0
10+
// coinbase placeholder, so tx1 was dropped and every populated (>=1 tx) won
11+
// block was rejected bad-txnmrklroot.
12+
//
13+
// SSOT under test: btc::coin::stratum_merkle_siblings (template_builder.hpp),
14+
// which get_stratum_merkle_branches() now delegates to. Revert the leaf-0
15+
// prepend there and FoldReconstructsBodyRoot goes RED.
16+
17+
#include <gtest/gtest.h>
18+
19+
#include <impl/btc/coin/template_builder.hpp>
20+
#include <core/uint256.hpp>
21+
22+
#include <vector>
23+
24+
using btc::coin::compute_merkle_root;
25+
using btc::coin::merkle_hash_pair;
26+
using btc::coin::stratum_merkle_siblings;
27+
28+
namespace {
29+
30+
// Miner-side reconstruction: fold the coinbase txid against the branch siblings.
31+
uint256 fold_branches(const uint256& coinbase, const std::vector<uint256>& sibs) {
32+
uint256 acc = coinbase;
33+
for (const auto& s : sibs) acc = merkle_hash_pair(acc, s);
34+
return acc;
35+
}
36+
37+
// Body-side truth: the full merkle root committed by the serialized block over
38+
// [coinbase, tx1..txN].
39+
uint256 body_root(const uint256& coinbase, const std::vector<uint256>& txs) {
40+
std::vector<uint256> leaves;
41+
leaves.reserve(txs.size() + 1);
42+
leaves.push_back(coinbase);
43+
leaves.insert(leaves.end(), txs.begin(), txs.end());
44+
return compute_merkle_root(leaves);
45+
}
46+
47+
std::vector<uint256> make_txs(unsigned n) {
48+
std::vector<uint256> v;
49+
v.reserve(n);
50+
for (unsigned i = 0; i < n; ++i) v.push_back(uint256((uint64_t)(0x1000u + i)));
51+
return v;
52+
}
53+
54+
// The pre-#570 BUGGY branch builder: folds tx_hashes with NO coinbase leaf-0
55+
// placeholder. Replicated here only to PROVE this KAT catches the regression.
56+
std::vector<uint256> buggy_siblings(const std::vector<uint256>& tx_hashes) {
57+
if (tx_hashes.empty()) return {};
58+
std::vector<uint256> level = tx_hashes; // BUG: missing coinbase leaf-0
59+
std::vector<uint256> sibs;
60+
while (level.size() > 1) {
61+
sibs.push_back(level[1]);
62+
std::vector<uint256> next;
63+
next.push_back(uint256::ZERO);
64+
for (size_t i = 2; i < level.size(); i += 2) {
65+
const uint256& l = level[i];
66+
const uint256& r = (i + 1 < level.size()) ? level[i + 1] : level[i];
67+
next.push_back(merkle_hash_pair(l, r));
68+
}
69+
level = std::move(next);
70+
}
71+
return sibs;
72+
}
73+
74+
const uint256 COINBASE((uint64_t)0xC0FFEEull);
75+
76+
} // namespace
77+
78+
// Positive contract: production siblings reconstruct the body root for every
79+
// template size — including the single-tx (N=1) case the pre-#570 bug silently
80+
// corrupted, and odd counts (last-element duplication path).
81+
TEST(StratumMerkleBranch, FoldReconstructsBodyRoot) {
82+
for (unsigned n : {0u, 1u, 2u, 3u, 4u, 5u, 7u, 16u}) {
83+
auto txs = make_txs(n);
84+
auto sibs = stratum_merkle_siblings(txs);
85+
EXPECT_EQ(fold_branches(COINBASE, sibs), body_root(COINBASE, txs))
86+
<< "N=" << n << " branch fold diverged from body merkle root";
87+
}
88+
}
89+
90+
// Coinbase-only template yields zero branches and the coinbase IS the root.
91+
TEST(StratumMerkleBranch, CoinbaseOnlyHasNoBranches) {
92+
auto sibs = stratum_merkle_siblings(make_txs(0));
93+
EXPECT_TRUE(sibs.empty());
94+
EXPECT_EQ(fold_branches(COINBASE, sibs), COINBASE);
95+
}
96+
97+
// flip-RED proof: the pre-#570 builder (no leaf-0 placeholder) diverges from
98+
// the body root on EVERY populated template — i.e. this KAT would have caught
99+
// the bad-txnmrklroot defect.
100+
TEST(StratumMerkleBranch, PreFixBuilderDivergesOnPopulated) {
101+
for (unsigned n : {1u, 2u, 3u, 5u}) {
102+
auto txs = make_txs(n);
103+
EXPECT_NE(fold_branches(COINBASE, buggy_siblings(txs)), body_root(COINBASE, txs))
104+
<< "N=" << n << " buggy builder unexpectedly matched — guard is blind";
105+
}
106+
}

0 commit comments

Comments
 (0)