diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index a57a00155..907fb5abd 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -147,7 +147,7 @@ jobs: test_mweb_builder \ test_address_resolution test_compute_share_target test_web_honesty_regression \ test_utxo test_dgb_subsidy test_dgb_coinbase_value dgb_share_test dgb_redistribute_test dgb_block_assembly_test dgb_witness_commitment_test dgb_header_sample_build_test dgb_header_ingest_test dgb_mempool_ingest_test \ - dgb_gentx_coinbase_test dgb_connection_coinbase_test dgb_pplns_payout_split_test nmc_auxpow_merkle_test nmc_template_builder_test nmc_underfill_guard_test nmc_auxpow_wire_test nmc_reconstruct_won_block_test nmc_mempool_name_test nmc_block_broadcast_test nmc_host_dualpath_test nmc_fallback_path_conformance_test dgb_gentx_share_path_test dgb_conn_pplns_producer_test dgb_other_tx_resolver_test \ + dgb_gentx_coinbase_test dgb_connection_coinbase_test dgb_won_block_serialize_test dgb_pplns_payout_split_test nmc_auxpow_merkle_test nmc_template_builder_test nmc_underfill_guard_test nmc_auxpow_wire_test nmc_reconstruct_won_block_test nmc_mempool_name_test nmc_block_broadcast_test nmc_host_dualpath_test nmc_fallback_path_conformance_test dgb_gentx_share_path_test dgb_conn_pplns_producer_test dgb_other_tx_resolver_test \ dgb_other_tx_assembler_test dgb_reconstruct_won_block_test dgb_reconstruct_closure_test dgb_gentx_unpack_test dgb_work_source_test dgb_template_builder_test dgb_embedded_coin_node_test dgb_embedded_tx_select_test dgb_template_other_txs_test dgb_coinbase_value_parity_test dgb_submit_classify_test dgb_aux_parent_coinbase_parity_test dgb_template_capture_test dgb_aux_doge_db_commitment_bind_test dgb_aux_doge_mm_commitment_test dgb_aux_doge_dc_proof_test dgb_aux_doge_bind_parsers_test dgb_compact_blocks_bip152_parity_test dgb_aux_dual_target_select_test dgb_aux_broadcast_path_election_test dgb_aux_doge_submit_test dgb_aux_doge_embed_livewire_test dgb_aux_doge_dc_layout_verifier_test \ rpc_request_test softfork_check_test genesis_check_test algo_select_test digishield_walk_test header_chain_test \ dgb_coin_node_seam_test dgb_block_broadcast_test dgb_won_block_dispatch_test dgb_forced_won_share_dualpath_test dgb_scrypt_pow_test dgb_nonce_grinder_test dgb_regrind_block_test dgb_won_block_finalize_test dgb_share_target_genesis_test dgb_share_target_retarget_test dgb_share_bits_oracle_pin_test dgb_pool_msg_wire_test dgb_get_shares_walk_test dgb_download_stops_test dgb_think_p1_walk_bounds_test dgb_think_p1_desired_emit_test dgb_think_p6_desired_cutoff_test dgb_think_p4_head_keys_test dgb_think_p3_best_head_test dgb_g1_oracle_byte_parity_test dgb_think_p2_walk_bounds_test dgb_expected_time_to_block_test dgb_tail_score_endpoints_test dgb_pool_attempts_per_second_test dgb_pool_efficiency_test dgb_think_p5_best_share_punish_test dgb_auto_ratchet_tail_guard_test dgb_auto_ratchet_sim_test dgb_binomial_conf_interval_test dgb_desired_version_tally_test dgb_min_protocol_ratchet_test dgb_get_height_and_last_endpoints_test dgb_chain_walk_window_test dgb_redistribute_delegate_ghal_test dgb_share_weight_decay_test dgb_naughty_propagation_test dgb_hash_format_parity_test dgb_emergency_decay_saturation_test dgb_arith256_muldiv_kat_test v37_test \ @@ -328,7 +328,7 @@ jobs: test_mweb_builder \ test_address_resolution test_compute_share_target test_web_honesty_regression \ test_utxo test_dgb_subsidy test_dgb_coinbase_value dgb_share_test dgb_redistribute_test dgb_block_assembly_test dgb_witness_commitment_test dgb_header_sample_build_test dgb_header_ingest_test dgb_mempool_ingest_test \ - dgb_gentx_coinbase_test dgb_connection_coinbase_test dgb_pplns_payout_split_test nmc_auxpow_merkle_test nmc_template_builder_test nmc_underfill_guard_test nmc_auxpow_wire_test nmc_reconstruct_won_block_test nmc_mempool_name_test nmc_block_broadcast_test nmc_host_dualpath_test nmc_fallback_path_conformance_test dgb_gentx_share_path_test dgb_conn_pplns_producer_test dgb_other_tx_resolver_test \ + dgb_gentx_coinbase_test dgb_connection_coinbase_test dgb_won_block_serialize_test dgb_pplns_payout_split_test nmc_auxpow_merkle_test nmc_template_builder_test nmc_underfill_guard_test nmc_auxpow_wire_test nmc_reconstruct_won_block_test nmc_mempool_name_test nmc_block_broadcast_test nmc_host_dualpath_test nmc_fallback_path_conformance_test dgb_gentx_share_path_test dgb_conn_pplns_producer_test dgb_other_tx_resolver_test \ dgb_other_tx_assembler_test dgb_reconstruct_won_block_test dgb_reconstruct_closure_test dgb_gentx_unpack_test dgb_work_source_test dgb_template_builder_test dgb_embedded_coin_node_test dgb_embedded_tx_select_test dgb_template_other_txs_test dgb_coinbase_value_parity_test dgb_submit_classify_test dgb_aux_parent_coinbase_parity_test dgb_template_capture_test dgb_aux_doge_db_commitment_bind_test dgb_aux_doge_mm_commitment_test dgb_aux_doge_dc_proof_test dgb_aux_doge_bind_parsers_test dgb_compact_blocks_bip152_parity_test dgb_aux_dual_target_select_test dgb_aux_broadcast_path_election_test dgb_aux_doge_submit_test dgb_aux_doge_embed_livewire_test dgb_aux_doge_dc_layout_verifier_test \ rpc_request_test softfork_check_test genesis_check_test algo_select_test digishield_walk_test header_chain_test \ dgb_coin_node_seam_test dgb_block_broadcast_test dgb_won_block_dispatch_test dgb_forced_won_share_dualpath_test dgb_scrypt_pow_test dgb_nonce_grinder_test dgb_regrind_block_test dgb_won_block_finalize_test dgb_share_target_genesis_test dgb_share_target_retarget_test dgb_share_bits_oracle_pin_test dgb_pool_msg_wire_test dgb_get_shares_walk_test dgb_download_stops_test dgb_think_p1_walk_bounds_test dgb_think_p1_desired_emit_test dgb_think_p6_desired_cutoff_test dgb_think_p4_head_keys_test dgb_think_p3_best_head_test dgb_g1_oracle_byte_parity_test dgb_think_p2_walk_bounds_test dgb_expected_time_to_block_test dgb_tail_score_endpoints_test dgb_pool_attempts_per_second_test dgb_pool_efficiency_test dgb_think_p5_best_share_punish_test dgb_auto_ratchet_tail_guard_test dgb_auto_ratchet_sim_test dgb_binomial_conf_interval_test dgb_desired_version_tally_test dgb_min_protocol_ratchet_test dgb_get_height_and_last_endpoints_test dgb_chain_walk_window_test dgb_redistribute_delegate_ghal_test dgb_share_weight_decay_test dgb_naughty_propagation_test dgb_hash_format_parity_test dgb_emergency_decay_saturation_test dgb_arith256_muldiv_kat_test test_coin_broadcaster test_multiaddress_pplns test_pplns_stress \ diff --git a/src/impl/dgb/coin/won_block_serialize.hpp b/src/impl/dgb/coin/won_block_serialize.hpp new file mode 100644 index 000000000..fb7ac4d17 --- /dev/null +++ b/src/impl/dgb/coin/won_block_serialize.hpp @@ -0,0 +1,158 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later +#pragma once +// --------------------------------------------------------------------------- +// dgb::coin::serialize_won_block -- the won-block wire serializer for the +// Stratum submit path (work_source.cpp mining_submit / #82 dual-path sink). +// +// THE BLOCKER it closes (G3b, bad-txnmrklroot on the empty-embedded-chain +// won block): the reconstruct path hand-rolled the block framing and could +// emit a body the ALREADY-HASHED header does not commit to. Two divergences +// live here, both of which digibyted rejects and neither of which the header +// can be re-derived around (the miner hashed the merkle root already -- any +// post-hoc coinbase mutation invalidates the PoW binding): +// +// 1. TX-SET vs COMMITMENT. The header merkle root is the ascent of the +// FROZEN stratum merkle branches from the coinbase txid. When the job +// carried NO branches (the empty-embedded-chain case: the branch cache +// at get_stratum_merkle_branches() is still a Stage-4c stub returning +// {}) the committed root IS the bare coinbase txid -- i.e. the miner +// committed to a COINBASE-ONLY block. Appending job->tx_data anyway +// produces a body whose recomputed root cannot match -> bad-txnmrklroot, +// deterministically. The only correct emission is the committed one: +// coinbase-only. Dropping the extra txs forfeits their fees; shipping +// them forfeits the whole block. We ship the block. +// +// 2. BIP144 WITNESS FORM vs BIP141 COMMITMENT. A witness-shaped coinbase +// is only legal when it carries the BIP141 commitment output +// (OP_RETURN 0x24 aa21a9ed <32B>); Core's CheckWitnessMalleation rejects +// a witness-bearing coinbase without one (unexpected-witness). The +// PPLNS connection coinbase only carries the commitment when the +// producer seam populates segwit_commitment_script (gentx_coinbase.hpp) +// -- on the empty-chain path it does not. Emitting the marker/flag +// regardless is therefore a second guaranteed reject. A coinbase-only / +// all-legacy block needs no witness at all and Core accepts the legacy +// serialization on a segwit-active chain, so the rule is: witness form +// IFF the coinbase actually carries the commitment. Injecting the +// commitment here is NOT an option -- it changes the coinbase txid and +// breaks the header the miner already hashed. The commitment must be +// present at JOB-BUILD time (coinb2), which is the producer-seam slice. +// +// Pure + byte-level (no tx codec, no chain state), so it is directly KAT-able +// against hand-built vectors. Per-coin isolation: src/impl/dgb/ only; no +// core/ or shared-layer surface. p2pool-merged-v36 surface: NONE -- this is +// parent-block (daemon-facing) framing, not share format / PoW / PPLNS. +// --------------------------------------------------------------------------- + +#include +#include +#include + +namespace dgb::coin +{ + +// BIP141 commitment scriptPubKey prefix: OP_RETURN PUSH36 aa21a9ed. +inline constexpr uint8_t kWitnessCommitmentPrefix[6] = {0x6a, 0x24, 0xaa, 0x21, 0xa9, 0xed}; + +// True iff the coinbase NON-witness serialization carries a BIP141 witness +// commitment output. Byte-scan for the 6-byte scriptPubKey prefix followed by +// the 32-byte commitment -- the same shape Core scans for in +// GetWitnessCommitmentIndex(), without pulling in the tx codec. +inline bool coinbase_has_witness_commitment(const std::vector& coinbase) +{ + if (coinbase.size() < sizeof(kWitnessCommitmentPrefix) + 32) + return false; + const size_t last = coinbase.size() - (sizeof(kWitnessCommitmentPrefix) + 32); + for (size_t i = 0; i <= last; ++i) { + bool hit = true; + for (size_t j = 0; j < sizeof(kWitnessCommitmentPrefix); ++j) { + if (coinbase[i + j] != kWitnessCommitmentPrefix[j]) { hit = false; break; } + } + if (hit) return true; + } + return false; +} + +// BIP144 reserialization of the coinbase: marker/flag after the 4-byte version +// and a single 32-byte all-zero reserved witness item before the 4-byte +// locktime. The txid is UNCHANGED (txid is defined over the non-witness +// bytes), so the header merkle root stays valid. +inline std::vector to_bip144_coinbase(const std::vector& coinbase) +{ + std::vector out; + out.reserve(coinbase.size() + 36); + out.insert(out.end(), coinbase.begin(), coinbase.begin() + 4); // version + out.push_back(0x00); // marker + out.push_back(0x01); // flag + out.insert(out.end(), coinbase.begin() + 4, coinbase.end() - 4); // vin/vout + out.push_back(0x01); // stack_count = 1 + out.push_back(0x20); // item_len = 32 + out.insert(out.end(), 32, 0x00); // reserved value + out.insert(out.end(), coinbase.end() - 4, coinbase.end()); // locktime + return out; +} + +struct WonBlockAssembly +{ + std::vector bytes; // full block wire bytes + size_t tx_count{0}; // txs actually serialized (incl. coinbase) + bool witness_form{false}; // coinbase emitted BIP144? + size_t dropped_txs{0}; // body txs the header does not commit to + bool witness_form_suppressed{false}; // segwit_active but no BIP141 commitment +}; + +// Serialize header || varint(tx_count) || coinbase[+witness] || other_txs, +// enforcing the two invariants documented at the top of this file. +// +// committed_coinbase_only == "the job carried NO merkle branches", i.e. the +// header merkle root IS the bare coinbase txid. +inline WonBlockAssembly serialize_won_block( + const std::vector& header80, + const std::vector& coinbase, + const std::vector>& other_txs, + bool segwit_active, + bool committed_coinbase_only) +{ + WonBlockAssembly out; + + // (1) Emit only the tx set the frozen header actually commits to. + const std::vector> kNone; + const bool drop = committed_coinbase_only && !other_txs.empty(); + const std::vector>& body = drop ? kNone : other_txs; + out.dropped_txs = drop ? other_txs.size() : 0; + + // (2) Witness form IFF the coinbase carries the BIP141 commitment. + const bool has_commitment = coinbase_has_witness_commitment(coinbase); + out.witness_form = segwit_active && has_commitment && coinbase.size() >= 8; + out.witness_form_suppressed = segwit_active && !out.witness_form; + + const std::vector cb = + out.witness_form ? to_bip144_coinbase(coinbase) : coinbase; + + out.tx_count = 1 + body.size(); + + size_t body_bytes = 0; + for (const auto& tx : body) body_bytes += tx.size(); + out.bytes.reserve(header80.size() + 9 + cb.size() + body_bytes); + out.bytes.insert(out.bytes.end(), header80.begin(), header80.end()); + + // tx-count varint (Bitcoin CompactSize). + const uint64_t n = out.tx_count; + if (n < 0xfd) { + out.bytes.push_back(static_cast(n)); + } else if (n <= 0xffff) { + out.bytes.push_back(0xfd); + out.bytes.push_back(static_cast(n & 0xff)); + out.bytes.push_back(static_cast((n >> 8) & 0xff)); + } else { + out.bytes.push_back(0xfe); + for (int i = 0; i < 4; ++i) + out.bytes.push_back(static_cast((n >> (8 * i)) & 0xff)); + } + + out.bytes.insert(out.bytes.end(), cb.begin(), cb.end()); + for (const auto& tx : body) + out.bytes.insert(out.bytes.end(), tx.begin(), tx.end()); + return out; +} + +} // namespace dgb::coin diff --git a/src/impl/dgb/stratum/work_source.cpp b/src/impl/dgb/stratum/work_source.cpp index 7c79d0e4d..e3ec8c7da 100644 --- a/src/impl/dgb/stratum/work_source.cpp +++ b/src/impl/dgb/stratum/work_source.cpp @@ -27,6 +27,7 @@ #include // scrypt_pow_hash (DGB-Scrypt PoW SSOT) #include // classify_submission (Stage-4d decision SSOT) #include // build_connection_coinbase_from_pplns SSOT +#include // serialize_won_block SSOT (won-block framing) #include // compact_to_target (compact bits -> u256) #include @@ -34,7 +35,8 @@ #include // chain::target_to_difficulty (vardiff/pool unit parity) #include // address_to_script (share payout from username) -#include // ParseHex +#include // ParseHex, HexStr +#include // Span (HexStr arg) #include #include @@ -524,37 +526,61 @@ nlohmann::json DGBWorkSource::mining_submit( LOG_WARNING << "[DGB-STRATUM-BLOCK] *** BLOCK FOUND *** user=" << username << " height~=" << height << " job=" << job_id; - // Serialize the block: header || tx_count || coinbase[+witness] || txs. - // A segwit-active coinbase is reserialized in BIP144 form with the - // 32-byte witness reserved value (digibyted validates the aa21a9ed - // commitment by hashing witness_root||reserved; a missing witness -> - // bad-witness-merkle-match -> block rejected). - std::vector coinbase_serialized = coinbase; - if (job->segwit_active) { - const std::array marker_flag = {0x00, 0x01}; - coinbase_serialized.insert(coinbase_serialized.begin() + 4, - marker_flag.begin(), marker_flag.end()); - std::array witness_bytes{}; - witness_bytes[0] = 0x01; // stack_count = 1 - witness_bytes[1] = 0x20; // item_len = 32 (reserved value, all zero) - coinbase_serialized.insert(coinbase_serialized.end() - 4, - witness_bytes.begin(), witness_bytes.end()); - } - + // Serialize the block through the dgb::coin::serialize_won_block SSOT + // (coin/won_block_serialize.hpp). It enforces the two invariants the + // ALREADY-HASHED header pins and the old hand-rolled framing violated: + // + // 1. the body must be exactly the tx set the frozen merkle root + // commits to -- with NO merkle branches in the job the committed + // root is the bare coinbase txid, so the block is coinbase-only; + // appending job->tx_data anyway is a guaranteed bad-txnmrklroot; + // 2. BIP144 witness form is emitted IFF the coinbase carries the + // BIP141 aa21a9ed commitment -- a witness-bearing coinbase + // without one is rejected (unexpected-witness), and the + // commitment cannot be injected here (it would change the + // coinbase txid and invalidate the header the miner hashed). + // + // Both conditions are LOGGED when they fire: they mean an upstream + // job-build seam (branch cache / segwit_commitment_script producer) + // is still unpopulated, and the block ships degraded-but-valid rather + // than complete-but-rejected. static const std::vector kEmptyTxData; const std::vector& txs = job->tx_data ? *job->tx_data : kEmptyTxData; - std::vector block_bytes; - block_bytes.reserve(80 + 9 + coinbase_serialized.size() + txs.size() * 256); - block_bytes.insert(block_bytes.end(), header.begin(), header.end()); - push_varint(block_bytes, 1 + txs.size()); // total tx count (coinbase + others) - block_bytes.insert(block_bytes.end(), - coinbase_serialized.begin(), coinbase_serialized.end()); - for (const auto& tx_hex : txs) { - auto tx_bytes = ParseHex(tx_hex); - block_bytes.insert(block_bytes.end(), tx_bytes.begin(), tx_bytes.end()); + std::vector> other_txs; + other_txs.reserve(txs.size()); + for (const auto& tx_hex : txs) + other_txs.push_back(ParseHex(tx_hex)); + + const dgb::coin::WonBlockAssembly asm_out = dgb::coin::serialize_won_block( + header, coinbase, other_txs, job->segwit_active, + /*committed_coinbase_only=*/job->merkle_branches.empty()); + const std::vector& block_bytes = asm_out.bytes; + + if (asm_out.dropped_txs) { + LOG_ERROR << "[DGB-STRATUM-BLOCK] job carried " << asm_out.dropped_txs + << " body tx(s) but NO merkle branches -- the frozen header" + << " commits to a COINBASE-ONLY block; shipping committed" + << " form (their fees are forfeit). Populate the branch" + << " cache (get_stratum_merkle_branches) to claim them."; } + if (asm_out.witness_form_suppressed) { + LOG_WARNING << "[DGB-STRATUM-BLOCK] segwit-active job but the coinbase" + << " carries no BIP141 commitment output -- emitting LEGACY" + << " (witness-form would be rejected unexpected-witness)." + << " Populate segwit_commitment_script at job build."; + } + + // Won blocks are rare: dump the wire bytes so any daemon-side reject is + // decodable offline without a second live repro. + LOG_WARNING << "[DGB-STRATUM-BLOCK] wire: txs=" << asm_out.tx_count + << " witness_form=" << (asm_out.witness_form ? 1 : 0) + << " merkle_root=" << merkle_root.GetHex() + << " coinbase_txid=" << coinbase_txid.GetHex() + << " branches=" << job->merkle_branches.size() + << " block=" << HexStr(Span(block_bytes.data(), + block_bytes.size())); // Dual-path broadcaster (#82): submit_block_fn_ relays via P2P (primary) // and falls back to the submitblock RPC; true iff it reached >=1 sink. A diff --git a/src/impl/dgb/test/CMakeLists.txt b/src/impl/dgb/test/CMakeLists.txt index cfd287e89..8d54a33ad 100644 --- a/src/impl/dgb/test/CMakeLists.txt +++ b/src/impl/dgb/test/CMakeLists.txt @@ -177,6 +177,19 @@ if (BUILD_TESTING AND GTest_FOUND) dgb_coin pool sharechain) gtest_add_tests(dgb_connection_coinbase_test "" AUTO) + # --- G3b: won-block wire framing KAT ----------------------------------- + # Pins coin/won_block_serialize.hpp: the body emitted for a won block is + # exactly the tx set the FROZEN stratum merkle root commits to (no branches + # => coinbase-only, else bad-txnmrklroot), and BIP144 witness form is + # emitted IFF the coinbase carries the BIP141 aa21a9ed commitment (else + # unexpected-witness). Header-only + byte-level: no chain/tx-codec deps. + # MUST appear in BOTH this registration AND the build.yml --target + # allowlist (#143 NOT_BUILT trap). + add_executable(dgb_won_block_serialize_test won_block_serialize_test.cpp) + target_link_libraries(dgb_won_block_serialize_test PRIVATE + GTest::gtest_main GTest::gtest) + gtest_add_tests(dgb_won_block_serialize_test "" AUTO) + # --- Phase B: PPLNS weights -> consensus-sorted payout outputs KAT ------ # Pins coin/pplns_payout_split.hpp: compute_pplns_payout_split reproduces # steps 2-3 of share_check.hpp generate_share_transaction (the verification diff --git a/src/impl/dgb/test/won_block_serialize_test.cpp b/src/impl/dgb/test/won_block_serialize_test.cpp new file mode 100644 index 000000000..0bd7b4f21 --- /dev/null +++ b/src/impl/dgb/test/won_block_serialize_test.cpp @@ -0,0 +1,163 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later +// DGB G3b — won-block wire framing KAT (coin/won_block_serialize.hpp). +// +// Pins the two invariants the ALREADY-HASHED stratum header imposes on the +// won-block body, both of which the previous hand-rolled framing in +// work_source.cpp mining_submit() violated on the empty-embedded-chain path: +// +// (A) NO merkle branches in the job => the committed merkle root IS the bare +// coinbase txid => the emitted block MUST be coinbase-only. Appending +// job->tx_data anyway is a deterministic bad-txnmrklroot. +// (B) BIP144 witness form IFF the coinbase carries the BIP141 aa21a9ed +// commitment output. A witness-bearing coinbase without one is rejected +// (unexpected-witness), and the commitment CANNOT be injected at submit +// time -- it changes the coinbase txid and invalidates the header. +// +// Plus the structural guarantee the whole reconstruct rests on: the BIP144 +// reserialization leaves the non-witness bytes (hence the txid) untouched. + +#include +#include + +#include +#include + +namespace { + +using dgb::coin::coinbase_has_witness_commitment; +using dgb::coin::serialize_won_block; +using dgb::coin::to_bip144_coinbase; + +std::vector header80() { return std::vector(80, 0x11); } + +// Minimal coinbase-shaped blob: version(4) || body || locktime(4). The framer +// is byte-level (no tx codec), so a shaped blob is a faithful stand-in. +std::vector coinbase_no_commitment() +{ + std::vector cb = {0x01, 0x00, 0x00, 0x00}; // version = 1 + for (int i = 0; i < 40; ++i) cb.push_back(static_cast(0x40 + i)); + cb.insert(cb.end(), {0x00, 0x00, 0x00, 0x00}); // locktime = 0 + return cb; +} + +// Same, with a BIP141 commitment output scriptPubKey embedded in the body: +// OP_RETURN(0x6a) PUSH36(0x24) aa21a9ed <32 bytes>. +std::vector coinbase_with_commitment() +{ + std::vector cb = {0x01, 0x00, 0x00, 0x00}; + for (int i = 0; i < 8; ++i) cb.push_back(static_cast(0x40 + i)); + cb.insert(cb.end(), {0x6a, 0x24, 0xaa, 0x21, 0xa9, 0xed}); + cb.insert(cb.end(), 32, 0x7e); + cb.insert(cb.end(), {0x00, 0x00, 0x00, 0x00}); + return cb; +} + +std::vector body_tx(uint8_t tag) { return std::vector(24, tag); } + +// ---- (A) tx-set vs commitment --------------------------------------------- + +TEST(DgbWonBlockSerialize, NoBranchesDropsBodyTxsSoRootStaysCommitted) +{ + const auto cb = coinbase_no_commitment(); + const std::vector> body = {body_tx(0xa1), body_tx(0xa2)}; + + const auto out = serialize_won_block(header80(), cb, body, + /*segwit_active=*/false, + /*committed_coinbase_only=*/true); + + EXPECT_EQ(out.tx_count, 1u); // coinbase only -- matches the root + EXPECT_EQ(out.dropped_txs, 2u); // and it is REPORTED, never silent + ASSERT_EQ(out.bytes.size(), 80u + 1u + cb.size()); + EXPECT_EQ(out.bytes[80], 0x01); // tx-count varint + EXPECT_TRUE(std::equal(cb.begin(), cb.end(), out.bytes.begin() + 81)); +} + +TEST(DgbWonBlockSerialize, WithBranchesKeepsBodyTxs) +{ + const auto cb = coinbase_no_commitment(); + const std::vector> body = {body_tx(0xb1), body_tx(0xb2)}; + + const auto out = serialize_won_block(header80(), cb, body, + /*segwit_active=*/false, + /*committed_coinbase_only=*/false); + + EXPECT_EQ(out.tx_count, 3u); + EXPECT_EQ(out.dropped_txs, 0u); + EXPECT_EQ(out.bytes.size(), 80u + 1u + cb.size() + 48u); + EXPECT_EQ(out.bytes[80], 0x03); +} + +// ---- (B) witness form vs BIP141 commitment -------------------------------- + +TEST(DgbWonBlockSerialize, SegwitActiveWithoutCommitmentEmitsLegacy) +{ + const auto cb = coinbase_no_commitment(); + ASSERT_FALSE(coinbase_has_witness_commitment(cb)); + + const auto out = serialize_won_block(header80(), cb, {}, + /*segwit_active=*/true, + /*committed_coinbase_only=*/true); + + EXPECT_FALSE(out.witness_form); + EXPECT_TRUE(out.witness_form_suppressed); + // No marker/flag: the byte after the 4-byte version is the body, not 0x00. + EXPECT_NE(out.bytes[85], 0x00); + EXPECT_EQ(out.bytes.size(), 80u + 1u + cb.size()); +} + +TEST(DgbWonBlockSerialize, SegwitActiveWithCommitmentEmitsBip144) +{ + const auto cb = coinbase_with_commitment(); + ASSERT_TRUE(coinbase_has_witness_commitment(cb)); + + const auto out = serialize_won_block(header80(), cb, {}, + /*segwit_active=*/true, + /*committed_coinbase_only=*/true); + + ASSERT_TRUE(out.witness_form); + EXPECT_FALSE(out.witness_form_suppressed); + EXPECT_EQ(out.bytes.size(), 80u + 1u + cb.size() + 36u); + EXPECT_EQ(out.bytes[85], 0x00); // marker + EXPECT_EQ(out.bytes[86], 0x01); // flag + // witness stack: 1 item of 32 zero bytes, immediately before the locktime. + const size_t wit = out.bytes.size() - 4 - 34; + EXPECT_EQ(out.bytes[wit], 0x01); + EXPECT_EQ(out.bytes[wit + 1], 0x20); + for (size_t i = 0; i < 32; ++i) EXPECT_EQ(out.bytes[wit + 2 + i], 0x00); +} + +TEST(DgbWonBlockSerialize, SegwitInactiveNeverEmitsWitnessEvenWithCommitment) +{ + const auto cb = coinbase_with_commitment(); + const auto out = serialize_won_block(header80(), cb, {}, + /*segwit_active=*/false, + /*committed_coinbase_only=*/true); + EXPECT_FALSE(out.witness_form); + EXPECT_FALSE(out.witness_form_suppressed); + EXPECT_EQ(out.bytes.size(), 80u + 1u + cb.size()); +} + +// ---- structural: BIP144 reserialize preserves the txid preimage ------------ + +TEST(DgbWonBlockSerialize, Bip144ReserializePreservesNonWitnessBytes) +{ + const auto cb = coinbase_with_commitment(); + const auto wcb = to_bip144_coinbase(cb); + + ASSERT_EQ(wcb.size(), cb.size() + 36u); + std::vector stripped; + stripped.insert(stripped.end(), wcb.begin(), wcb.begin() + 4); // version + stripped.insert(stripped.end(), wcb.begin() + 6, wcb.end() - 4 - 34); // vin/vout + stripped.insert(stripped.end(), wcb.end() - 4, wcb.end()); // locktime + EXPECT_EQ(stripped, cb); // txid preimage unchanged -> header stays valid +} + +TEST(DgbWonBlockSerialize, CommitmentScanRejectsTruncatedPrefix) +{ + std::vector cb = {0x01, 0x00, 0x00, 0x00, + 0x6a, 0x24, 0xaa, 0x21, 0xa9, 0xed}; + cb.insert(cb.end(), 31, 0x00); // one byte short of the 32-byte commitment + EXPECT_FALSE(coinbase_has_witness_commitment(cb)); +} + +} // namespace