From 9276c04eba9335a3b6f3d98b432f123928d3737b Mon Sep 17 00:00:00 2001 From: frstrtr Date: Sun, 21 Jun 2026 19:28:16 +0000 Subject: [PATCH 1/3] dgb(#82): wire captured-template txs into won-block reconstruct Replace the interim coinbase-only template_other_txs_fn stub in the #82 forced-won live seam with the production capture->bridge path: - declare a run-loop-scoped dgb::coin::TemplateCapture (#300/#271) that outlives every tracker callback the provider() is installed into - feed it through make_template_other_txs_fn (#299) as the reconstruct closure template_other_txs_fn, so a won block replays the EXACT non-coinbase set the share committed to (merkle-consistent) instead of reconstructing coinbase-only - seed_forced_from_gbt captures the node-B GBT transactions[] keyed by the forced share hash; coinbasevalue already includes those fees so the regenerated gentx subsidy stays balanced A capture MISS still decodes to an empty set -> a valid coinbase-only block (never fail-closed). Per-coin: src/impl/dgb + main_dgb only. --- src/c2pool/main_dgb.cpp | 26 ++++++++++++++++++++++---- 1 file changed, 22 insertions(+), 4 deletions(-) diff --git a/src/c2pool/main_dgb.cpp b/src/c2pool/main_dgb.cpp index fcc10c407..853309b7f 100644 --- a/src/c2pool/main_dgb.cpp +++ b/src/c2pool/main_dgb.cpp @@ -34,6 +34,8 @@ #include // make_mempool_tx_source (EmbeddedTxSource) #include #include // make_reconstruct_closure_from_template (#280) +#include // TemplateCapture per-job retain (#300/#271) +#include // make_template_other_txs_fn bridge (#299) #include // finalize_won_block_pow -- forced-won PoW grind JOINT (#82 gate) #include // c2pool::dgb::compact_to_target (nBits -> u256) #include // won_share_inputs (#279) @@ -356,6 +358,14 @@ int run_node(const core::CoinParams& params, bool testnet, // self-deadlock — the corrected consume-seam audit). Fail-closed end to // end: any error in a builder fn throws, is caught inside the closure -> // std::nullopt (announce + audit; the RPC submitblock fallback still fires). + // #82 tx-bearing won block: retain each handed-out template's + // transactions[] keyed by the resulting share_hash so the reconstructor + // replays the EXACT non-coinbase set the share committed to (merkle- + // consistent), replacing the interim coinbase-only stub. Plain local: + // outlives every tracker callback the provider() is installed into + // (all bound within this main scope, before ioc.run()). + dgb::coin::TemplateCapture template_capture; + auto& reconstruct_tracker = p2p_node.tracker(); auto faithful_reconstruct = dgb::coin::make_reconstruct_closure_from_template( /*won_share_fields_fn=*/ @@ -386,9 +396,10 @@ int run_node(const core::CoinParams& params, bool testnet, return gc.bytes; }, /*template_other_txs_fn=*/ - [](const uint256&) -> std::vector { - return {}; // coinbase-only today (see note above) - }); + // #300/#299: replay the captured per-job template transactions[] + // keyed by share_hash (TemplateCapture above feeds it at seed time); + // a capture MISS decodes to an empty set -> valid coinbase-only block. + dgb::coin::make_template_other_txs_fn(template_capture.provider())); // -- #82 forced-won PoW finalize ------------------------------------- // A forced-won share (the regtest --regtest-force-won-share live seam) @@ -714,7 +725,7 @@ int run_node(const core::CoinParams& params, bool testnet, // reconstruct closure frames it; --soak-regrind then grinds the // nonce to satisfy THIS bits. Regtest-only; coinbase-only => // merkle root == gentx txid by construction (no bad-txnmrklroot). - auto seed_forced_from_gbt = [forced, &rpc, &header_chain]() { + auto seed_forced_from_gbt = [forced, forced_hash, &rpc, &template_capture, &header_chain]() { if (!rpc) { // Unreachable on the gated path (--regtest-force-won-share // requires --coin-daemon, which arms rpc); without the GBT @@ -727,6 +738,13 @@ int run_node(const core::CoinParams& params, bool testnet, } try { auto gbt = rpc->getblocktemplate({"segwit"}); + // #82 tx-bearing: retain this template transactions[] + // under the forced share hash so the won-block + // reconstructor replays them (coinbasevalue already + // includes their fees -> subsidy/merkle consistent). + template_capture.capture( + forced_hash, + gbt.value("transactions", nlohmann::json::array())); auto& mh = forced->m_min_header; mh.m_version = gbt.at("version").get(); mh.m_previous_block.SetHex( From b91facef844c26c4b200076eef2f2b9e28e6a602 Mon Sep 17 00:00:00 2001 From: frstrtr Date: Sun, 21 Jun 2026 20:45:41 +0000 Subject: [PATCH 2/3] dgb(#82): recompute won-block header merkle_root over the actual tx vector The tx-bearing reconstruct path (reconstruct_won_block_from_template) sources its body from the captured GBT template, but assemble_won_block derived the header merkle_root by walking the share merkle_link (check_merkle_link). That link only spans the txs it was built over, so a coinbase-only link with a funded tx in the body emitted a header root for the coinbase-only set -> daemon rejects bad-txnmrklroot (the #82 funded multi-tx soak FAIL on both arms). Recompute the root over [gentx_hash] ++ each other-tx txid via a canonical BuildMerkleRoot (build_block_merkle_root), set before serialize so the nonce-grind seam grinds the final header. Coinbase-only is unchanged (root == gentx_hash). +1 regression KAT; corrected Test 4 which had enshrined the buggy coinbase-only root with 2 other_txs. 8/8 green. --- src/impl/dgb/coin/block_assembly.hpp | 43 +++++++++++++++++++++++ src/impl/dgb/test/block_assembly_test.cpp | 39 +++++++++++++++++++- 2 files changed, 81 insertions(+), 1 deletion(-) diff --git a/src/impl/dgb/coin/block_assembly.hpp b/src/impl/dgb/coin/block_assembly.hpp index bd84f7585..c344e022b 100644 --- a/src/impl/dgb/coin/block_assembly.hpp +++ b/src/impl/dgb/coin/block_assembly.hpp @@ -45,6 +45,7 @@ #include "block.hpp" #include "../share_check.hpp" // dgb::check_merkle_link (SSOT merkle-branch walk) #include "../share_types.hpp" // dgb::MerkleLink +#include "mempool.hpp" // dgb::coin::compute_txid (block-body txid SSOT) namespace dgb { @@ -72,6 +73,28 @@ reconstruct_block_header(const SmallBlockHeaderType& small_header, return header; } +// Build the canonical Bitcoin block merkle root over an ordered txid list +// ([gentx_hash] ++ other-tx txids): duplicate-the-last on an odd count, then +// sha256d each adjacent pair up to the single root. This is the BLOCK-BODY +// SSOT for the header merkle_root -- it MUST be recomputed over the txs the +// block actually carries, NOT walked up the share's merkle_link, or a +// tx-bearing reconstruct emits a header root for the coinbase-only set and the +// daemon rejects the block with bad-txnmrklroot (#82 funded-soak regression). +inline uint256 build_block_merkle_root(std::vector txids) +{ + if (txids.empty()) return uint256(); + while (txids.size() > 1) { + if (txids.size() % 2 == 1) + txids.push_back(txids.back()); + std::vector next; + next.reserve(txids.size() / 2); + for (size_t i = 0; i + 1 < txids.size(); i += 2) + next.push_back(Hash(txids[i], txids[i + 1])); + txids = std::move(next); + } + return txids[0]; +} + // Assemble the full serialized parent block for a won share. // small_header : share.m_min_header (version|prev|time|bits|nonce) // gentx : the reconstructed coinbase transaction (block tx 0) @@ -105,6 +128,26 @@ assemble_won_block(const SmallBlockHeaderType& small_header, for (const auto& tx : other_txs) block.m_txs.push_back(tx); + // Recompute the header merkle_root over the ACTUAL block tx vector + // ([gentx_hash] ++ each other-tx txid), overriding the merkle_link walk + // reconstruct_block_header did. The share's merkle_link only spans the txs + // it was built over; when the reconstruct sources its body from the captured + // GBT template (reconstruct_won_block_from_template) that link can reflect a + // different (coinbase-only) set, so trusting it emits a header root that + // disagrees with the body -> daemon bad-txnmrklroot. Deriving the root from + // the body keeps header and body consistent by construction (coinbase-only: + // build_block_merkle_root([gentx_hash]) == gentx_hash, identical to the + // empty-branch walk -- no regression). Set BEFORE serialize so the + // nonce-grind seam (regrind_block.hpp) grinds the final, correct header. + { + std::vector txids; + txids.reserve(block.m_txs.size()); + txids.push_back(gentx_hash); + for (const auto& tx : other_txs) + txids.push_back(compute_txid(tx)); + block.m_merkle_root = build_block_merkle_root(std::move(txids)); + } + PackStream packed = pack(block); auto sp = packed.get_span(); std::vector bytes( diff --git a/src/impl/dgb/test/block_assembly_test.cpp b/src/impl/dgb/test/block_assembly_test.cpp index 28ac0d0fd..1c19fdc92 100644 --- a/src/impl/dgb/test/block_assembly_test.cpp +++ b/src/impl/dgb/test/block_assembly_test.cpp @@ -180,7 +180,16 @@ TEST(DgbBlockAssembly, AssembleRoundTripsCoinbaseFirst) EXPECT_EQ(blk.m_timestamp, sh.m_timestamp); EXPECT_EQ(blk.m_bits, sh.m_bits); EXPECT_EQ(blk.m_nonce, sh.m_nonce); - EXPECT_EQ(blk.m_merkle_root, gtx_hash); + // header merkle_root is recomputed over the ACTUAL block tx vector + // ([gentx_hash] ++ other-tx txids), NOT the (empty here) share merkle_link: + // with two other_txs it must differ from the coinbase-only root and equal + // build_block_merkle_root over the real leaves (#82 bad-txnmrklroot fix). + { + std::vector leaves = { gtx_hash, + dgb::coin::compute_txid(other[0]), dgb::coin::compute_txid(other[1]) }; + EXPECT_EQ(blk.m_merkle_root, dgb::coin::build_block_merkle_root(leaves)); + EXPECT_NE(blk.m_merkle_root, gtx_hash); + } // txs = [gentx] ++ other_txs : coinbase at index 0, total = 1 + 2. ASSERT_EQ(blk.m_txs.size(), 3u); @@ -295,4 +304,32 @@ TEST(DgbBlockAssembly, LegacyGentxEmitsLegacyBlock) EXPECT_EQ(hex, hex2); } +// --- Test 8: tx-bearing block header root == body root, != coinbase-only ------ +// Regression lock for the #82 funded multi-tx gate: a reconstructed block that +// carries 1+ non-coinbase txs MUST publish a header merkle_root computed over +// [gentx]++other_txs, so a peer/daemon validating the body accepts it. The +// pre-fix path walked the share merkle_link (empty == coinbase-only => gentx_hash) +// and the daemon rejected bad-txnmrklroot. +TEST(DgbBlockAssembly, TxBearingRootMatchesBodyNotMerkleLink) +{ + auto sh = make_small_header(); + auto gentx = make_gentx(); + auto gtx_hash = fixed_gentx_hash(); + ::dgb::MerkleLink link; // empty link == the coinbase-only branch + std::vector other = { make_tx(42) }; + + auto [bytes, hex] = assemble_won_block(sh, gentx, gtx_hash, link, other); + PackStream ps(bytes); + BlockType blk; + ps >> blk; + + ASSERT_EQ(blk.m_txs.size(), 2u); + const uint256 body_root = dgb::coin::build_block_merkle_root( + { gtx_hash, dgb::coin::compute_txid(other[0]) }); + EXPECT_EQ(blk.m_merkle_root, body_root); + // the empty merkle_link would have yielded gentx_hash -- prove we did NOT. + EXPECT_NE(blk.m_merkle_root, gtx_hash); + EXPECT_NE(body_root, gtx_hash); +} + } // namespace From b056beabfc9d34ada13d9961746953a45d537a32 Mon Sep 17 00:00:00 2001 From: frstrtr Date: Sun, 21 Jun 2026 21:08:48 +0000 Subject: [PATCH 3/3] dgb(#82): tx-bearing funded dual-path won-block soak harness Fail-closed regtest soak proving a won block carrying a non-coinbase funded legacy tx is reconstructed (#300/#302/#303) and ACCEPTED by a peer down BOTH arms (P2P primary + submitblock isolated). Asserts NTX>=2 and funded-txid presence per arm via getrawmempool/getblock; die() aborts on any miss (no getrawtransaction-without-txindex false-green path). --- scripts/dgb_regtest_won_block_soak_funded.sh | 185 +++++++++++++++++++ 1 file changed, 185 insertions(+) create mode 100755 scripts/dgb_regtest_won_block_soak_funded.sh diff --git a/scripts/dgb_regtest_won_block_soak_funded.sh b/scripts/dgb_regtest_won_block_soak_funded.sh new file mode 100755 index 000000000..5fc91201f --- /dev/null +++ b/scripts/dgb_regtest_won_block_soak_funded.sh @@ -0,0 +1,185 @@ +#!/usr/bin/env bash +# dgb_regtest_won_block_soak_funded.sh -- DGB #82 TX-BEARING won-block dual-path soak. +# +# Sibling of dgb_regtest_won_block_soak.sh. That script proves PROP-1 (coinbase-only +# is ACCEPTED, not a silent malformed-block drop). This script proves the stronger, +# #82-closeable bar from integrator (UID1801/UID1817): a won block carrying a +# NON-coinbase tx must be reconstructed (#300 per-job template-capture + #302 +# merkle-link + #303 captured-tx wiring) and ACCEPTED by a peer down BOTH arms. +# +# ASSERT: won block on node B has NTX >= 2 (coinbase + >=1 funded tx), both arms. +# +# BIP141 ISOLATION (legacy txs first, per integrator): the maturity coinbases AND +# the funded spend both use LEGACY (P2PKH) addresses, so the funded tx carries NO +# witness data. This isolates plain tx-inclusion + merkle-path correctness from the +# segwit witness-commitment path; a segwit-tx variant is a follow-up only after this +# legacy gate is green. +# +# PER-COIN ISOLATION: DGB only. Localhost-only. Self-service creds. Ports outside the +# bitcoin-family 1844x regtest band. +set -euo pipefail + +CLI_BIN="${DGB_CLI:-digibyte-cli}" +DAEMON_BIN="${DGB_DAEMON:-digibyted}" +C2POOL_DGB="${C2POOL_DGB:-c2pool-dgb}" + +DATADIR_A="${DGB_REGTEST_DATADIR:-$HOME/.c2pool-dgb-regtest-funded}" +DATADIR_B="${DATADIR_A}-peer" +RPCPORT_A=${DGB_RPCPORT_A:-18863} +RPCPORT_B=${DGB_RPCPORT_B:-18873} +P2PPORT_A=${DGB_P2PPORT_A:-18864} +P2PPORT_B=${DGB_P2PPORT_B:-18874} +PAYOUT_LABEL="c2pool-soak-funded" + +log() { echo "[dgb-funded-soak $(printf "%(%H:%M:%S)T")] $*" >&2; } +die() { echo "[dgb-funded-soak FAIL] $*" >&2; exit 1; } +need() { command -v "$1" >/dev/null 2>&1 || die "missing binary: $1"; } + +cli_a() { "$CLI_BIN" -regtest -datadir="$DATADIR_A" -rpcport=$RPCPORT_A "$@"; } +cli_b() { "$CLI_BIN" -regtest -datadir="$DATADIR_B" -rpcport=$RPCPORT_B "$@"; } + +C2POOL_PID="" +cleanup() { + [ -n "$C2POOL_PID" ] && kill "$C2POOL_PID" >/dev/null 2>&1 || true + cli_a stop >/dev/null 2>&1 || true + cli_b stop >/dev/null 2>&1 || true +} +trap cleanup EXIT + +need "$DAEMON_BIN"; need "$CLI_BIN"; need "$C2POOL_DGB"; need openssl + +provision() { + local dd="$1" rpcport="$2" + # fresh datadir each run -> predictable heights, no mempool/chain carryover + rm -rf "$dd" + mkdir -p "$dd"; chmod 700 "$dd" + if [ ! -f "$dd/digibyte.conf" ]; then + local pass; pass="$(openssl rand -hex 24)" + cat > "$dd/digibyte.conf" </dev/null 2>&1; do n=$((n+1)); [ $n -gt 60 ] && die "rpc never came up: $*"; sleep 0.5; done; } +tip_b() { cli_b getblockcount; } +mempool_a() { cli_a getmempoolinfo | grep -o '"size"[^,]*' | grep -o '[0-9]\+'; } + +ntx_of() { # ntx_of -> number of txs in block + local fn="$1" h="$2" + "$fn" getblock "$h" | tr -d '\n' | grep -o '"tx"[^]]*]' | grep -o '"[0-9a-f]\{64\}"' | wc -l +} + +kill_stale_c2pool() { + local pids; pids="$(pgrep -f 'c2pool-dgb .*--run' 2>/dev/null || true)" + [ -z "$pids" ] && return 0 + log "pre-flight: freeing pool P2P port from stale c2pool-dgb: $pids" + kill $pids 2>/dev/null || true; sleep 2 + pids="$(pgrep -f 'c2pool-dgb .*--run' 2>/dev/null || true)" + [ -n "$pids" ] && { kill -9 $pids 2>/dev/null || true; sleep 1; } + return 0 +} +kill_stale_c2pool + +# --- substrate: two peered regtest nodes -------------------------------------- +log "starting node A (regtest RPC $RPCPORT_A / P2P $P2PPORT_A)" +"$DAEMON_BIN" -regtest -dandelion=0 -datadir="$DATADIR_A" -rpcport=$RPCPORT_A -port=$P2PPORT_A -daemon >/dev/null +wait_rpc cli_a +log "starting node B (regtest RPC $RPCPORT_B / P2P $P2PPORT_B), peering to A" +"$DAEMON_BIN" -regtest -dandelion=0 -datadir="$DATADIR_B" -rpcport=$RPCPORT_B -port=$P2PPORT_B \ + -connect=127.0.0.1:$P2PPORT_A -daemon >/dev/null +wait_rpc cli_b + +cli_a createwallet "$PAYOUT_LABEL" >/dev/null 2>&1 || cli_a loadwallet "$PAYOUT_LABEL" >/dev/null 2>&1 || true +# LEGACY (P2PKH) coinbase + spend -> funded tx carries no witness (BIP141 isolated). +ADDR_A="$(cli_a getnewaddress "$PAYOUT_LABEL" legacy)" +# Mine 110 so blocks 1..10 coinbases are mature (spendable) -> enough legacy UTXOs +# to fund a distinct tx for each arm without a 100-block re-wait between arms. +log "mining 110 maturity blocks to legacy addr $ADDR_A" +cli_a generatetoaddress 110 "$ADDR_A" >/dev/null +HEIGHT_A="$(cli_a getblockcount)" +n=0; until [ "$(tip_b)" -ge "$HEIGHT_A" ]; do + n=$((n+1)); [ $n -gt 120 ] && die "node B never synced to A height $HEIGHT_A (got $(tip_b))"; sleep 0.5 +done + +DGB_REGTEST_GENESIS="$(cli_a getblockhash 0)" +: "${DGB_REGTEST_MAGIC:=fabfb5da}" + +# fund_mempool_legacy -- broadcast ONE legacy (non-witness) tx into node A mempool +# and confirm it landed (via getrawmempool, unambiguous), so the c2pool +# getblocktemplate capture includes it. Sets global FUNDED_TXID. NOTE: must run in +# the PARENT shell (NOT a $() subshell) so a die() actually aborts the soak. +FUNDED_TXID="" +fund_mempool_legacy() { + local dest amt + dest="$(cli_a getnewaddress funded legacy)" + amt="1.0" + local sendout; sendout="$(cli_a sendtoaddress "$dest" "$amt" 2>&1)" || true + FUNDED_TXID="$sendout" + log "DEBUG send dest=$dest -> [$sendout]" + # NB: no `cli | grep -q` -- grep -q closes the pipe early, SIGPIPEs cli, and under + # `set -o pipefail` the pipeline reports failure even on a match. Capture + match. + local n=0 mp; until mp="$(cli_a getrawmempool 2>&1)"; [[ "$mp" == *"$FUNDED_TXID"* ]]; do + n=$((n+1)); [ $n -gt 40 ] && { log "DEBUG rawmempool=[$mp] mempoolinfo=[$(cli_a getmempoolinfo 2>&1 | tr '\n' ' ')] balance=[$(cli_a getbalance 2>&1)]"; die "funded tx $FUNDED_TXID never appeared in node A mempool"; }; sleep 0.25 + done + # legacy/no-witness assertion via raw-tx serialization: a segwit tx carries + # the BIP144 marker+flag (0001) immediately after the 4-byte version; a legacy + # tx has its input-count varint there instead. (Core removed getmempoolentry's + # "size" field, so the old size==vsize discriminator is no longer queryable.) + local rawhex marker + rawhex="$(cli_a getrawtransaction "$FUNDED_TXID")" + marker="${rawhex:8:4}" + [ "$marker" != "0001" ] || die "funded tx $FUNDED_TXID carries a witness (marker=$marker) -- BIP141 isolation broken" + log "funded legacy tx $FUNDED_TXID in node A mempool (no-witness marker ok); mempool size=$(mempool_a)" +} + +# run_arm -- runs in the PARENT shell (no $() capture) so die() aborts. Sets global +# ARM_WON to the accepted block hash. +ARM_WON="" +run_arm() { # run_arm