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
185 changes: 185 additions & 0 deletions scripts/dgb_regtest_won_block_soak_funded.sh
Original file line number Diff line number Diff line change
@@ -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" <<CONF
regtest=1
server=1
rpcuser=c2pool
rpcpassword=$pass
fallbackfee=0.01
dandelion=0
[regtest]
rpcport=$rpcport
CONF
chmod 600 "$dd/digibyte.conf"
fi
}
provision "$DATADIR_A" "$RPCPORT_A"
provision "$DATADIR_B" "$RPCPORT_B"

wait_rpc() { local n=0; until "$@" getblockcount >/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 <cli_fn> <blockhash> -> 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 <label> <extra c2pool flags> <logfile>
local label="$1" extra="$2" logf="$3" base won ntx
base="$(tip_b)"
fund_mempool_legacy
log "ARM $label: arming c2pool-dgb (funded mempool has the legacy tx) $extra"
"$C2POOL_DGB" --run \
--coin-daemon 127.0.0.1:$P2PPORT_A \
--coin-rpc 127.0.0.1:$RPCPORT_A \
--coin-rpc-auth "$DATADIR_A/digibyte.conf" \
--coin-magic "$DGB_REGTEST_MAGIC" \
--coin-genesis "$DGB_REGTEST_GENESIS" --regtest --regtest-force-won-share \
--soak-regrind $extra >"$logf" 2>&1 &
C2POOL_PID=$!
log "ARM $label: c2pool-dgb PID $C2POOL_PID; waiting for tx-bearing won block on node B"
local n=0
until [ "$(tip_b)" -gt "$base" ]; do
n=$((n+1)); [ $n -gt 240 ] && die "ARM $label: no won block reached node B within 120s"
sleep 0.5
done
won="$(cli_b getblockhash "$(tip_b)")"
ntx="$(ntx_of cli_b "$won")"
[ "$ntx" -ge 2 ] || die "ARM $label: won block $won has $ntx txs, expected >=2 (coinbase + funded legacy tx) -- captured-template tx-wire (#300/#302/#303) did not carry the mempool tx into the reconstructed block"
local blk; blk="$(cli_b getblock "$won")"
[[ "$blk" == *"$FUNDED_TXID"* ]] \
|| die "ARM $label: funded tx $FUNDED_TXID not present in accepted block $won"
log "ARM $label OK: tx-bearing won block $won ($ntx txs incl funded $FUNDED_TXID) ACCEPTED by peer node B"
kill "$C2POOL_PID" >/dev/null 2>&1; C2POOL_PID=""
ARM_WON="$won"
}

run_arm "A (P2P primary)" "" /tmp/c2pool-dgb-funded-soak-arma.log
WON_A="$ARM_WON"
run_arm "B (submitblock isolated)" "--no-p2p-relay" /tmp/c2pool-dgb-funded-soak-armb.log
WON_B="$ARM_WON"

log "BOTH ARMS PROVEN (TX-BEARING): ARM A (P2P relay) block $WON_A + ARM B (submitblock isolated) block $WON_B each carry a funded legacy tx and were ACCEPTED by peer node B. #82 tx-bearing dual-path gate satisfied."
echo "FUNDED_SOAK_RESULT armA=$WON_A armB=$WON_B"
26 changes: 22 additions & 4 deletions src/c2pool/main_dgb.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,8 @@
#include <impl/dgb/coin/embedded_tx_select.hpp> // make_mempool_tx_source (EmbeddedTxSource)
#include <impl/dgb/coin/won_block_dispatch.hpp>
#include <impl/dgb/coin/reconstruct_closure.hpp> // make_reconstruct_closure_from_template (#280)
#include <impl/dgb/coin/template_capture.hpp> // TemplateCapture per-job retain (#300/#271)
#include <impl/dgb/coin/template_other_txs.hpp> // make_template_other_txs_fn bridge (#299)
#include <impl/dgb/coin/won_block_finalize.hpp> // finalize_won_block_pow -- forced-won PoW grind JOINT (#82 gate)
#include <impl/dgb/coin/header_sample_build.hpp> // c2pool::dgb::compact_to_target (nBits -> u256)
#include <impl/dgb/coin/won_share_inputs.hpp> // won_share_inputs (#279)
Expand Down Expand Up @@ -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=*/
Expand Down Expand Up @@ -386,9 +396,10 @@ int run_node(const core::CoinParams& params, bool testnet,
return gc.bytes;
},
/*template_other_txs_fn=*/
[](const uint256&) -> std::vector<dgb::coin::MutableTransaction> {
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)
Expand Down Expand Up @@ -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
Expand All @@ -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<uint64_t>();
mh.m_previous_block.SetHex(
Expand Down
43 changes: 43 additions & 0 deletions src/impl/dgb/coin/block_assembly.hpp
Original file line number Diff line number Diff line change
Expand Up @@ -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
{
Expand Down Expand Up @@ -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<uint256> txids)
{
if (txids.empty()) return uint256();
while (txids.size() > 1) {
if (txids.size() % 2 == 1)
txids.push_back(txids.back());
std::vector<uint256> 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)
Expand Down Expand Up @@ -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<uint256> 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<BlockType>(block);
auto sp = packed.get_span();
std::vector<unsigned char> bytes(
Expand Down
39 changes: 38 additions & 1 deletion src/impl/dgb/test/block_assembly_test.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -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<uint256> 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);
Expand Down Expand Up @@ -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<MutableTransaction> 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
Loading