diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 297d7891a..9dd650612 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -67,8 +67,9 @@ jobs: test_phase4_embedded \ test_mweb_builder \ test_address_resolution test_compute_share_target \ - test_utxo test_dgb_subsidy dgb_share_test dgb_block_assembly_test dgb_gentx_coinbase_test \ - nmc_auxpow_merkle_test \ + test_utxo test_dgb_subsidy dgb_share_test dgb_block_assembly_test \ + dgb_gentx_coinbase_test nmc_auxpow_merkle_test dgb_gentx_share_path_test dgb_other_tx_resolver_test \ + dgb_other_tx_assembler_test dgb_reconstruct_won_block_test dgb_gentx_unpack_test \ rpc_request_test softfork_check_test genesis_check_test algo_select_test digishield_walk_test header_chain_test \ v37_test \ -j$(nproc) @@ -198,8 +199,9 @@ jobs: test_phase4_embedded \ test_mweb_builder \ test_address_resolution test_compute_share_target \ - test_utxo test_dgb_subsidy dgb_share_test dgb_block_assembly_test dgb_gentx_coinbase_test \ - nmc_auxpow_merkle_test \ + test_utxo test_dgb_subsidy dgb_share_test dgb_block_assembly_test \ + dgb_gentx_coinbase_test nmc_auxpow_merkle_test dgb_gentx_share_path_test dgb_other_tx_resolver_test \ + dgb_other_tx_assembler_test dgb_reconstruct_won_block_test dgb_gentx_unpack_test \ rpc_request_test softfork_check_test genesis_check_test algo_select_test digishield_walk_test header_chain_test \ test_coin_broadcaster test_multiaddress_pplns test_pplns_stress \ v37_test \ diff --git a/src/impl/dgb/coin/gentx_unpack.hpp b/src/impl/dgb/coin/gentx_unpack.hpp new file mode 100644 index 000000000..2c4753779 --- /dev/null +++ b/src/impl/dgb/coin/gentx_unpack.hpp @@ -0,0 +1,93 @@ +#pragma once +// --------------------------------------------------------------------------- +// dgb::coin::unpack_gentx_coinbase -- the INVERSE of #173's GentxCoinbase +// exposure: turn the SSOT non-witness gentx bytes that +// generate_share_transaction surfaces (out_gentx, GentxCoinbase{bytes, txid}) +// back into a deserialized MutableTransaction, ready to inject at block tx +// index 0 of the won-block reconstructor (reconstruct_won_block.hpp). +// +// reconstruct_won_block() takes the gentx as an already-deserialized +// MutableTransaction + its gentx_hash; in the run-loop those come from the +// share's own SSOT coinbase (gentx_coinbase.hpp assemble_gentx_coinbase, the +// single wire layout consumed by both emission and verification). This slice +// is the codec step between the two -- deliberately kept OUT of the pure +// composition body (reconstruct_won_block.hpp header note) so the as_block +// ordering + merkle math stay build-verifiable on injected inputs, while the +// byte<->object round-trip is pinned by its own KAT here. +// +// CRITICAL (integrator 2026-06-19): the gentx is a NON-WITNESS coinbase and +// MUST round-trip its non-witness bytes EXACTLY -- the txid (double-SHA256 of +// the non-witness serialization == p2pool gentx_hash) is what the merkle_root +// walk in assemble_won_block consumes, so any witness/serialization drift here +// corrupts the assembled block's merkle root and the daemon rejects it. We +// therefore unpack with TX_NO_WITNESS: the coinbase vin count is a non-zero +// 0x01 (never the 0x00 segwit dummy), so this parses the body unambiguously +// and CANNOT consume a witness marker/flag or attach a witness stack. The +// resulting MutableTransaction has empty witness stacks (HasWitness()==false), +// so re-serializing it -- even inside assemble_won_block's TX_WITH_WITNESS +// BlockType codec -- emits the identical non-witness bytes and a stable txid. +// +// Failure posture mirrors the sibling reconstructor slices: trailing bytes +// after a complete tx mean the input is not a clean gentx serialization; we +// throw std::out_of_range rather than silently accept a truncated/over-long +// blob, since a wrong coinbase hashes to the wrong merkle root. +// +// Per-coin isolation: src/impl/dgb/ only. p2pool-merged-v36 surface: NONE -- +// this is the inverse of an already-oracle-pinned serializer; no share format, +// PoW, coinbase commitment, or PPLNS math is touched. +// --------------------------------------------------------------------------- + +#include +#include +#include + +#include +#include +#include + +#include "transaction.hpp" // dgb::coin::MutableTransaction, TX_NO_WITNESS + +namespace dgb +{ +namespace coin +{ + +// The deserialized gentx ready for injection at block tx index 0. +// tx : MutableTransaction reconstructed from the non-witness bytes +// txid : double-SHA256(non-witness serialization) == p2pool gentx_hash +struct UnpackedGentx +{ + MutableTransaction tx; + uint256 txid; +}; + +// Inverse of assemble_gentx_coinbase (#173 exposure): non-witness gentx bytes +// -> {MutableTransaction, txid}. Throws std::out_of_range if the input carries +// trailing bytes past a complete transaction (malformed gentx serialization). +inline UnpackedGentx +unpack_gentx_coinbase(const std::vector& gentx_bytes) +{ + PackStream ps(gentx_bytes); + + MutableTransaction tx; + // Non-witness parse: the coinbase vin count is 0x01 (never the segwit + // 0x00 dummy), so this reads version|vin|vout|locktime with no witness + // branch -- guaranteeing the empty-witness, byte-exact round-trip the + // merkle_root walk depends on. + UnserializeTransaction(tx, ps, TX_NO_WITNESS); + + if (!ps.empty()) + throw std::out_of_range( + "unpack_gentx_coinbase: trailing bytes after gentx -- " + "input is not a clean non-witness coinbase serialization"); + + UnpackedGentx out; + // txid = SHA256d of the (canonical) non-witness re-serialization; equals + // the gentx_hash assemble_gentx_coinbase computed over the same layout. + out.txid = Hash(pack(TX_NO_WITNESS(tx)).get_span()); + out.tx = std::move(tx); + return out; +} + +} // namespace coin +} // namespace dgb diff --git a/src/impl/dgb/coin/other_tx_assembler.hpp b/src/impl/dgb/coin/other_tx_assembler.hpp new file mode 100644 index 000000000..8eb2d7aae --- /dev/null +++ b/src/impl/dgb/coin/other_tx_assembler.hpp @@ -0,0 +1,89 @@ +#pragma once +// --------------------------------------------------------------------------- +// dgb::coin::assemble_other_txs -- the won-block reconstructor's (#82) bridge +// between the ORDERED other_tx hash list (resolve_other_tx_hashes, sub-slice 1) +// and the deserialized MutableTransaction vector that assemble_won_block +// (block_assembly.hpp) frames as txs = [gentx] ++ other_txs. +// +// Faithful C++ port of the known_txs lookup inside p2pool data.py +// Share.as_block(tracker, known_txs): +// +// other_txs = [known_txs[h] for h in transaction_hashes] +// +// resolve_other_tx_hashes already produced `transaction_hashes` (the ref-walk +// over ancestor new_transaction_hashes, in block other_txs order); this slice +// is the remaining `known_txs[h]` indexing. Each hash is resolved through an +// INJECTED lookup (same seam-first decomposition as other_tx_resolver.hpp / +// won_block_dispatch.hpp), so the bridge is unit-testable against a synthetic +// known-tx set without standing up a live mempool / ShareTracker. In the +// run-loop known_txs_fn binds to the node's known-transaction store (mempool + +// peer-relayed tx cache), the same set share verification populated. +// +// Failure mode (integrator 2026-06-19, mirroring sub-slice 1): a missing hash +// is a malformed/incomplete-reconstruction condition -- p2pool's known_txs[h] +// raises KeyError. We throw std::out_of_range rather than skip the tx or emit +// a placeholder: a block with a dropped/wrong other_tx has the wrong merkle +// root and is rejected by the daemon, so failing loudly here is strictly safer +// than shipping a malformed block to the network. +// +// Witness framing is NOT decided here. assemble_other_txs returns the txs +// verbatim (whatever witness stacks they carry); the block's witness SHAPE is +// governed downstream by assemble_won_block's TX_WITH_WITNESS conditional +// serializer, which emits the segwit marker/flag iff some tx HasWitness(). +// DGB is segwit-active (share_types.hpp SEGWIT_ACTIVATION_VERSION, v36 >= it), +// so that path is LIVE -- a witness-bearing other_tx (or gentx) yields a +// segwit block, exactly as the daemon's submitblock codec expects. +// +// Per-coin isolation: src/impl/dgb/ only. p2pool-merged-v36 surface: NONE -- +// this only re-reads transactions already validated/relayed into known_txs; no +// share format, PoW, coinbase commitment, or PPLNS math is touched. +// --------------------------------------------------------------------------- + +#include +#include +#include + +#include + +#include "transaction.hpp" // dgb::coin::MutableTransaction + +namespace dgb +{ +namespace coin +{ + +// Resolve an ordered other_tx hash list to the deserialized transactions that +// assemble_won_block frames after the gentx. +// +// other_tx_hashes : output of resolve_other_tx_hashes, in block other_txs +// order (= share transaction_hash_refs order) +// known_txs_fn : hash -> pointer to the known MutableTransaction, or +// nullptr if the hash is not in the known-tx set +// +// Returns the transactions in the SAME order as other_tx_hashes (order is +// consensus-relevant: it fixes the block merkle root). Throws std::out_of_range +// if any hash is unknown -- a block with a missing other_tx would hash wrong and +// be rejected, so the reconstruction must fail loudly rather than emit it. +inline std::vector +assemble_other_txs( + const std::vector& other_tx_hashes, + const std::function& known_txs_fn) +{ + std::vector out; + out.reserve(other_tx_hashes.size()); + + for (const auto& h : other_tx_hashes) + { + const MutableTransaction* tx = known_txs_fn(h); + if (tx == nullptr) + throw std::out_of_range( + "assemble_other_txs: other_tx hash not present in known_txs -- " + "cannot reconstruct a complete won block"); + out.push_back(*tx); + } + + return out; +} + +} // namespace coin +} // namespace dgb diff --git a/src/impl/dgb/coin/other_tx_resolver.hpp b/src/impl/dgb/coin/other_tx_resolver.hpp new file mode 100644 index 000000000..31120b62c --- /dev/null +++ b/src/impl/dgb/coin/other_tx_resolver.hpp @@ -0,0 +1,102 @@ +#pragma once +// --------------------------------------------------------------------------- +// dgb::coin::resolve_other_tx_hashes -- the won-block reconstructor's (#82) +// connecting tissue between a share's transaction_hash_refs and the ordered +// other_tx hash list that assemble_won_block (block_assembly.hpp) needs. +// +// Faithful C++ port of the ancestry resolution inside p2pool data.py +// Tracker.get_other_tx_hashes(share): +// +// parents_needed = max(ref_share_count for ref_share_count, _ in +// share.share_info['transaction_hash_refs']) ... +// other_tx_hashes = [ +// self.items[ self.get_nth_parent_hash(share.hash, ref_share_count) ] +// .share_info['new_transaction_hashes'][ref_tx_count] +// for ref_share_count, ref_tx_count in +// share.share_info['transaction_hash_refs'] +// ] +// +// A transaction_hash_ref is a (share_count, tx_count) pair: walk back +// `share_count` generations from the won share (share_count == 0 is the won +// share ITSELF, matching get_nth_parent_hash(h, 0) == h), then index `tx_count` +// into THAT ancestor's new_transaction_hashes. The result preserves ref order, +// which is the block's other_txs order ([gentx] ++ other_txs in as_block). +// +// Why a ref-walk and NOT a flat known_txs map (integrator 2026-06-19): the +// share format stores ancestry as back-references into ancestor shares' +// new_transaction_hashes (V34+ never embeds txs), so the ONLY faithful +// resolution is to re-walk that ancestry through the tracker. A flat +// known_txs[hash] lookup would resolve the same hashes by accident on the +// happy path but diverges the moment two ancestors announce the same txid or +// a ref points past the locally-known set -- it cannot reproduce how the +// sharechain actually addresses a tx. +// +// The two tracker operations are INJECTED as callables (same seam-first +// decomposition as won_block_dispatch.hpp / WonBlockReconstructor) so the walk +// is unit-testable against a synthetic ancestry without standing up a live +// ShareTracker. In the run-loop these bind to: +// nth_parent_fn = chain.get_nth_parent_via_skip(h, n) (shared skip list) +// new_tx_hashes_fn= chain.get_share(h).invoke([](auto* s){ +// return s->m_tx_info.m_new_transaction_hashes; }) +// +// Per-coin isolation: src/impl/dgb/ only. p2pool-merged-v36 surface: NONE -- +// this only re-reads share_info already validated by share_check; no share +// format, PoW, coinbase commitment, or PPLNS math is touched. +// --------------------------------------------------------------------------- + +#include +#include +#include + +#include "../share_types.hpp" // dgb::TxHashRefs, uint256 + +namespace dgb +{ +namespace coin +{ + +// Resolve a share's transaction_hash_refs to the ordered other_tx hash list. +// +// won_share_hash : hash of the share that found the block (walk origin) +// refs : share.m_tx_info.m_transaction_hash_refs, in order +// nth_parent_fn : (start, n) -> hash of the n-th parent of `start` +// (n == 0 returns `start`); IsNull() if the walk runs off +// the end of the known sharechain +// new_tx_hashes_fn: share_hash -> that share's new_transaction_hashes +// +// Returns the other_tx hashes in ref order. Throws std::out_of_range if an +// ancestor walk-back exceeds the sharechain depth or a tx_count indexes past +// an ancestor's new_transaction_hashes -- both are malformed-share conditions +// that must fail the reconstruction loudly rather than emit a wrong block. +inline std::vector +resolve_other_tx_hashes( + const uint256& won_share_hash, + const std::vector& refs, + const std::function& nth_parent_fn, + const std::function&(const uint256&)>& new_tx_hashes_fn) +{ + std::vector out; + out.reserve(refs.size()); + + for (const auto& ref : refs) + { + const uint256 ancestor = nth_parent_fn(won_share_hash, ref.m_share_count); + if (ancestor.IsNull()) + throw std::out_of_range( + "resolve_other_tx_hashes: transaction_hash_ref share_count " + "walks past the known sharechain"); + + const std::vector& nths = new_tx_hashes_fn(ancestor); + if (ref.m_tx_count >= nths.size()) + throw std::out_of_range( + "resolve_other_tx_hashes: transaction_hash_ref tx_count " + "out of range for ancestor new_transaction_hashes"); + + out.push_back(nths[ref.m_tx_count]); + } + + return out; +} + +} // namespace coin +} // namespace dgb diff --git a/src/impl/dgb/coin/reconstruct_won_block.hpp b/src/impl/dgb/coin/reconstruct_won_block.hpp new file mode 100644 index 000000000..26324577f --- /dev/null +++ b/src/impl/dgb/coin/reconstruct_won_block.hpp @@ -0,0 +1,127 @@ +#pragma once +// --------------------------------------------------------------------------- +// dgb::coin::reconstruct_won_block -- the won-block reconstructor's (#82) BODY: +// the single composition that the dispatch handler (won_block_dispatch.hpp, +// make_on_block_found) injects as its WonBlockReconstructor. It ties the three +// previously-landed sub-slices into one faithful port of p2pool data.py +// Share.as_block(tracker, known_txs): +// +// gentx = self.check(tracker, known_txs) # coinbase tx +// other_txs = [known_txs[h] +// for h in self.get_other_tx_hashes(tracker)] # ref-walk +// return dict(header=self.header, txs=[gentx]+other_txs) +// +// Composition (each step is its own unit-tested SSOT slice): +// 1. resolve_other_tx_hashes (other_tx_resolver.hpp, sub-slice 1 / #174) +// share.transaction_hash_refs --ancestry walk--> ordered other_tx hashes +// 2. assemble_other_txs (other_tx_assembler.hpp, sub-slice 2 / #176) +// ordered hashes --known_txs lookup--> deserialized MutableTransactions +// 3. assemble_won_block (block_assembly.hpp, as_block FRAMING / #168) +// small_header + gentx + merkle_link + other_txs --> {bytes, hex} +// (merkle_root recomputed from gentx_hash up the share merkle_link; +// txs = [gentx] ++ other_txs; BlockType codec = live submitblock path) +// +// The gentx enters as an already-deserialized MutableTransaction + its txid +// (gentx_hash), exactly as block_assembly_test models it. In the run-loop this +// binds to the SSOT gentx that generate_share_transaction exposes via its +// out_gentx parameter (#173): GentxCoinbase{bytes, txid}. Turning those SSOT +// non-witness bytes back into a MutableTransaction is the inverse of #173's +// exposure -- a distinct param-stream codec concern with its own round-trip KAT +// (the explicitly-next slice) -- so it is kept OUT of this pure-composition body +// to keep the as_block ordering + merkle math build-verifiable and KAT-able NOW. +// +// All three tracker/mempool operations are INJECTED as callables (the same +// seam-first decomposition the sub-slices already use), so the whole +// reconstruction is unit-testable against a synthetic ancestry + known-tx set +// without standing up a live ShareTracker / mempool. In the run-loop they bind +// to (cf. won_block_dispatch.hpp / the sub-slice run-loop notes): +// nth_parent_fn = chain.get_nth_parent_via_skip(h, n) +// new_tx_hashes_fn = chain.get_share(h)->m_tx_info.m_new_transaction_hashes +// known_txs_fn = node known-tx store (mempool + peer-relayed tx cache) +// +// Failure posture inherited from the sub-slices: a ref that walks past the +// known sharechain, a tx_count out of range, or an unknown other_tx hash each +// throw std::out_of_range -- a partial/wrong reconstruction would hash to the +// wrong merkle root and be rejected by the daemon, so failing loudly here is +// strictly safer than broadcasting a malformed block. +// +// Per-coin isolation: src/impl/dgb/ only. p2pool-merged-v36 surface: NONE -- +// pure composition of already-validated share_info + already-relayed txs + the +// proven BlockType serializer; no share format, PoW, coinbase commitment, or +// PPLNS math is touched. DGB-Scrypt is a STANDALONE parent in the V36 default +// build (no merged-coinbase leg). +// --------------------------------------------------------------------------- + +#include +#include +#include +#include + +#include + +#include "block_assembly.hpp" // assemble_won_block, SmallBlockHeaderType, BlockType +#include "other_tx_resolver.hpp" // resolve_other_tx_hashes +#include "other_tx_assembler.hpp" // assemble_other_txs +#include "transaction.hpp" // MutableTransaction +#include "../share_types.hpp" // TxHashRefs, MerkleLink, uint256 + +namespace dgb +{ +namespace coin +{ + +// The reconstructed parent block, ready for broadcast_won_block's dual path: +// bytes : the blob the embedded P2P relay sends +// hex : the same block for the external submitblock (RPC) fallback +struct ReconstructedWonBlock +{ + std::vector bytes; + std::string hex; +}; + +// Reconstruct the full serialized parent block for a won share. +// +// small_header : share.m_min_header (version|prev|time|bits|nonce) +// merkle_link : share.m_merkle_link (gentx -> merkle root branch) +// gentx : the share's coinbase tx, already deserialized (block tx 0) +// gentx_hash : its txid (double-SHA256) == p2pool gentx_hash, for the +// merkle_root walk +// won_share_hash : hash of the share that found the block (ref-walk origin) +// refs : share.m_tx_info.m_transaction_hash_refs, in order +// nth_parent_fn : (start, n) -> hash of the n-th parent (n==0 -> start); +// IsNull() if the walk runs off the known sharechain +// new_tx_hashes_fn : share_hash -> that share's new_transaction_hashes +// known_txs_fn : hash -> pointer to the known MutableTransaction, or +// nullptr if absent +// +// Returns {bytes, hex} with hex == HexStr(bytes). Throws std::out_of_range on +// any malformed-share / incomplete-known-txs condition (see the per-step slices). +inline ReconstructedWonBlock +reconstruct_won_block( + const SmallBlockHeaderType& small_header, + const ::dgb::MerkleLink& merkle_link, + const MutableTransaction& gentx, + const uint256& gentx_hash, + const uint256& won_share_hash, + const std::vector& refs, + const std::function& nth_parent_fn, + const std::function&(const uint256&)>& new_tx_hashes_fn, + const std::function& known_txs_fn) +{ + // 1. share.transaction_hash_refs -> ordered other_tx hashes (ancestry walk). + const std::vector other_tx_hashes = + resolve_other_tx_hashes(won_share_hash, refs, nth_parent_fn, new_tx_hashes_fn); + + // 2. ordered hashes -> deserialized MutableTransactions (known_txs lookup). + const std::vector other_txs = + assemble_other_txs(other_tx_hashes, known_txs_fn); + + // 3. small_header + gentx + merkle_link + other_txs -> {bytes, hex}. + // (merkle_root recomputed from gentx_hash up merkle_link; [gentx]++others.) + auto framed = assemble_won_block(small_header, gentx, gentx_hash, merkle_link, other_txs); + + return ReconstructedWonBlock{std::move(framed.first), std::move(framed.second)}; +} + +} // namespace coin +} // namespace dgb diff --git a/src/impl/dgb/share_check.hpp b/src/impl/dgb/share_check.hpp index 622f3b51f..d3ec92ffa 100644 --- a/src/impl/dgb/share_check.hpp +++ b/src/impl/dgb/share_check.hpp @@ -8,6 +8,8 @@ #include "share_messages.hpp" #include "share_types.hpp" +#include + #include #include #include @@ -931,7 +933,7 @@ inline std::vector get_share_script(const auto* obj) // Reference: frstrtr/p2pool-merged-v36 p2pool/data.py generate_transaction() // ============================================================================ template -uint256 generate_share_transaction(const ShareT& share, TrackerT& tracker, const core::CoinParams& params, bool dump_diag = false, bool v36_active = false) +uint256 generate_share_transaction(const ShareT& share, TrackerT& tracker, const core::CoinParams& params, bool dump_diag = false, bool v36_active = false, dgb::coin::GentxCoinbase* out_gentx = nullptr) { auto gst_t0 = std::chrono::steady_clock::now(); constexpr int64_t ver = ShareT::version; @@ -1133,81 +1135,22 @@ uint256 generate_share_transaction(const ShareT& share, TrackerT& tracker, const payout_outputs.erase(payout_outputs.begin(), payout_outputs.end() - MAX_OUTPUTS); // --- 4. Serialise the coinbase transaction --- - // Non-witness serialization (for txid computation): - // version(4) + vin_count(varint) + vin + vout_count(varint) + vouts + locktime(4) - PackStream tx; - - // tx version = 1 - uint32_t tx_version = 1; - tx.write(std::span(reinterpret_cast(&tx_version), 4)); - - // vin count = 1 - { - unsigned char one = 1; - tx.write(std::span(reinterpret_cast(&one), 1)); - } - - // vin[0]: prev_output = 0...0:ffffffff, script = coinbase, sequence = 0 - { - // prev_hash (32 zero bytes) - uint256 zero_hash; - tx << zero_hash; - // prev_index (0xffffffff) - uint32_t prev_idx = 0xffffffff; - tx.write(std::span(reinterpret_cast(&prev_idx), 4)); - // script (VarStr) - tx << share.m_coinbase; - // sequence (0xffffffff — standard coinbase sequence, matches Python) - uint32_t seq = 0xffffffff; - tx.write(std::span(reinterpret_cast(&seq), 4)); - } - - // Count total outputs - size_t n_outs = payout_outputs.size() + 1 /* donation */ + 1 /* OP_RETURN commitment */; - // Segwit commitment output (if applicable) - bool has_segwit = false; + // Wire layout (version|vin|vouts|locktime) and txid are produced by the + // SSOT assembler dgb::coin::assemble_gentx_coinbase() so that emission and + // verification can never diverge on a byte. This function only builds the + // per-share inputs (coinbase script, optional segwit commitment, payout + // outputs, donation, OP_RETURN ref commitment). + + // Segwit witness-commitment output (value=0) — present iff this share + // carries segwit data on a segwit-active version. The script is + // 0x6a24aa21a9ed + SHA256d(wtxid_merkle_root || P2POOL_WITNESS_NONCE). + std::optional> segwit_commitment_script; if constexpr (ver >= dgb::SEGWIT_ACTIVATION_VERSION) { if constexpr (requires { share.m_segwit_data; }) { if (share.m_segwit_data.has_value()) { - has_segwit = true; - n_outs += 1; - } - } - } - - // vout count (varint — for < 253 outputs, it's a single byte) - if (n_outs < 253) - { - uint8_t cnt = static_cast(n_outs); - tx.write(std::span(reinterpret_cast(&cnt), 1)); - } - else - { - uint8_t marker = 0xfd; - tx.write(std::span(reinterpret_cast(&marker), 1)); - uint16_t cnt = static_cast(n_outs); - tx.write(std::span(reinterpret_cast(&cnt), 2)); - } - - // Helper to write a single tx_out: value(8LE) + script(VarStr) - auto write_txout = [&](uint64_t value, const std::vector& script) { - tx.write(std::span(reinterpret_cast(&value), 8)); - BaseScript bs; - bs.m_data = script; - tx << bs; - }; - - // Segwit commitment output (value=0, script=OP_RETURN + witness_commitment) - if (has_segwit) - { - if constexpr (requires { share.m_segwit_data; }) - { - if (share.m_segwit_data.has_value()) - { - // witness commitment: 0x6a24aa21a9ed + SHA256d(wtxid_merkle_root || '[P2Pool]'*4) std::vector wscript; wscript.push_back(0x6a); // OP_RETURN wscript.push_back(0x24); // PUSH 36 @@ -1219,7 +1162,7 @@ uint256 generate_share_transaction(const ShareT& share, TrackerT& tracker, const uint256 commitment = compute_p2pool_witness_commitment(sd.m_wtxid_merkle_root); auto commitment_bytes = commitment.GetChars(); wscript.insert(wscript.end(), commitment_bytes.begin(), commitment_bytes.end()); - write_txout(0, wscript); + segwit_commitment_script = std::move(wscript); if (dump_diag) { LOG_INFO << "[WC-GST] wtxid_root=" << sd.m_wtxid_merkle_root.GetHex() << " commitment=" << commitment.GetHex(); @@ -1228,17 +1171,13 @@ uint256 generate_share_transaction(const ShareT& share, TrackerT& tracker, const } } - // PPLNS payout outputs - for (auto& [script, amount] : payout_outputs) - write_txout(amount, script); - // Donation output — V35 shares always use P2PK DONATION_SCRIPT, // V36 shares always use P2SH COMBINED_DONATION_SCRIPT. // Each share was created with the donation script matching its version. auto donation_script = params.donation_script_func(ver); - write_txout(donation_amount, donation_script); // OP_RETURN commitment: value=0, script = 0x6a28 + ref_hash(32) + last_txout_nonce(8) + std::vector op_return_script; { // We need the ref_hash — recompute it from the share the same way share_init_verify does PackStream ref_stream; @@ -1350,7 +1289,6 @@ uint256 generate_share_transaction(const ShareT& share, TrackerT& tracker, const uint256 ref_hash = check_merkle_link(hash_ref, share.m_ref_merkle_link); // Build OP_RETURN script: 0x6a 0x28 + ref_hash(32) + last_txout_nonce(8) - std::vector op_return_script; op_return_script.push_back(0x6a); // OP_RETURN op_return_script.push_back(0x28); // PUSH 40 bytes op_return_script.insert(op_return_script.end(), ref_hash.data(), ref_hash.data() + 32); @@ -1359,19 +1297,23 @@ uint256 generate_share_transaction(const ShareT& share, TrackerT& tracker, const auto* p = reinterpret_cast(&nonce); op_return_script.insert(op_return_script.end(), p, p + 8); } - write_txout(0, op_return_script); } - // lock_time = 0 - { - uint32_t locktime = 0; - tx.write(std::span(reinterpret_cast(&locktime), 4)); - } + // --- 5. Assemble + compute txid via the SSOT (double-SHA256 of + // non-witness serialization). Reconstruct the PackStream tx so the + // downstream V36 hash_link diagnostics keep working unchanged. + auto _gc = dgb::coin::assemble_gentx_coinbase( + share.m_coinbase.m_data, segwit_commitment_script, payout_outputs, + donation_amount, donation_script, op_return_script); + PackStream tx; + tx.write(std::span( + reinterpret_cast(_gc.bytes.data()), _gc.bytes.size())); + auto txid = _gc.txid; - // --- 5. Compute txid (double-SHA256 of non-witness serialization) --- - auto tx_span = std::span( - reinterpret_cast(tx.data()), tx.size()); - auto txid = Hash(tx_span); + // Expose the SSOT gentx (non-witness bytes + txid) for the won-block + // reconstructor (#82): block tx[0] is this coinbase and gentx_hash is + // its merkle leaf. Default nullptr => zero change for verification callers. + if (out_gentx) *out_gentx = _gc; // V36 hash_link cross-check: compute prefix hash_link from our coinbase // and compare with the share's stored hash_link. If states differ, @@ -1452,8 +1394,8 @@ uint256 generate_share_transaction(const ShareT& share, TrackerT& tracker, const LOG_WARNING << "[GENTX-DIAG] coinbase_hex=" << to_hex(cp, tx.size()); LOG_WARNING << "[GENTX-DIAG] pplns_outputs=" << payout_outputs.size() << " donation_amount=" << donation_amount - << " n_outs=" << n_outs - << " has_segwit=" << has_segwit; + << " n_outs=" << (payout_outputs.size() + 2 + (segwit_commitment_script.has_value() ? 1u : 0u)) + << " has_segwit=" << segwit_commitment_script.has_value(); LOG_WARNING << "[GENTX-DIAG] total_weight=" << total_weight.GetHex() << " total_don_weight=" << total_donation_weight.GetHex(); for (size_t i = 0; i < payout_outputs.size(); ++i) { @@ -2729,7 +2671,8 @@ uint256 create_local_share( share.m_last_txout_nonce = static_cast(std::time(nullptr)) ^ (static_cast(min_header.m_nonce) << 32); - // --- Build the p2pool coinbase in the same format as generate_share_transaction --- + // --- Build the p2pool coinbase via the SSOT assemble_gentx_coinbase() --- + // (identical wire layout to generate_share_transaction; one byte path, not a twin) // This is needed to compute hash_link and to verify the share locally. // The coinbase format is: version(4) + vin(1 input) + vout(outputs...) + locktime(4) // @@ -2942,41 +2885,29 @@ uint256 create_local_share( if (payout_outputs.size() > 4000) payout_outputs.erase(payout_outputs.begin(), payout_outputs.end() - 4000); - PackStream gentx; - { uint32_t v = 1; gentx.write(std::span(reinterpret_cast(&v), 4)); } - { unsigned char one = 1; gentx.write(std::span(reinterpret_cast(&one), 1)); } - { uint256 z; gentx << z; } - { uint32_t idx = 0xffffffff; gentx.write(std::span(reinterpret_cast(&idx), 4)); } - gentx << share.m_coinbase; - { uint32_t seq = 0xffffffff; gentx.write(std::span(reinterpret_cast(&seq), 4)); } - size_t n_outs = payout_outputs.size() + 1 + 1; - bool has_segwit_fb = share.m_segwit_data.has_value(); - if (has_segwit_fb) n_outs += 1; - if (n_outs < 253) { uint8_t cnt = (uint8_t)n_outs; gentx.write(std::span(reinterpret_cast(&cnt), 1)); } - else { uint8_t m = 0xfd; gentx.write(std::span(reinterpret_cast(&m), 1)); uint16_t cnt = (uint16_t)n_outs; gentx.write(std::span(reinterpret_cast(&cnt), 2)); } - auto write_txout = [&](uint64_t value, const std::vector& script) { - gentx.write(std::span(reinterpret_cast(&value), 8)); - BaseScript bs; bs.m_data = script; gentx << bs; - }; - if (has_segwit_fb) { + // Build the p2pool coinbase via the SSOT assembler + // dgb::coin::assemble_gentx_coinbase() — identical wire layout to + // generate_share_transaction(). This is no longer a hand-rolled twin: + // both emission and verification route through the one byte path. + std::optional> segwit_opt; + if (share.m_segwit_data.has_value()) { auto& sd = share.m_segwit_data.value(); std::vector wscript = {0x6a, 0x24, 0xaa, 0x21, 0xa9, 0xed}; uint256 commitment = compute_p2pool_witness_commitment(sd.m_wtxid_merkle_root); auto cb = commitment.GetChars(); wscript.insert(wscript.end(), cb.begin(), cb.end()); - write_txout(0, wscript); - } - for (auto& [script, amount] : payout_outputs) write_txout(amount, script); - write_txout(donation_amount, params.donation_script_func(int64_t(36))); - { std::vector op; op.push_back(0x6a); op.push_back(0x28); - op.insert(op.end(), ref_hash.data(), ref_hash.data() + 32); - uint64_t n = share.m_last_txout_nonce; auto* p = reinterpret_cast(&n); - op.insert(op.end(), p, p + 8); write_txout(0, op); } - { uint32_t lt = 0; gentx.write(std::span(reinterpret_cast(<), 4)); } - - coinbase_bytes_for_hashlink.assign( - reinterpret_cast(gentx.data()), - reinterpret_cast(gentx.data()) + gentx.size()); + segwit_opt = std::move(wscript); + } + auto donation_script = params.donation_script_func(int64_t(36)); + std::vector op_return_vec; + op_return_vec.push_back(0x6a); op_return_vec.push_back(0x28); + op_return_vec.insert(op_return_vec.end(), ref_hash.data(), ref_hash.data() + 32); + { uint64_t n = share.m_last_txout_nonce; auto* p = reinterpret_cast(&n); + op_return_vec.insert(op_return_vec.end(), p, p + 8); } + auto _gc = dgb::coin::assemble_gentx_coinbase( + share.m_coinbase.m_data, segwit_opt, payout_outputs, + donation_amount, donation_script, op_return_vec); + coinbase_bytes_for_hashlink = _gc.bytes; } // The split point: everything before ref_hash + last_txout_nonce + locktime diff --git a/src/impl/dgb/test/CMakeLists.txt b/src/impl/dgb/test/CMakeLists.txt index 26ee111be..cd711f609 100644 --- a/src/impl/dgb/test/CMakeLists.txt +++ b/src/impl/dgb/test/CMakeLists.txt @@ -32,6 +32,21 @@ if (BUILD_TESTING AND GTest_FOUND) dgb_coin pool sharechain) gtest_add_tests(dgb_block_assembly_test "" AUTO) + # --- #82: won-block reconstructor BODY (as_block composition) ---------- + # Pins coin/reconstruct_won_block.hpp: the single composition the dispatch + # handler injects as its WonBlockReconstructor (resolve_other_tx_hashes -> + # assemble_other_txs -> assemble_won_block). Links the dgb OBJECT lib like + # dgb_block_assembly_test because it pulls block_assembly.hpp (BlockType / + # MutableTransaction / check_merkle_link). MUST appear in BOTH this + # registration AND the build.yml --target allowlist (#143 NOT_BUILT trap). + add_executable(dgb_reconstruct_won_block_test reconstruct_won_block_test.cpp) + target_link_libraries(dgb_reconstruct_won_block_test PRIVATE + GTest::gtest_main GTest::gtest + core dgb + c2pool_payout c2pool_merged_mining c2pool_hashrate c2pool_storage + dgb_coin pool sharechain) + gtest_add_tests(dgb_reconstruct_won_block_test "" AUTO) + # --- M3 RPC-transport standalone regression guards --------------------- # Header-only guards over the external-daemon RPC coin-layer SSOTs # (rpc_request.hpp request-shape + version floor + genesis identity, and @@ -54,6 +69,66 @@ if (BUILD_TESTING AND GTest_FOUND) dgb_coin pool sharechain) gtest_add_tests(dgb_gentx_coinbase_test "" AUTO) + # --- #172 share-path gentx KAT (collapse consumption proof) ------------ + # Proves generate_share_transaction (the verification SSOT call site #172 + # rewired) EMITS assemble_gentx_coinbase framing for a real v36 share, via + # a round-trip equivalence (the op_return ref_hash is share-derived, so it + # cannot be pinned to a fixed oracle vector). Links the dgb OBJECT lib like + # dgb_share_test. MUST also be in the build.yml --target allowlist (#143). + add_executable(dgb_gentx_share_path_test gentx_share_path_test.cpp) + target_link_libraries(dgb_gentx_share_path_test PRIVATE + GTest::gtest_main GTest::gtest + core dgb + c2pool_payout c2pool_merged_mining c2pool_hashrate c2pool_storage + dgb_coin pool sharechain) + gtest_add_tests(dgb_gentx_share_path_test "" AUTO) + + # --- #82 sub-slice 1: transaction_hash_refs -> other_tx hash walk ------- + # coin/other_tx_resolver.hpp is the won-block reconstructor`s connecting + # tissue (faithful get_other_tx_hashes ancestry resolution). It pulls + # uint256 (SetHex TU) via share_types.hpp, so it links the proven dgb OBJECT-lib SCC set (like dgb_block_assembly_test) + # rather than the header-only guard foreach (linking bare `core` drags in + # web_server.cpp, whose symbols live in the SCC libs). The tracker ops are + # injected, so no dgb pool state is exercised. MUST also appear in the + # build.yml --target allowlist (#143 NOT_BUILT trap). + add_executable(dgb_other_tx_resolver_test other_tx_resolver_test.cpp) + target_link_libraries(dgb_other_tx_resolver_test PRIVATE + GTest::gtest_main GTest::gtest + core dgb + c2pool_payout c2pool_merged_mining c2pool_hashrate c2pool_storage + dgb_coin pool sharechain) + gtest_add_tests(dgb_other_tx_resolver_test "" AUTO) + + # --- #82 sub-slice 2: other_tx hashes -> MutableTransaction other_txs --- + # coin/other_tx_assembler.hpp is the won-block reconstructor`s bridge from + # the resolved other_tx hash list (sub-slice 1) to the deserialized txs that + # assemble_won_block frames (faithful as_block known_txs[h] lookup). Links + # the dgb OBJECT lib like dgb_block_assembly_test because the end-to-end KAT + # frames through dgb::check_merkle_link + the live BlockType codec. MUST also + # appear in the build.yml --target allowlist (#143 NOT_BUILT trap). + add_executable(dgb_other_tx_assembler_test other_tx_assembler_test.cpp) + target_link_libraries(dgb_other_tx_assembler_test PRIVATE + GTest::gtest_main GTest::gtest + core dgb + c2pool_payout c2pool_merged_mining c2pool_hashrate c2pool_storage + dgb_coin pool sharechain) + gtest_add_tests(dgb_other_tx_assembler_test "" AUTO) + + # --- #82: gentx-bytes -> MutableTransaction unpack (inverse of #173) ---- + # coin/gentx_unpack.hpp turns the share's SSOT non-witness gentx bytes back + # into the MutableTransaction reconstruct_won_block injects at tx index 0. + # Pulls PackStream/Hash/uint256 + transaction.hpp, so it links the proven + # dgb OBJECT-lib SCC set like dgb_gentx_coinbase_test. Oracle round-trip + + # inverse-pairing KAT. MUST also appear in the build.yml --target allowlist + # (#143 NOT_BUILT trap). + add_executable(dgb_gentx_unpack_test gentx_unpack_test.cpp) + target_link_libraries(dgb_gentx_unpack_test PRIVATE + GTest::gtest_main GTest::gtest + core dgb + c2pool_payout c2pool_merged_mining c2pool_hashrate c2pool_storage + dgb_coin pool sharechain) + gtest_add_tests(dgb_gentx_unpack_test "" AUTO) + foreach(dgb_guard rpc_request_test softfork_check_test genesis_check_test algo_select_test digishield_walk_test header_chain_test) add_executable(${dgb_guard} ${dgb_guard}.cpp) target_link_libraries(${dgb_guard} PRIVATE diff --git a/src/impl/dgb/test/gentx_share_path_test.cpp b/src/impl/dgb/test/gentx_share_path_test.cpp new file mode 100644 index 000000000..f1c91da35 --- /dev/null +++ b/src/impl/dgb/test/gentx_share_path_test.cpp @@ -0,0 +1,308 @@ +// DGB #82 / #172 — share-path gentx KAT. +// +// The helper-level KAT (gentx_coinbase_test) pins dgb::coin::assemble_gentx_coinbase +// directly against oracle vectors. This test proves the *consumption* side of +// the #172 collapse: that generate_share_transaction() — the verification SSOT +// call site that #172 rewired onto assemble_gentx_coinbase — actually EMITS the +// SSOT framing for a real share, not merely that the pure helper does. +// +// The OP_RETURN ref commitment is share-derived: +// ref_hash = check_merkle_link(Hash(ref_serialization), m_ref_merkle_link) +// so the share-path gentx cannot be pinned to a fixed 0xab..×32 oracle byte +// vector (criterion 1: no oracle vector regeneration). Instead we assert a +// round-trip equivalence: generate_share_transaction(share) == a verbatim +// re-derivation of the same per-share inputs handed to the SAME SSOT assembler. +// If the share path ever stops routing through the SSOT, or builds the coinbase +// inputs differently, the two txids diverge and this fails — which is exactly +// "the collapse is what's tested." +// +// A v36 MergedMiningShare with a NULL parent is used so the PPLNS walk is +// skipped entirely (no tracker chain entries are touched): payout_outputs is +// empty and donation_amount == subsidy. That isolates the coinbase framing + +// op_return derivation, which is the SSOT contract under test. +// +// MUST appear in BOTH test/CMakeLists.txt AND the build.yml --target allowlist +// or it becomes a #143-style NOT_BUILT sentinel that reds master. + +#include + +#include +#include +#include +#include +#include +#include + +#include +#include +#include + +#include +#include +#include +#include +#include + +namespace { + +// A v36 share with a null parent => PPLNS skipped (empty payouts, donation == +// subsidy). Coinbase script / nonce / timestamp / last_txout_nonce are set to +// non-trivial values so the op_return ref derivation is exercised. +dgb::MergedMiningShare make_v36_share() +{ + dgb::MergedMiningShare s; + s.m_prev_hash.SetNull(); // null parent => PPLNS walk skipped + s.m_coinbase.m_data = { 0x03, 0xa1, 0xb2, 0xc3, 0x04, 0x11, 0x22, 0x33, 0x44 }; + s.m_nonce = 0x12345678; + s.m_pubkey_hash.SetNull(); + s.m_pubkey_type = 0; + s.m_subsidy = 500000000ull; + s.m_donation = 0; + s.m_stale_info = dgb::none; + s.m_desired_version = 36; + s.m_segwit_data = std::nullopt; + s.m_far_share_hash.SetNull(); + s.m_max_bits = 0x1e0fffff; + s.m_bits = 0x1e0fffff; + s.m_timestamp = 1718700000; + s.m_absheight = 1000; + // m_abswork default-constructs to zero (uint128) + s.m_merged_payout_hash.SetNull(); + s.m_last_txout_nonce = 0x0001020304050607ull; + // m_ref_merkle_link left empty => check_merkle_link returns Hash(ref) directly + return s; +} + +// Verbatim re-derivation of generate_share_transaction's per-share coinbase +// inputs (v36 path), ending in the SAME SSOT assembler. Mirrors share_check.hpp +// section 4/5 byte-for-byte via the identical pack primitives. +template +dgb::coin::GentxCoinbase rederive_via_ssot( + const ShareT& share, const core::CoinParams& params) +{ + constexpr int64_t ver = ShareT::version; + + // null parent => empty payouts, donation_amount == subsidy + std::vector, uint64_t>> payout_outputs; + uint64_t donation_amount = share.m_subsidy; + + // no segwit_data => no witness-commitment vout + std::optional> segwit_commitment_script; + + auto donation_script = params.donation_script_func(ver); + + std::vector op_return_script; + { + PackStream ref_stream; + { + auto hex = params.active_identifier_hex(); + for (size_t i = 0; i + 1 < hex.size(); i += 2) + { + unsigned char byte = static_cast( + std::stoul(hex.substr(i, 2), nullptr, 16)); + ref_stream.write(std::span( + reinterpret_cast(&byte), 1)); + } + } + { + ref_stream << share.m_prev_hash; + ref_stream << share.m_coinbase; + ref_stream << share.m_nonce; + + if constexpr (requires { share.m_address; }) + ref_stream << share.m_address; + else if constexpr (requires { share.m_pubkey_type; }) + { + ref_stream << share.m_pubkey_hash; + ref_stream << share.m_pubkey_type; + } + else + ref_stream << share.m_pubkey_hash; + + if constexpr (core::version_gate::is_v36_active(ver)) + ::Serialize(ref_stream, VarInt(share.m_subsidy)); + else + ref_stream << share.m_subsidy; + + ref_stream << share.m_donation; + { + uint8_t si = static_cast(share.m_stale_info); + ref_stream << si; + } + ::Serialize(ref_stream, VarInt(share.m_desired_version)); + + if constexpr (requires { share.m_segwit_data; }) + { + if constexpr (ver >= dgb::SEGWIT_ACTIVATION_VERSION) + { + if (share.m_segwit_data.has_value()) { + ref_stream << share.m_segwit_data.value(); + } else { + std::vector empty_branch; + ref_stream << empty_branch; + uint256 zero_root; + ref_stream << zero_root; + } + } + } + + if constexpr (core::version_gate::is_v36_active(ver)) + { + if constexpr (requires { share.m_merged_addresses; }) + ref_stream << share.m_merged_addresses; + } + + if constexpr (ver < 34) + { + if constexpr (requires { share.m_tx_info; }) + ref_stream << share.m_tx_info; + } + + ref_stream << share.m_far_share_hash; + ref_stream << share.m_max_bits; + ref_stream << share.m_bits; + ref_stream << share.m_timestamp; + ref_stream << share.m_absheight; + + if constexpr (core::version_gate::is_v36_active(ver)) + { + if constexpr (requires { share.m_abswork; }) + ::Serialize(ref_stream, Using(share.m_abswork)); + } + else + { + ref_stream << share.m_abswork; + } + + if constexpr (core::version_gate::is_v36_active(ver)) + { + if constexpr (requires { share.m_merged_coinbase_info; }) + ref_stream << share.m_merged_coinbase_info; + if constexpr (requires { share.m_merged_payout_hash; }) + ref_stream << share.m_merged_payout_hash; + } + } + + if constexpr (core::version_gate::is_v36_active(ver)) + { + if constexpr (requires { share.m_message_data; }) + ref_stream << share.m_message_data; + } + + auto ref_span = std::span( + reinterpret_cast(ref_stream.data()), ref_stream.size()); + uint256 hash_ref = Hash(ref_span); + uint256 ref_hash = dgb::check_merkle_link(hash_ref, share.m_ref_merkle_link); + + op_return_script.push_back(0x6a); // OP_RETURN + op_return_script.push_back(0x28); // PUSH 40 + op_return_script.insert(op_return_script.end(), ref_hash.data(), ref_hash.data() + 32); + { + uint64_t nonce = share.m_last_txout_nonce; + auto* p = reinterpret_cast(&nonce); + op_return_script.insert(op_return_script.end(), p, p + 8); + } + } + + return dgb::coin::assemble_gentx_coinbase( + share.m_coinbase.m_data, segwit_commitment_script, payout_outputs, + donation_amount, donation_script, op_return_script); +} + +// --- Test 1: share path emits SSOT framing (round-trip equivalence) ----------- +TEST(DgbGentxSharePath, GenerateShareTransactionEmitsSsotFraming) +{ + auto params = dgb::make_coin_params(/*testnet=*/false); + dgb::ShareTracker tracker; // empty chain + auto share = make_v36_share(); + + // Route A: the verification SSOT call site under test. + uint256 actual = dgb::generate_share_transaction( + share, tracker, params, /*dump_diag=*/false, /*v36_active=*/true); + + // Route B: verbatim re-derivation through the SAME assembler. + auto expected = rederive_via_ssot(share, params); + + EXPECT_EQ(actual, expected.txid) + << "generate_share_transaction no longer emits assemble_gentx_coinbase framing"; +} + +// --- Test 2: determinism (the SSOT path is a pure function of the share) ------- +TEST(DgbGentxSharePath, Deterministic) +{ + auto params = dgb::make_coin_params(false); + dgb::ShareTracker tracker; + auto share = make_v36_share(); + + uint256 a = dgb::generate_share_transaction(share, tracker, params, false, true); + uint256 b = dgb::generate_share_transaction(share, tracker, params, false, true); + EXPECT_EQ(a, b); +} + +// --- Test 3: coinbase-affecting fields flow into the SSOT gentx ---------------- +// last_txout_nonce feeds the op_return; coinbase script feeds vin[0]. Changing +// either must change the gentx txid (proves the share data reaches the SSOT, +// not a cached/stubbed value). +TEST(DgbGentxSharePath, CoinbaseInputsAreLoadBearing) +{ + auto params = dgb::make_coin_params(false); + dgb::ShareTracker tracker; + + auto base = make_v36_share(); + uint256 h0 = dgb::generate_share_transaction(base, tracker, params, false, true); + + auto bumped_nonce = base; + bumped_nonce.m_last_txout_nonce ^= 0xffull; + uint256 h1 = dgb::generate_share_transaction(bumped_nonce, tracker, params, false, true); + EXPECT_NE(h0, h1) << "last_txout_nonce did not reach the op_return commitment"; + + auto bumped_cb = base; + bumped_cb.m_coinbase.m_data.push_back(0x99); + uint256 h2 = dgb::generate_share_transaction(bumped_cb, tracker, params, false, true); + EXPECT_NE(h0, h2) << "coinbase script did not reach the gentx vin"; +} + +// --- Test 4: SSOT gentx bytes are exposed for won-block reconstruction (#82) --- +// reconstruct_won_block needs the EXACT coinbase bytes whose txid is the merkle +// leaf -- not just the hash. The out_gentx param surfaces them straight from the +// SSOT assembler; they must (a) hash to the returned gentx_hash and (b) +// deserialize into the coinbase MutableTransaction that assemble_won_block frames +// as block tx[0], round-tripping byte-identically. +TEST(DgbGentxSharePath, SsotGentxBytesExposedForReconstruct) +{ + auto params = dgb::make_coin_params(false); + dgb::ShareTracker tracker; + auto share = make_v36_share(); + + dgb::coin::GentxCoinbase gc; + uint256 gentx_hash = dgb::generate_share_transaction( + share, tracker, params, /*dump_diag=*/false, /*v36_active=*/true, &gc); + + ASSERT_FALSE(gc.bytes.empty()) << "out_gentx not populated"; + EXPECT_EQ(gc.txid, gentx_hash) + << "exposed gentx bytes do not match the returned gentx_hash"; + auto sp = std::span(gc.bytes.data(), gc.bytes.size()); + EXPECT_EQ(Hash(sp), gentx_hash) + << "double-SHA(exposed bytes) != gentx_hash (merkle leaf would mismatch)"; + + // Deserialize into the coinbase tx that assemble_won_block consumes. + dgb::coin::MutableTransaction gentx; + { + PackStream ps(gc.bytes); + dgb::coin::UnserializeTransaction(gentx, ps, dgb::coin::TX_NO_WITNESS); + } + // Coinbase shape: single input spending the null outpoint at 0xffffffff. + ASSERT_EQ(gentx.vin.size(), 1u); + EXPECT_TRUE(gentx.vin[0].prevout.hash.IsNull()); + EXPECT_EQ(gentx.vin[0].prevout.index, 0xffffffffu); + + // Re-serialize non-witness => byte-identical to the SSOT bytes, so the + // reconstructed block tx[0] is faithful and the daemon-side block hashes. + auto repacked = pack(dgb::coin::TX_NO_WITNESS(gentx)); + std::vector rebytes( + reinterpret_cast(repacked.data()), + reinterpret_cast(repacked.data()) + repacked.size()); + EXPECT_EQ(rebytes, gc.bytes) << "coinbase tx does not round-trip the SSOT bytes"; +} + +} // namespace diff --git a/src/impl/dgb/test/gentx_unpack_test.cpp b/src/impl/dgb/test/gentx_unpack_test.cpp new file mode 100644 index 000000000..b1ed57470 --- /dev/null +++ b/src/impl/dgb/test/gentx_unpack_test.cpp @@ -0,0 +1,153 @@ +// DGB #82 — gentx-bytes -> MutableTransaction unpack round-trip KAT. +// +// Locks dgb::coin::unpack_gentx_coinbase() (coin/gentx_unpack.hpp) — the +// INVERSE of #173's GentxCoinbase exposure — as the codec step that the +// run-loop uses to turn the share's SSOT non-witness gentx bytes back into the +// MutableTransaction that reconstruct_won_block injects at block tx index 0. +// +// The proof is two-pronged and uses the SAME ground-truth vectors as +// gentx_coinbase_test.cpp (derived from the canonical oracle +// frstrtr/p2pool-dgb-scrypt, NOT self-generated): +// 1. ORACLE-PINNED: unpack(oracle_bytes) -> re-serialize TX_NO_WITNESS == +// oracle_bytes EXACTLY, and txid == oracle_txid. This is the byte-exact, +// txid-stable round-trip the merkle_root walk depends on (integrator +// 2026-06-19: any drift here corrupts the assembled block). +// 2. INVERSE PAIRING: assemble_gentx_coinbase(...) -> unpack(...) recovers a +// tx whose non-witness bytes and txid equal the assembler's GentxCoinbase +// {bytes, txid} — proving unpack is the exact inverse of the #173 path +// for both the no-segwit and witness-commitment-first layouts. +// +// Per-coin isolation: src/impl/dgb/ only; the only DGB<->BCH divergence is the +// segwit predicate at assemble time (vout count), exercised by both layouts; +// the unpacked gentx carries no witness either way (HasWitness()==false). + +#include +#include +#include + +#include + +#include +#include +#include +#include +#include + +namespace { + +std::vector unhex(const std::string& h) { + std::vector v; v.reserve(h.size() / 2); + auto nyb = [](char c) -> int { return (c <= '9') ? c - '0' : (c | 0x20) - 'a' + 10; }; + for (size_t i = 0; i + 1 < h.size(); i += 2) + v.push_back(static_cast((nyb(h[i]) << 4) | nyb(h[i + 1]))); + return v; +} +std::string tohex(const std::vector& v) { + static const char* H = "0123456789abcdef"; + std::string s; s.reserve(v.size() * 2); + for (unsigned char b : v) { s.push_back(H[b >> 4]); s.push_back(H[b & 0xf]); } + return s; +} + +// Re-serialize a MutableTransaction in non-witness form and hex it (the exact +// bytes the txid + merkle_root walk are taken over). +std::string noseg_hex(const dgb::coin::MutableTransaction& tx) { + auto packed = pack(dgb::coin::TX_NO_WITNESS(tx)); + auto sp = packed.get_span(); + std::vector v( + reinterpret_cast(sp.data()), + reinterpret_cast(sp.data()) + sp.size()); + return tohex(v); +} + +// --- fixed inputs shared verbatim with gentx_coinbase_test / the oracle ---- +const std::vector CB = unhex("03a1b2c3041122334455667788"); +const std::vector P1 = unhex(std::string("76a914") + std::string(40, '1') + "88ac"); +const std::vector P2 = unhex(std::string("76a914") + std::string(40, '2') + "88ac"); +const std::vector DON = unhex("4104ffd03de44a6e11b9917f3a29f9443283d9871c9d743ef30d5eddcd37094b64d1b3d8090496b53256786bf5c82932ec23c3b74d9f05a6f95a8b5529352656664bac"); + +const std::vector, uint64_t>> PAYOUTS = { + {P1, 5000000000ull}, + {P2, 2500000000ull}, +}; + +// Ground truth from the oracle serializer (identical to gentx_coinbase_test). +const std::string NOSEG_BYTES = + "01000000010000000000000000000000000000000000000000000000000000000000000000ffffffff0d03a1b2c3041122334455667788ffffffff0400f2052a010000001976a914111111111111111111111111111111111111111188ac00f90295000000001976a914222222222222222222222222222222222222222288ac0100000000000000434104ffd03de44a6e11b9917f3a29f9443283d9871c9d743ef30d5eddcd37094b64d1b3d8090496b53256786bf5c82932ec23c3b74d9f05a6f95a8b5529352656664bac00000000000000002a6a28abababababababababababababababababababababababababababababababab080706050403020100000000"; +const std::string NOSEG_TXID = + "c5734775c1521b216e0e1bca506e4d15755cf55125caf56b7e0728a6d54a9b59"; +const std::string SEG_BYTES = + "01000000010000000000000000000000000000000000000000000000000000000000000000ffffffff0d03a1b2c3041122334455667788ffffffff050000000000000000266a24aa21a9edcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcd00f2052a010000001976a914111111111111111111111111111111111111111188ac00f90295000000001976a914222222222222222222222222222222222222222288ac0100000000000000434104ffd03de44a6e11b9917f3a29f9443283d9871c9d743ef30d5eddcd37094b64d1b3d8090496b53256786bf5c82932ec23c3b74d9f05a6f95a8b5529352656664bac00000000000000002a6a28abababababababababababababababababababababababababababababababab080706050403020100000000"; +const std::string SEG_TXID = + "4b66aabff52ca1336b52688815cece123075856880aaf6e77cb44f0d477b6162"; + +std::vector make_opret() { + auto v = unhex("6a28"); + for (int i = 0; i < 32; ++i) v.push_back(0xab); + const unsigned char nonce[8] = {0x08, 0x07, 0x06, 0x05, 0x04, 0x03, 0x02, 0x01}; + for (unsigned char b : nonce) v.push_back(b); + return v; +} +std::vector make_wc() { + auto v = unhex("6a24aa21a9ed"); + for (int i = 0; i < 32; ++i) v.push_back(0xcd); + return v; +} + +} // namespace + +// (1a) ORACLE round-trip, no-segwit layout: bytes exact + txid stable. +TEST(DGB_gentx_unpack, NoSegwitOracleRoundTrip) { + auto u = dgb::coin::unpack_gentx_coinbase(unhex(NOSEG_BYTES)); + EXPECT_EQ(noseg_hex(u.tx), NOSEG_BYTES); // byte-exact non-witness re-serialize + EXPECT_EQ(u.txid.GetHex(), NOSEG_TXID); // gentx_hash stable + EXPECT_FALSE(u.tx.HasWitness()); // no witness drift +} + +// (1b) ORACLE round-trip, witness-commitment-first layout: still non-witness. +TEST(DGB_gentx_unpack, SegwitCommitmentFirstOracleRoundTrip) { + auto u = dgb::coin::unpack_gentx_coinbase(unhex(SEG_BYTES)); + EXPECT_EQ(noseg_hex(u.tx), SEG_BYTES); + EXPECT_EQ(u.txid.GetHex(), SEG_TXID); + EXPECT_FALSE(u.tx.HasWitness()); // commitment is a vout, not a witness +} + +// Structural sanity: the recovered coinbase has the p2pool gentx shape. +TEST(DGB_gentx_unpack, RecoversCoinbaseShape) { + auto u = dgb::coin::unpack_gentx_coinbase(unhex(NOSEG_BYTES)); + EXPECT_EQ(u.tx.version, 1); + EXPECT_EQ(u.tx.locktime, 0u); + ASSERT_EQ(u.tx.vin.size(), 1u); + EXPECT_TRUE(u.tx.vin[0].prevout.hash.IsNull()); // coinbase: null prev + EXPECT_EQ(u.tx.vin[0].prevout.index, 0xffffffffu); + EXPECT_EQ(u.tx.vin[0].sequence, 0xffffffffu); + EXPECT_EQ(u.tx.vout.size(), 4u); // P1,P2,donation,op_return +} + +// (2) INVERSE PAIRING: assemble (#173 path) -> unpack recovers bytes + txid. +TEST(DGB_gentx_unpack, IsInverseOfAssembleNoSegwit) { + auto opret = make_opret(); + auto g = dgb::coin::assemble_gentx_coinbase( + CB, std::nullopt, PAYOUTS, /*donation_amount=*/1, DON, opret); + auto u = dgb::coin::unpack_gentx_coinbase(g.bytes); + EXPECT_EQ(noseg_hex(u.tx), tohex(g.bytes)); // exact inverse + EXPECT_EQ(u.txid.GetHex(), g.txid.GetHex()); // txid == GentxCoinbase.txid +} + +TEST(DGB_gentx_unpack, IsInverseOfAssembleSegwitCommitment) { + auto opret = make_opret(); + auto wc = make_wc(); + auto g = dgb::coin::assemble_gentx_coinbase( + CB, std::optional>(wc), PAYOUTS, + /*donation_amount=*/1, DON, opret); + auto u = dgb::coin::unpack_gentx_coinbase(g.bytes); + EXPECT_EQ(noseg_hex(u.tx), tohex(g.bytes)); + EXPECT_EQ(u.txid.GetHex(), g.txid.GetHex()); +} + +// Robustness: a trailing byte past a complete tx is a malformed gentx -> throw. +TEST(DGB_gentx_unpack, TrailingBytesThrow) { + auto bytes = unhex(NOSEG_BYTES); + bytes.push_back(0x00); // one extra byte after a complete tx + EXPECT_THROW(dgb::coin::unpack_gentx_coinbase(bytes), std::out_of_range); +} diff --git a/src/impl/dgb/test/other_tx_assembler_test.cpp b/src/impl/dgb/test/other_tx_assembler_test.cpp new file mode 100644 index 000000000..e9261500c --- /dev/null +++ b/src/impl/dgb/test/other_tx_assembler_test.cpp @@ -0,0 +1,254 @@ +// --------------------------------------------------------------------------- +// dgb_other_tx_assembler_test -- pins coin/other_tx_assembler.hpp, the won-block +// reconstructor's (#82 sub-slice 2) bridge from the ordered other_tx hash list +// (resolve_other_tx_hashes, sub-slice 1) to the MutableTransaction vector that +// assemble_won_block frames as txs = [gentx] ++ other_txs. +// +// Faithful to p2pool data.py as_block: other_txs = [known_txs[h] for h in +// transaction_hashes]. Proves: +// * the known_txs lookup PRESERVES ref/hash order (= block other_txs order, +// which fixes the merkle root) and is the SAME order assemble_won_block frames; +// * a hash absent from known_txs THROWS std::out_of_range (loud failure: a +// dropped other_tx => wrong merkle root => daemon-rejected block); +// * empty hash list => empty other_txs; +// * END-TO-END: resolve_other_tx_hashes -> assemble_other_txs -> assemble_won_block +// lands the resolved txs in the block in order, after the coinbase; +// * WITNESS reachability via the other_tx path: a witness-bearing other_tx +// flips the assembled block to the TX_WITH_WITNESS codec (DGB is +// segwit-active), proving witness framing is not gentx-only dead code. +// +// Links the dgb OBJECT lib (assemble_won_block reuses dgb::check_merkle_link + +// the live BlockType submitblock codec) like dgb_block_assembly_test. MUST also +// appear in the build.yml --target allowlist (#143 NOT_BUILT trap). +// --------------------------------------------------------------------------- + +#include + +#include +#include +#include +#include + +#include +#include + +#include "../coin/other_tx_assembler.hpp" +#include "../coin/other_tx_resolver.hpp" +#include "../coin/block_assembly.hpp" + +namespace { + +using dgb::coin::MutableTransaction; +using dgb::coin::BlockType; +using dgb::coin::SmallBlockHeaderType; +using dgb::coin::assemble_other_txs; +using dgb::coin::assemble_won_block; +using dgb::coin::resolve_other_tx_hashes; +using dgb::TxHashRefs; + +uint256 H(const char* two) +{ + std::string s; + for (int i = 0; i < 32; ++i) s += two; + uint256 h; h.SetHex(s); + return h; +} + +// A tx whose output value tags it, so we can assert identity/order after the +// lookup and after the block round-trip. +MutableTransaction tagged_tx(int64_t value) +{ + MutableTransaction tx; + tx.version = 1; + tx.locktime = 0; + dgb::coin::TxIn in; + in.prevout.hash.SetNull(); + in.prevout.index = 0; + in.sequence = 0xffffffff; + tx.vin.push_back(in); + dgb::coin::TxOut out; + out.value = value; + tx.vout.push_back(out); + return tx; +} + +MutableTransaction witness_tx(int64_t value) +{ + auto tx = tagged_tx(value); + tx.vin[0].scriptWitness.stack.assign(1, std::vector(4, 0xab)); + return tx; +} + +MutableTransaction coinbase_gentx() +{ + MutableTransaction tx; + tx.version = 1; + tx.locktime = 0; + dgb::coin::TxIn in; + in.prevout.hash.SetNull(); + in.prevout.index = 0xffffffff; + in.sequence = 0xffffffff; + tx.vin.push_back(in); + dgb::coin::TxOut out; + out.value = 5000000000LL; + tx.vout.push_back(out); + return tx; +} + +SmallBlockHeaderType small_header() +{ + SmallBlockHeaderType h; + h.m_version = 0x20000000; + h.m_previous_block.SetHex("00000000000000000000000000000000000000000000000000000000deadbeef"); + h.m_timestamp = 1718700000; + h.m_bits = 0x1a0fffff; + h.m_nonce = 0x12345678; + return h; +} + +// A synthetic known_txs store keyed by hash, with a lookup matching the +// assembler's injected (hash)->const MutableTransaction* contract. +struct KnownTxs +{ + std::map by_hash; + const MutableTransaction* lookup(const uint256& h) const + { + auto it = by_hash.find(h); + return it == by_hash.end() ? nullptr : &it->second; + } + std::function fn() const + { + return [this](const uint256& h) { return lookup(h); }; + } +}; + +// --- Test 1: lookup preserves order ---------------------------------------- +TEST(DgbOtherTxAssembler, PreservesHashOrder) +{ + KnownTxs k; + k.by_hash[H("c0")] = tagged_tx(10); + k.by_hash[H("c1")] = tagged_tx(20); + k.by_hash[H("c2")] = tagged_tx(30); + + // hashes deliberately NOT in map-sorted order -- output must follow input. + std::vector hashes = { H("c2"), H("c0"), H("c1") }; + auto txs = assemble_other_txs(hashes, k.fn()); + + ASSERT_EQ(txs.size(), 3u); + EXPECT_EQ(txs[0].vout[0].value, 30); + EXPECT_EQ(txs[1].vout[0].value, 10); + EXPECT_EQ(txs[2].vout[0].value, 20); +} + +// --- Test 2: missing hash throws (loud failure, no partial block) ---------- +TEST(DgbOtherTxAssembler, MissingHashThrows) +{ + KnownTxs k; + k.by_hash[H("c0")] = tagged_tx(10); + + std::vector hashes = { H("c0"), H("ff") }; // ff is unknown + EXPECT_THROW(assemble_other_txs(hashes, k.fn()), std::out_of_range); +} + +// --- Test 3: empty hash list => empty other_txs ---------------------------- +TEST(DgbOtherTxAssembler, EmptyHashesEmptyTxs) +{ + KnownTxs k; + std::vector none; + auto txs = assemble_other_txs(none, k.fn()); + EXPECT_TRUE(txs.empty()); +} + +// --- Test 4: end-to-end resolve -> assemble -> won-block -------------------- +// Walks the full reconstructor bridge: a 2-share ancestry's refs resolve to +// hashes (sub-slice 1), the hashes resolve to txs (sub-slice 2), and the txs +// frame into the block after the coinbase, in order. +TEST(DgbOtherTxAssembler, EndToEndResolveAssembleFrame) +{ + // ancestry: won <- p1 ; won.nths=[c0,c1], p1.nths=[d0] + const uint256 won = H("a0"), p1 = H("a1"); + std::map parent = { {won, p1} }; + std::map> nths = { + {won, { H("c0"), H("c1") }}, + {p1, { H("d0") }}, + }; + auto nth_parent_fn = [&](const uint256& start, uint64_t n) -> uint256 { + uint256 cur = start; + for (uint64_t i = 0; i < n; ++i) { + auto it = parent.find(cur); + if (it == parent.end()) { uint256 z; z.SetNull(); return z; } + cur = it->second; + } + return cur; + }; + auto new_tx_hashes_fn = [&](const uint256& h) -> const std::vector& { + return nths.at(h); + }; + + // refs: (0,1)=won.nths[1]=c1 ; (1,0)=p1.nths[0]=d0 ; (0,0)=won.nths[0]=c0 + std::vector refs = { {0,1}, {1,0}, {0,0} }; + auto hashes = resolve_other_tx_hashes(won, refs, nth_parent_fn, new_tx_hashes_fn); + ASSERT_EQ(hashes.size(), 3u); + EXPECT_EQ(hashes[0], H("c1")); + EXPECT_EQ(hashes[1], H("d0")); + EXPECT_EQ(hashes[2], H("c0")); + + KnownTxs k; + k.by_hash[H("c0")] = tagged_tx(100); + k.by_hash[H("c1")] = tagged_tx(200); + k.by_hash[H("d0")] = tagged_tx(300); + auto other = assemble_other_txs(hashes, k.fn()); + + auto gentx = coinbase_gentx(); + uint256 gtx_hash; gtx_hash.SetHex("1111111111111111111111111111111111111111111111111111111111111111"); + ::dgb::MerkleLink link; // empty branch => root == gtx_hash + auto [bytes, hex] = assemble_won_block(small_header(), gentx, gtx_hash, link, other); + + PackStream ps(bytes); + BlockType blk; + ps >> blk; + // [gentx] ++ other_txs, in resolved (ref) order: 200 (c1), 300 (d0), 100 (c0) + ASSERT_EQ(blk.m_txs.size(), 4u); + EXPECT_EQ(blk.m_txs[0].vin[0].prevout.index, 0xffffffffu); // coinbase first + EXPECT_EQ(blk.m_txs[1].vout[0].value, 200); + EXPECT_EQ(blk.m_txs[2].vout[0].value, 300); + EXPECT_EQ(blk.m_txs[3].vout[0].value, 100); +} + +// --- Test 5: witness-bearing other_tx flips block to TX_WITH_WITNESS -------- +// Proves the segwit-active path is reachable via the other_tx leg (not just the +// gentx): an other_tx carrying a witness stack makes the conditional codec emit +// a witness block, and the witness round-trips through BlockType. +TEST(DgbOtherTxAssembler, WitnessOtherTxEmitsWitnessBlock) +{ + KnownTxs k; + k.by_hash[H("c0")] = witness_tx(42); + std::vector hashes = { H("c0") }; + auto other = assemble_other_txs(hashes, k.fn()); + ASSERT_EQ(other.size(), 1u); + ASSERT_TRUE(other[0].HasWitness()); + + auto gentx = coinbase_gentx(); // legacy coinbase + ASSERT_FALSE(gentx.HasWitness()); + uint256 gtx_hash; gtx_hash.SetHex("1111111111111111111111111111111111111111111111111111111111111111"); + ::dgb::MerkleLink link; + + auto [wbytes, whex] = assemble_won_block(small_header(), gentx, gtx_hash, link, other); + // same block but with a legacy other_tx => strictly smaller (no witness). + KnownTxs kl; kl.by_hash[H("c0")] = tagged_tx(42); + auto legacy_other = assemble_other_txs(hashes, kl.fn()); + auto [lbytes, lhex] = assemble_won_block(small_header(), gentx, gtx_hash, link, legacy_other); + EXPECT_GT(wbytes.size(), lbytes.size()); + + PackStream ps(wbytes); + BlockType blk; + ps >> blk; + ASSERT_EQ(blk.m_txs.size(), 2u); + EXPECT_FALSE(blk.m_txs[0].HasWitness()); // coinbase legacy + EXPECT_TRUE(blk.m_txs[1].HasWitness()); // other_tx carries witness + ASSERT_EQ(blk.m_txs[1].vin[0].scriptWitness.stack.size(), 1u); + EXPECT_EQ(blk.m_txs[1].vin[0].scriptWitness.stack[0], + std::vector(4, 0xab)); +} + +} // namespace diff --git a/src/impl/dgb/test/other_tx_resolver_test.cpp b/src/impl/dgb/test/other_tx_resolver_test.cpp new file mode 100644 index 000000000..43f18a748 --- /dev/null +++ b/src/impl/dgb/test/other_tx_resolver_test.cpp @@ -0,0 +1,149 @@ +// --------------------------------------------------------------------------- +// dgb_other_tx_resolver_test -- pins coin/other_tx_resolver.hpp, the won-block +// reconstructor's (#82) transaction_hash_refs -> ordered other_tx hash walk. +// +// Proves the ref-walk semantics against a SYNTHETIC ancestry (no live +// ShareTracker): the two tracker operations (nth_parent / new_transaction_hashes +// lookup) are injected, exactly as resolve_other_tx_hashes takes them, so the +// faithful p2pool get_other_tx_hashes resolution is verified in isolation: +// * share_count == 0 indexes the won share's OWN new_transaction_hashes +// * share_count == N walks back N generations +// * ref ORDER is the output order (= block other_txs order) +// * empty refs => empty list +// * malformed refs (tx_count OOB / walk past chain end) throw, never emit a +// wrong hash +// +// Header-only: links no dgb OBJECT lib, only core (uint256) + GTest. MUST also +// appear in the build.yml --target allowlist (#143 NOT_BUILT trap). +// --------------------------------------------------------------------------- +#include + +#include +#include +#include +#include + +#include + +#include "../coin/other_tx_resolver.hpp" + +using dgb::coin::resolve_other_tx_hashes; +using dgb::TxHashRefs; + +namespace { + +uint256 H(const char* two) // 0x"two" repeated to 64 hex chars +{ + std::string s; + for (int i = 0; i < 32; ++i) s += two; + uint256 h; h.SetHex(s); + return h; +} + +// A synthetic 3-share chain: won <- p1 <- p2. Each share carries its own +// new_transaction_hashes. parent[h] gives the immediate parent; null beyond p2. +struct Ancestry +{ + uint256 won = H("a0"), p1 = H("a1"), p2 = H("a2"); + std::map parent; // child -> parent + std::map> nths; // share -> new_transaction_hashes + + Ancestry() + { + parent[won] = p1; + parent[p1] = p2; + // p2 has no parent -> walking past it returns null. + nths[won] = { H("c0"), H("c1") }; + nths[p1] = { H("d0") }; + nths[p2] = { H("e0"), H("e1"), H("e2") }; + } + + // nth_parent_fn: 0 => start itself; null if walk runs off the chain. + uint256 nth_parent(const uint256& start, uint64_t n) const + { + uint256 cur = start; + for (uint64_t i = 0; i < n; ++i) + { + auto it = parent.find(cur); + if (it == parent.end()) return uint256(); // null + cur = it->second; + } + return cur; + } + + const std::vector& new_tx_hashes(const uint256& h) const + { + return nths.at(h); + } +}; + +std::function walk_of(const Ancestry& a) +{ + return [&a](const uint256& s, uint64_t n) { return a.nth_parent(s, n); }; +} +std::function&(const uint256&)> nths_of(const Ancestry& a) +{ + return [&a](const uint256& h) -> const std::vector& { return a.new_tx_hashes(h); }; +} + +} // namespace + +// share_count == 0 resolves against the won share's own new_transaction_hashes. +TEST(OtherTxResolver, SelfShareRefs) +{ + Ancestry a; + std::vector refs = { {0, 1}, {0, 0} }; + auto out = resolve_other_tx_hashes(a.won, refs, walk_of(a), nths_of(a)); + ASSERT_EQ(out.size(), 2u); + EXPECT_EQ(out[0], H("c1")); // won.new_tx[1] + EXPECT_EQ(out[1], H("c0")); // won.new_tx[0] -- ref ORDER preserved +} + +// share_count == N walks N generations back before indexing. +TEST(OtherTxResolver, AncestorWalkBack) +{ + Ancestry a; + std::vector refs = { {1, 0}, {2, 2} }; + auto out = resolve_other_tx_hashes(a.won, refs, walk_of(a), nths_of(a)); + ASSERT_EQ(out.size(), 2u); + EXPECT_EQ(out[0], H("d0")); // p1.new_tx[0] + EXPECT_EQ(out[1], H("e2")); // p2.new_tx[2] +} + +// Mixed refs across generations preserve emission order = block other_txs order. +TEST(OtherTxResolver, MixedOrderPreserved) +{ + Ancestry a; + std::vector refs = { {2, 0}, {0, 1}, {1, 0} }; + auto out = resolve_other_tx_hashes(a.won, refs, walk_of(a), nths_of(a)); + ASSERT_EQ(out.size(), 3u); + EXPECT_EQ(out[0], H("e0")); + EXPECT_EQ(out[1], H("c1")); + EXPECT_EQ(out[2], H("d0")); +} + +// No refs => empty other_txs (coinbase-only block). +TEST(OtherTxResolver, EmptyRefs) +{ + Ancestry a; + auto out = resolve_other_tx_hashes(a.won, {}, walk_of(a), nths_of(a)); + EXPECT_TRUE(out.empty()); +} + +// tx_count past the ancestor's new_transaction_hashes is a malformed share. +TEST(OtherTxResolver, TxCountOutOfRangeThrows) +{ + Ancestry a; + std::vector refs = { {1, 5} }; // p1 has only 1 new tx + EXPECT_THROW(resolve_other_tx_hashes(a.won, refs, walk_of(a), nths_of(a)), + std::out_of_range); +} + +// share_count past the chain end is a malformed share (null ancestor). +TEST(OtherTxResolver, WalkPastChainEndThrows) +{ + Ancestry a; + std::vector refs = { {3, 0} }; // only won<-p1<-p2 exist + EXPECT_THROW(resolve_other_tx_hashes(a.won, refs, walk_of(a), nths_of(a)), + std::out_of_range); +} diff --git a/src/impl/dgb/test/reconstruct_won_block_test.cpp b/src/impl/dgb/test/reconstruct_won_block_test.cpp new file mode 100644 index 000000000..e4a4418d1 --- /dev/null +++ b/src/impl/dgb/test/reconstruct_won_block_test.cpp @@ -0,0 +1,257 @@ +// --------------------------------------------------------------------------- +// dgb_reconstruct_won_block_test -- pins coin/reconstruct_won_block.hpp, the +// won-block reconstructor's (#82) BODY: the single composition the dispatch +// handler injects as its WonBlockReconstructor. Proves the three landed +// sub-slices compose into a faithful p2pool Share.as_block, end to end, against +// a SYNTHETIC ancestry + known-tx set (no live ShareTracker / mempool): +// * refs --resolve--> ordered hashes --assemble--> txs --frame--> {bytes,hex} +// * the composed bytes are IDENTICAL to assemble_won_block fed the manually +// resolved other_txs (no step reorders / drops / duplicates), hex==HexStr; +// * block tx layout is [gentx] ++ other_txs in transaction_hash_refs order; +// * empty refs => block carries the gentx only; +// * a malformed ref (walk past chain end) and an unknown other_tx hash each +// PROPAGATE as std::out_of_range -- the reconstructor never emits a partial +// or wrong block. +// +// Links the dgb OBJECT lib like dgb_block_assembly_test (block_assembly.hpp pulls +// BlockType / MutableTransaction / check_merkle_link). MUST also appear in the +// build.yml --target allowlist (#143 NOT_BUILT trap). +// --------------------------------------------------------------------------- +#include + +#include +#include +#include +#include +#include + +#include +#include +#include + +#include "../coin/reconstruct_won_block.hpp" + +using dgb::coin::reconstruct_won_block; +using dgb::coin::assemble_won_block; +using dgb::coin::resolve_other_tx_hashes; +using dgb::coin::assemble_other_txs; +using dgb::coin::ReconstructedWonBlock; +using dgb::coin::BlockType; +using dgb::coin::SmallBlockHeaderType; +using dgb::coin::MutableTransaction; +using dgb::TxHashRefs; + +namespace { + +uint256 H(const char* two) // 0x"two" repeated to 64 hex chars +{ + std::string s; + for (int i = 0; i < 32; ++i) s += two; + uint256 h; h.SetHex(s); + return h; +} + +MutableTransaction make_tx(int64_t value) +{ + MutableTransaction tx; + tx.version = 1; + tx.locktime = 0; + dgb::coin::TxIn in; + in.prevout.hash.SetNull(); + in.prevout.index = 0; + in.sequence = 0xffffffff; + tx.vin.push_back(in); + dgb::coin::TxOut out; + out.value = value; + tx.vout.push_back(out); + return tx; +} + +MutableTransaction make_gentx() +{ + MutableTransaction tx; + tx.version = 1; + tx.locktime = 0; + dgb::coin::TxIn in; + in.prevout.hash.SetNull(); + in.prevout.index = 0xffffffff; // coinbase + in.sequence = 0xffffffff; + tx.vin.push_back(in); + dgb::coin::TxOut out; + out.value = 5000000000LL; + tx.vout.push_back(out); + return tx; +} + +SmallBlockHeaderType make_small_header() +{ + SmallBlockHeaderType h; + h.m_version = 0x20000000; + h.m_previous_block.SetHex("00000000000000000000000000000000000000000000000000000000deadbeef"); + h.m_timestamp = 1718700000; + h.m_bits = 0x1a0fffff; + h.m_nonce = 0x12345678; + return h; +} + +uint256 fixed_gentx_hash() +{ + uint256 h; + h.SetHex("1111111111111111111111111111111111111111111111111111111111111111"); + return h; +} + +// Synthetic ancestry won <- p1 <- p2, each share carrying new_transaction_hashes, +// plus a known_txs store mapping those hashes to distinct MutableTransactions. +struct Fixture +{ + uint256 won = H("a0"), p1 = H("a1"), p2 = H("a2"); + std::map parent; + std::map> nths; + std::map known; + + Fixture() + { + parent[won] = p1; + parent[p1] = p2; + nths[won] = { H("c0"), H("c1") }; + nths[p1] = { H("d0") }; + nths[p2] = { H("e0"), H("e1") }; + // distinct values so order is observable downstream + known[H("c1")] = make_tx(11); + known[H("d0")] = make_tx(22); + known[H("c0")] = make_tx(33); + } + + std::function nth_parent() const + { + auto p = parent; + return [p](const uint256& start, uint64_t n) { + uint256 cur = start; + for (uint64_t i = 0; i < n; ++i) { + auto it = p.find(cur); + if (it == p.end()) return uint256(); + cur = it->second; + } + return cur; + }; + } + std::function&(const uint256&)> new_tx() const + { + return [this](const uint256& h) -> const std::vector& { return nths.at(h); }; + } + std::function known_fn() const + { + return [this](const uint256& h) -> const MutableTransaction* { + auto it = known.find(h); + return it == known.end() ? nullptr : &it->second; + }; + } +}; + +// --- Test 1: end-to-end composition == manual resolve+assemble+frame ---------- +TEST(DgbReconstructWonBlock, ComposesToIdenticalBlock) +{ + Fixture f; + auto sh = make_small_header(); + auto gentx = make_gentx(); + auto gh = fixed_gentx_hash(); + ::dgb::MerkleLink link; // empty branch => root == gentx_hash + // refs: (0,1) -> won.nths[1]=c1 ; (1,0) -> p1.nths[0]=d0 + std::vector refs = { TxHashRefs(0, 1), TxHashRefs(1, 0) }; + + auto got = reconstruct_won_block(sh, link, gentx, gh, f.won, refs, + f.nth_parent(), f.new_tx(), f.known_fn()); + + // Independently drive the three SSOT steps and frame directly. + auto hashes = resolve_other_tx_hashes(f.won, refs, f.nth_parent(), f.new_tx()); + auto others = assemble_other_txs(hashes, f.known_fn()); + auto direct = assemble_won_block(sh, gentx, gh, link, others); + + EXPECT_EQ(got.bytes, direct.first); + EXPECT_EQ(got.hex, direct.second); + auto sp = std::span( + reinterpret_cast(got.bytes.data()), got.bytes.size()); + EXPECT_EQ(got.hex, HexStr(sp)); +} + +// --- Test 2: tx layout is [gentx] ++ other_txs in ref order ------------------- +TEST(DgbReconstructWonBlock, CoinbaseFirstThenRefOrder) +{ + Fixture f; + auto sh = make_small_header(); + auto gentx = make_gentx(); + auto gh = fixed_gentx_hash(); + ::dgb::MerkleLink link; + std::vector refs = { TxHashRefs(0, 1), TxHashRefs(1, 0) }; + + auto got = reconstruct_won_block(sh, link, gentx, gh, f.won, refs, + f.nth_parent(), f.new_tx(), f.known_fn()); + + PackStream ps(got.bytes); + BlockType blk; + ps >> blk; + + EXPECT_EQ(blk.m_merkle_root, gh); // empty link => root == gentx_hash + ASSERT_EQ(blk.m_txs.size(), 3u); // gentx + 2 others + EXPECT_EQ(blk.m_txs[0].vin[0].prevout.index, 0xffffffffu); // coinbase at 0 + EXPECT_TRUE(blk.m_txs[0].vin[0].prevout.hash.IsNull()); + EXPECT_EQ(blk.m_txs[1].vout[0].value, 11); // c1 -> make_tx(11), ref order + EXPECT_EQ(blk.m_txs[2].vout[0].value, 22); // d0 -> make_tx(22) +} + +// --- Test 3: empty refs => block carries only the gentx ----------------------- +TEST(DgbReconstructWonBlock, EmptyRefsGentxOnly) +{ + Fixture f; + auto sh = make_small_header(); + auto gentx = make_gentx(); + auto gh = fixed_gentx_hash(); + ::dgb::MerkleLink link; + std::vector refs; // none + + auto got = reconstruct_won_block(sh, link, gentx, gh, f.won, refs, + f.nth_parent(), f.new_tx(), f.known_fn()); + + PackStream ps(got.bytes); + BlockType blk; + ps >> blk; + ASSERT_EQ(blk.m_txs.size(), 1u); + EXPECT_EQ(blk.m_txs[0].vin[0].prevout.index, 0xffffffffu); +} + +// --- Test 4: malformed ref (walk past chain end) propagates as throw ---------- +TEST(DgbReconstructWonBlock, RefPastChainThrows) +{ + Fixture f; + auto sh = make_small_header(); + auto gentx = make_gentx(); + auto gh = fixed_gentx_hash(); + ::dgb::MerkleLink link; + // share_count 9 walks far past p2 (the chain end) -> null ancestor. + std::vector refs = { TxHashRefs(9, 0) }; + + EXPECT_THROW( + reconstruct_won_block(sh, link, gentx, gh, f.won, refs, + f.nth_parent(), f.new_tx(), f.known_fn()), + std::out_of_range); +} + +// --- Test 5: unknown other_tx hash propagates as throw ------------------------ +TEST(DgbReconstructWonBlock, UnknownKnownTxThrows) +{ + Fixture f; + f.known.erase(H("c1")); // resolves but is absent from known_txs + auto sh = make_small_header(); + auto gentx = make_gentx(); + auto gh = fixed_gentx_hash(); + ::dgb::MerkleLink link; + std::vector refs = { TxHashRefs(0, 1) }; // -> c1, now missing + + EXPECT_THROW( + reconstruct_won_block(sh, link, gentx, gh, f.won, refs, + f.nth_parent(), f.new_tx(), f.known_fn()), + std::out_of_range); +} + +} // namespace