diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 16d7336d1..626778abe 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -59,7 +59,7 @@ jobs: test_threading test_weights \ test_header_chain test_mempool test_template_builder \ test_doge_chain test_compact_blocks test_dash_x11_kat \ - test_dash_header_chain \ + test_dash_header_chain test_dash_block_replay \ test_multiaddress_pplns test_pplns_stress \ test_hash_link test_decay_pplns \ test_pplns_consensus \ @@ -186,7 +186,7 @@ jobs: test_threading test_weights \ test_header_chain test_mempool test_template_builder \ test_doge_chain test_compact_blocks test_dash_x11_kat \ - test_dash_header_chain \ + test_dash_header_chain test_dash_block_replay \ test_hash_link test_decay_pplns \ test_pplns_consensus \ test_v36_script_sorting test_v36_cross_impl_refhash \ diff --git a/src/impl/dash/CMakeLists.txt b/src/impl/dash/CMakeLists.txt index 0835326cf..f3a2f9e8d 100644 --- a/src/impl/dash/CMakeLists.txt +++ b/src/impl/dash/CMakeLists.txt @@ -7,3 +7,14 @@ # is confined to src/impl/dash/* and is SAFE-ADDITIVE: no existing target links # dash_x11, so ltc/btc/doge/dgb build + ctest surfaces are untouched. add_subdirectory(crypto) + +# S5 block-replay slice (PR-0 foundation decomposition): compact-block +# reconstruction primitives (BIP-152 short-tx-id / PartiallyDownloadedBlock), +# vendored from dashcore with the C2POOL_SERIALIZE_METHODS idiom. SAFE-ADDITIVE: +# additive static lib; no existing target links dash_block_replay, so the +# ltc/btc/doge/dgb build + ctest surfaces are untouched. Block deserialization +# is TX_WITH_WITNESS-gated via bitcoin_family::coin (read-only TxParams const -- +# no bitcoin_family/ touch). Replay ctest (test_dash_block_replay) wires in test/. +add_library(dash_block_replay STATIC coin/vendor/blockencodings.cpp) +target_include_directories(dash_block_replay PRIVATE ${CMAKE_SOURCE_DIR}/src) +target_link_libraries(dash_block_replay PRIVATE core nlohmann_json::nlohmann_json ${Boost_LIBRARIES}) diff --git a/src/impl/dash/coin/vendor/blockencodings.cpp b/src/impl/dash/coin/vendor/blockencodings.cpp new file mode 100644 index 000000000..e49fe1c22 --- /dev/null +++ b/src/impl/dash/coin/vendor/blockencodings.cpp @@ -0,0 +1,231 @@ +// Copyright (c) 2016-2020 The Bitcoin Core developers +// Copyright (c) 2020-2026 The Dash Core developers +// Distributed under the MIT software license, see the accompanying +// file COPYING or http://www.opensource.org/licenses/mit-license.php. +// +// VENDORED from dashcore/src/blockencodings.cpp. +// Adaptations (see vendor/README.md): +// 1. Dashcore primitives replaced by dash::coin::vendor typedefs. +// 2. CDataStream / streams.h replaced by c2pool's PackStream. +// 3. CheckBlock call removed from FillBlock (c2pool-dash validates +// blocks at higher layers). +// 4. LogPrint(BCLog::CMPCTBLOCK, ...) routed to LOG_DEBUG_COIND. +// 5. Mempool iteration replaced by a caller-supplied snapshot +// (MempoolShortIdProvider). + +#include + +#include +#include +#include +#include + +#include +#include + +#include + +namespace dash { +namespace coin { +namespace vendor { + +// Minimum serialized size of an empty MutableTransaction — used to bound +// the per-block transaction count. Dashcore computes this as +// `GetSerializeSize(CMutableTransaction(), PROTOCOL_VERSION)`; our +// `MutableTransaction` default-ctor serializes to a known fixed minimum +// of 10 bytes (4 version/type + 1 vin count + 1 vout count + 4 locktime). +// Keep as a constant to avoid dragging the whole serialize-size machinery +// in for a single bound check. +static constexpr size_t MIN_TRANSACTION_SIZE = 10; + +CBlockHeaderAndShortTxIDs::CBlockHeaderAndShortTxIDs(const CBlock& block) : + nonce(::core::random::random_nonce()), + shorttxids(block.m_txs.size() - 1), prefilledtxn(1), header(block) { + FillShortTxIDSelector(); + // TODO (post-Phase-M): use our mempool prior to block acceptance to + // predictively fill more than just the coinbase. + prefilledtxn[0] = {0, MakeTransactionRef(block.m_txs[0])}; + for (size_t i = 1; i < block.m_txs.size(); i++) { + const auto& tx = block.m_txs[i]; + auto packed = pack(tx); + uint256 txhash = ::Hash(packed.get_span()); + shorttxids[i - 1] = GetShortID(txhash); + } +} + +void CBlockHeaderAndShortTxIDs::FillShortTxIDSelector() const { + PackStream stream; + stream << header << nonce; + CSHA256 hasher; + hasher.Write(reinterpret_cast(stream.data()), stream.size()); + uint256 shorttxidhash; + hasher.Finalize(shorttxidhash.begin()); + shorttxidk0 = shorttxidhash.GetUint64(0); + shorttxidk1 = shorttxidhash.GetUint64(1); +} + +uint64_t CBlockHeaderAndShortTxIDs::GetShortID(const uint256& txhash) const { + static_assert(SHORTTXIDS_LENGTH == 6, "shorttxids calculation assumes 6-byte shorttxids"); + return SipHashUint256(shorttxidk0, shorttxidk1, txhash) & 0xffffffffffffL; +} + + +ReadStatus PartiallyDownloadedBlock::InitData(const CBlockHeaderAndShortTxIDs& cmpctblock, const std::vector>& extra_txn) { + if (cmpctblock.header.IsNull() || (cmpctblock.shorttxids.empty() && cmpctblock.prefilledtxn.empty())) + return READ_STATUS_INVALID; + if (cmpctblock.shorttxids.size() + cmpctblock.prefilledtxn.size() > max_block_tx_count / MIN_TRANSACTION_SIZE) + return READ_STATUS_INVALID; + + assert(header.IsNull() && txn_available.empty()); + header = cmpctblock.header; + txn_available.resize(cmpctblock.BlockTxCount()); + + int32_t lastprefilledindex = -1; + for (size_t i = 0; i < cmpctblock.prefilledtxn.size(); i++) { + if (!cmpctblock.prefilledtxn[i].tx) + return READ_STATUS_INVALID; + + lastprefilledindex += cmpctblock.prefilledtxn[i].index + 1; //index is a uint16_t, so can't overflow here + if (lastprefilledindex > std::numeric_limits::max()) + return READ_STATUS_INVALID; + if ((uint32_t)lastprefilledindex > cmpctblock.shorttxids.size() + i) { + // If we are inserting a tx at an index greater than our full list of shorttxids + // plus the number of prefilled txn we've inserted, then we have txn for which we + // have neither a prefilled txn or a shorttxid! + return READ_STATUS_INVALID; + } + txn_available[lastprefilledindex] = cmpctblock.prefilledtxn[i].tx; + } + prefilled_count = cmpctblock.prefilledtxn.size(); + + // Calculate map of txids -> positions and check mempool to see what we have (or don't) + // Because well-formed cmpctblock messages will have a (relatively) uniform distribution + // of short IDs, any highly-uneven distribution of elements can be safely treated as a + // READ_STATUS_FAILED. + std::unordered_map shorttxids(cmpctblock.shorttxids.size()); + uint16_t index_offset = 0; + for (size_t i = 0; i < cmpctblock.shorttxids.size(); i++) { + while (txn_available[i + index_offset]) + index_offset++; + shorttxids[cmpctblock.shorttxids[i]] = i + index_offset; + // See dashcore for the probability analysis that justifies the bound of 12. + if (shorttxids.bucket_size(shorttxids.bucket(cmpctblock.shorttxids[i])) > 12) + return READ_STATUS_FAILED; + } + // TODO: in the shortid-collision case, we should instead request both transactions + // which collided. Falling back to full-block-request here is overkill. + if (shorttxids.size() != cmpctblock.shorttxids.size()) + return READ_STATUS_FAILED; // Short ID collision + + std::vector have_txn(txn_available.size()); + if (mempool_provider) { + auto snapshot = mempool_provider(); + for (const auto& entry : snapshot) { + uint64_t shortid = cmpctblock.GetShortID(entry.first); + auto idit = shorttxids.find(shortid); + if (idit != shorttxids.end()) { + if (!have_txn[idit->second]) { + txn_available[idit->second] = entry.second; + have_txn[idit->second] = true; + mempool_count++; + } else { + // If we find two mempool txn that match the short id, just request it. + if (txn_available[idit->second]) { + txn_available[idit->second].reset(); + mempool_count--; + } + } + } + if (mempool_count == shorttxids.size()) + break; + } + } + + for (size_t i = 0; i < extra_txn.size(); i++) { + uint64_t shortid = cmpctblock.GetShortID(extra_txn[i].first); + auto idit = shorttxids.find(shortid); + if (idit != shorttxids.end()) { + if (!have_txn[idit->second]) { + txn_available[idit->second] = extra_txn[i].second; + have_txn[idit->second] = true; + mempool_count++; + extra_count++; + } else { + if (txn_available[idit->second] && + // Compare hashes of the existing slot vs the candidate; extra_txn + // duplicates that already matched via the mempool path are + // harmless. + [&] { + auto packed = pack(*txn_available[idit->second]); + return ::Hash(packed.get_span()) != extra_txn[i].first; + }()) + { + txn_available[idit->second].reset(); + mempool_count--; + extra_count--; + } + } + } + if (mempool_count == shorttxids.size()) + break; + } + + LOG_DEBUG_COIND << "[cmpctblock] Initialized PartiallyDownloadedBlock" + << " cmpctblock_size=" << pack(cmpctblock).size() + << " prefilled=" << prefilled_count + << " mempool=" << mempool_count + << " extra=" << extra_count; + + return READ_STATUS_OK; +} + +bool PartiallyDownloadedBlock::IsTxAvailable(size_t index) const { + assert(!header.IsNull()); + assert(index < txn_available.size()); + return txn_available[index] != nullptr; +} + +ReadStatus PartiallyDownloadedBlock::FillBlock(CBlock& block, const std::vector& vtx_missing) { + assert(!header.IsNull()); + block = CBlock(); + static_cast(block) = header; + block.m_txs.resize(txn_available.size()); + + size_t tx_missing_offset = 0; + for (size_t i = 0; i < txn_available.size(); i++) { + if (!txn_available[i]) { + if (vtx_missing.size() <= tx_missing_offset) + return READ_STATUS_INVALID; + block.m_txs[i] = *vtx_missing[tx_missing_offset++]; + } else { + block.m_txs[i] = *txn_available[i]; + } + } + + // Make sure we can't call FillBlock again. + header.SetNull(); + txn_available.clear(); + + if (vtx_missing.size() != tx_missing_offset) + return READ_STATUS_INVALID; + + // Adaptation: dashcore calls CheckBlock here, which re-runs full + // consensus validation (merkle root + script validity + Dash-specific + // DIP3 checks). c2pool-dash validates blocks at higher layers (the + // submit path re-runs the coinbase checks; the header chain validates + // merkle roots on arrival from peers). The compact-block reassembler + // only needs to re-emit the bytes the peer sent us; if the result is + // malformed the higher-level validator catches it. + + LOG_DEBUG_COIND << "[cmpctblock] Reconstructed block" + << " prefilled=" << prefilled_count + << " mempool=" << mempool_count + << " extra=" << extra_count + << " requested=" << vtx_missing.size(); + + return READ_STATUS_OK; +} + +} // namespace vendor +} // namespace coin +} // namespace dash diff --git a/src/impl/dash/coin/vendor/blockencodings.hpp b/src/impl/dash/coin/vendor/blockencodings.hpp new file mode 100644 index 000000000..c30199302 --- /dev/null +++ b/src/impl/dash/coin/vendor/blockencodings.hpp @@ -0,0 +1,172 @@ +// Copyright (c) 2016-2020 The Bitcoin Core developers +// Copyright (c) 2020-2026 The Dash Core developers +// Distributed under the MIT software license, see the accompanying +// file COPYING or http://www.opensource.org/licenses/mit-license.php. +// +// VENDORED from dashcore/src/blockencodings.h. See vendor/README.md. +// +// Adaptations from upstream: +// 1. Primitives (CBlockHeader, CBlock, CTransactionRef, CTxMemPool) +// replaced by c2pool types via shim.hpp typedefs. +// 2. Serialization idioms (SERIALIZE_METHODS(cls, obj) + +// VectorFormatter + CustomUintFormatter + DifferenceFormatter) +// re-expressed in c2pool's pack.hpp surface: +// btclibs idiom → c2pool idiom +// SERIALIZE_METHODS(X, obj){body} → SERIALIZE_METHODS(X) body +// VectorFormatter → ListType +// CustomUintFormatter<6> → ShortTxIdFormat +// DefaultFormatter/TransactionCompression → TxCompressionFormat +// VectorFormatter over indexes → DifferenceListFormat +// COMPACTSIZE(x) → VarInt(x) +// ser_action.ForRead() { ... } → formatter-type-discriminated +// post-read hook in FORMAT_METHODS. +// Wire format is byte-for-byte identical to dashcore. +// 3. CheckBlock() call removed from FillBlock (c2pool-dash validates +// blocks at higher layers). + +#pragma once + +#include + +#include + +#include +#include +#include + +namespace dash { +namespace coin { +namespace vendor { + +class BlockTransactionsRequest { +public: + // A BlockTransactionsRequest message + uint256 blockhash; + std::vector indexes; + + C2POOL_SERIALIZE_METHODS(BlockTransactionsRequest) + { + READWRITE(obj.blockhash, + Using(obj.indexes)); + } +}; + +class BlockTransactions { +public: + // A BlockTransactions message + uint256 blockhash; + std::vector txn; + + BlockTransactions() {} + explicit BlockTransactions(const BlockTransactionsRequest& req) : + blockhash(req.blockhash), txn(req.indexes.size()) {} + + C2POOL_SERIALIZE_METHODS(BlockTransactions) + { + READWRITE(obj.blockhash, + Using>(obj.txn)); + } +}; + +// Dumb serialization/storage-helper for CBlockHeaderAndShortTxIDs and PartiallyDownloadedBlock +struct PrefilledTransaction { + // Used as an offset since last prefilled tx in CBlockHeaderAndShortTxIDs, + // as a proper transaction-in-block-index in PartiallyDownloadedBlock + uint16_t index; + CTransactionRef tx; + + C2POOL_SERIALIZE_METHODS(PrefilledTransaction) + { + READWRITE(VarInt(obj.index), + Using(obj.tx)); + } +}; + +typedef enum ReadStatus_t +{ + READ_STATUS_OK, + READ_STATUS_INVALID, // Invalid object, peer is sending bogus crap + READ_STATUS_FAILED, // Failed to process object + READ_STATUS_CHECKBLOCK_FAILED, // Used only by FillBlock to indicate a + // failure in CheckBlock (c2pool-dash retains + // the enum value for upstream parity even + // though FillBlock never returns it today). +} ReadStatus; + +class CBlockHeaderAndShortTxIDs { +private: + mutable uint64_t shorttxidk0, shorttxidk1; + uint64_t nonce; + + void FillShortTxIDSelector() const; + + friend class PartiallyDownloadedBlock; + +protected: + std::vector shorttxids; + std::vector prefilledtxn; + +public: + static constexpr int SHORTTXIDS_LENGTH = 6; + + CBlockHeader header; + + // Dummy for deserialization + CBlockHeaderAndShortTxIDs() {} + + explicit CBlockHeaderAndShortTxIDs(const CBlock& block); + + uint64_t GetShortID(const uint256& txhash) const; + + size_t BlockTxCount() const { return shorttxids.size() + prefilledtxn.size(); } + + C2POOL_SERIALIZE_METHODS(CBlockHeaderAndShortTxIDs) + { + READWRITE(obj.header, + obj.nonce, + Using>(obj.shorttxids), + obj.prefilledtxn); + // Post-read integrity check (ser_action.ForRead() branch in + // upstream): index-set must fit in a uint16, and the short-id + // SipHash selector must be primed so GetShortID() works. + if constexpr (std::is_same_v) { + if (obj.BlockTxCount() > std::numeric_limits::max()) + throw std::ios_base::failure("indexes overflowed 16 bits"); + obj.FillShortTxIDSelector(); + } + } +}; + +class PartiallyDownloadedBlock { +protected: + std::vector txn_available; + size_t prefilled_count = 0, mempool_count = 0, extra_count = 0; + + // Shim: dashcore threads a `CTxMemPool*`; c2pool-dash passes a + // snapshot provider (see shim.hpp). Null callback = no mempool + // (pre-Phase-M); reassembly falls straight through to getblocktxn. + MempoolShortIdProvider mempool_provider; + // Upper bound on transactions a block may contain. Dashcore reads + // MaxBlockSize() from ChainParams at call-time; c2pool-dash injects + // it at construction so blockencodings.cpp stays free of + // chain-params deps. + size_t max_block_tx_count; + +public: + CBlockHeader header; + + explicit PartiallyDownloadedBlock(MempoolShortIdProvider provider, + size_t max_block_tx_count_in) + : mempool_provider(std::move(provider)) + , max_block_tx_count(max_block_tx_count_in) {} + + // extra_txn is a list of extra transactions to look at, in form + ReadStatus InitData(const CBlockHeaderAndShortTxIDs& cmpctblock, + const std::vector>& extra_txn); + bool IsTxAvailable(size_t index) const; + ReadStatus FillBlock(CBlock& block, const std::vector& vtx_missing); +}; + +} // namespace vendor +} // namespace coin +} // namespace dash diff --git a/test/CMakeLists.txt b/test/CMakeLists.txt index 0dd37fcda..63688847c 100644 --- a/test/CMakeLists.txt +++ b/test/CMakeLists.txt @@ -199,6 +199,20 @@ if (BUILD_TESTING AND GTest_FOUND) target_link_libraries(test_dash_header_chain PRIVATE c2pool_payout c2pool_hashrate c2pool_merged_mining) # OBJECT-lib SCC direct-naming (#22/#39): core stratum/web_server TUs gtest_add_tests(test_dash_header_chain "" AUTO) + # DASH block-replay test (PR-0 foundation, S5 slice): BIP 152 compact-block + # reassembly over the vendored blockencodings port. Links the additive + # dash_block_replay static lib (coin/vendor/blockencodings.cpp) on top of + # the test_dash_header_chain link set; no ltc/pool SCC dependency. + add_executable(test_dash_block_replay test_dash_block_replay.cpp) + target_link_libraries(test_dash_block_replay PRIVATE + GTest::gtest_main GTest::gtest + dash_block_replay dash_x11 core + nlohmann_json::nlohmann_json + ${Boost_LIBRARIES} + ) + target_link_libraries(test_dash_block_replay PRIVATE c2pool_payout c2pool_hashrate c2pool_merged_mining) # OBJECT-lib SCC direct-naming (#22/#39): core stratum/web_server TUs + gtest_add_tests(test_dash_block_replay "" AUTO) + add_executable(test_compact_blocks test_compact_blocks.cpp) target_link_libraries(test_compact_blocks PRIVATE GTest::gtest_main GTest::gtest diff --git a/test/test_dash_block_replay.cpp b/test/test_dash_block_replay.cpp new file mode 100644 index 000000000..37270599d --- /dev/null +++ b/test/test_dash_block_replay.cpp @@ -0,0 +1,286 @@ +// Block-replay (BIP 152 compact-block) reassembly tests for DASH (S5 slice). +// +// Exercises the vendored compact-block encoding ported in +// src/impl/dash/coin/vendor/blockencodings.{hpp,cpp} (foundation bdecc5c2): +// - CBlockHeaderAndShortTxIDs construction + short-ID derivation +// - PartiallyDownloadedBlock::InitData / IsTxAvailable / FillBlock +// - the getblocktxn wire types (BlockTransactionsRequest / BlockTransactions) +// and the differential index encoding (DifferenceListFormat). +// +// These are behavioural round-trip tests, not byte-for-byte KATs against a +// captured dashcore corpus: the foundation comment promises wire-format +// parity, but the value the reassembler must guarantee is that a block fed +// through compact-encode -> serialize -> deserialize -> reassemble comes back +// byte-identical. We assert that, plus the documented INVALID/short-circuit +// rejection paths. A regression in the SipHash short-ID, the prefilled-index +// walk, or the missing-tx fill order turns these red. +// +// Transactions are identified here by serialized hash (the same +// ::Hash(pack(tx)) the encoder uses to build short IDs), so distinct +// locktimes are enough to make them distinguishable fixtures. + +#include + +#include +#include +#include + +#include +#include +#include + +#include +#include +#include + +using namespace dash::coin; +namespace v = dash::coin::vendor; + +namespace { + +// A minimal-but-distinct transaction. Empty inputs/outputs keep the fixture +// small; the locktime is the only varying field, which is sufficient to give +// each tx a unique serialized hash (and therefore a unique short ID). +MutableTransaction make_tx(uint32_t locktime) { + MutableTransaction tx; + tx.version = 1; + tx.type = 0; + tx.locktime = locktime; + return tx; +} + +// Serialized hash — mirrors the encoder's GetShortID input +// (CBlockHeaderAndShortTxIDs ctor: ::Hash(pack(tx).get_span())). +uint256 tx_hash(const MutableTransaction& tx) { + auto ps = pack(tx); + return ::Hash(ps.get_span()); +} + +// A block with a non-null header (m_bits != 0) and `n_txs` distinct txs. +// m_txs[0] is the coinbase, prefilled by the compact encoder. +BlockType make_block(size_t n_txs) { + BlockType b; + b.m_version = 1; + b.m_bits = 0x1d00ffff; // non-zero => header.IsNull() == false + b.m_timestamp = 1700000000; + b.m_nonce = 42; + for (size_t i = 0; i < n_txs; ++i) + b.m_txs.push_back(make_tx(static_cast(1000 + i))); + return b; +} + +// No mempool snapshot (pre-Phase-M): every non-prefilled tx must arrive via +// getblocktxn. The constructor accepts a null std::function. +v::MempoolShortIdProvider no_mempool() { return nullptr; } + +constexpr size_t kMaxBlockTxCount = 100000; // generous bound; not under test here + +// Assert two blocks have identical transaction sets (by serialized hash) and +// matching header identity fields. +void expect_block_eq(const BlockType& got, const BlockType& want) { + ASSERT_EQ(got.m_txs.size(), want.m_txs.size()); + for (size_t i = 0; i < want.m_txs.size(); ++i) + EXPECT_EQ(tx_hash(got.m_txs[i]), tx_hash(want.m_txs[i])) << "tx mismatch at index " << i; + EXPECT_EQ(got.m_bits, want.m_bits); + EXPECT_EQ(got.m_timestamp, want.m_timestamp); + EXPECT_EQ(got.m_nonce, want.m_nonce); + EXPECT_EQ(got.m_previous_block, want.m_previous_block); + EXPECT_EQ(got.m_merkle_root, want.m_merkle_root); +} + +} // namespace + +// Full round trip with no mempool help: coinbase is prefilled, every other tx +// is supplied as a "missing" getblocktxn response, and the reassembled block +// must match the original. +TEST(DashBlockReplay, RoundTripAllMissing) { + const BlockType block = make_block(4); + + v::CBlockHeaderAndShortTxIDs cmpct(block); + EXPECT_EQ(cmpct.BlockTxCount(), 4u); + + v::PartiallyDownloadedBlock pdb(no_mempool(), kMaxBlockTxCount); + ASSERT_EQ(pdb.InitData(cmpct, {}), v::READ_STATUS_OK); + + // Only the coinbase (index 0) is available before getblocktxn. + EXPECT_TRUE(pdb.IsTxAvailable(0)); + for (size_t i = 1; i < 4; ++i) + EXPECT_FALSE(pdb.IsTxAvailable(i)) << "index " << i << " should be missing"; + + std::vector missing; + for (size_t i = 1; i < block.m_txs.size(); ++i) + missing.push_back(v::MakeTransactionRef(block.m_txs[i])); + + BlockType recon; + ASSERT_EQ(pdb.FillBlock(recon, missing), v::READ_STATUS_OK); + expect_block_eq(recon, block); +} + +// The compact block survives a serialize/deserialize hop (the wire path) and +// still reassembles. This is the property the foundation's "byte-for-byte" +// claim has to satisfy end to end. +TEST(DashBlockReplay, CompactBlockSerializationRoundTrip) { + const BlockType block = make_block(5); + + v::CBlockHeaderAndShortTxIDs cmpct(block); + auto stream = pack(cmpct); + EXPECT_GT(stream.size(), 0u); + + v::CBlockHeaderAndShortTxIDs decoded; + stream >> decoded; + EXPECT_EQ(decoded.BlockTxCount(), cmpct.BlockTxCount()); + + v::PartiallyDownloadedBlock pdb(no_mempool(), kMaxBlockTxCount); + ASSERT_EQ(pdb.InitData(decoded, {}), v::READ_STATUS_OK); + + std::vector missing; + for (size_t i = 1; i < block.m_txs.size(); ++i) + missing.push_back(v::MakeTransactionRef(block.m_txs[i])); + + BlockType recon; + ASSERT_EQ(pdb.FillBlock(recon, missing), v::READ_STATUS_OK); + expect_block_eq(recon, block); +} + +// A mempool snapshot supplies one of the block's txs by short ID, so it no +// longer has to be requested via getblocktxn. +TEST(DashBlockReplay, MempoolProviderSatisfiesShortId) { + const BlockType block = make_block(4); + + // Provide tx at index 1 from the "mempool". + const MutableTransaction& mem_tx = block.m_txs[1]; + auto provider = [mem_tx]() -> std::vector> { + return {{tx_hash(mem_tx), v::MakeTransactionRef(mem_tx)}}; + }; + + v::CBlockHeaderAndShortTxIDs cmpct(block); + v::PartiallyDownloadedBlock pdb(provider, kMaxBlockTxCount); + ASSERT_EQ(pdb.InitData(cmpct, {}), v::READ_STATUS_OK); + + EXPECT_TRUE(pdb.IsTxAvailable(0)); // coinbase, prefilled + EXPECT_TRUE(pdb.IsTxAvailable(1)); // satisfied from mempool + EXPECT_FALSE(pdb.IsTxAvailable(2)); + EXPECT_FALSE(pdb.IsTxAvailable(3)); + + // getblocktxn now only needs indexes 2 and 3, in order. + std::vector missing{ + v::MakeTransactionRef(block.m_txs[2]), + v::MakeTransactionRef(block.m_txs[3]), + }; + + BlockType recon; + ASSERT_EQ(pdb.FillBlock(recon, missing), v::READ_STATUS_OK); + expect_block_eq(recon, block); +} + +// extra_txn (the orphan/recent-relay pool) is consulted the same way the +// mempool snapshot is. +TEST(DashBlockReplay, ExtraTxnSatisfiesShortId) { + const BlockType block = make_block(3); + + v::CBlockHeaderAndShortTxIDs cmpct(block); + v::PartiallyDownloadedBlock pdb(no_mempool(), kMaxBlockTxCount); + + std::vector> extra{ + {tx_hash(block.m_txs[2]), v::MakeTransactionRef(block.m_txs[2])}, + }; + ASSERT_EQ(pdb.InitData(cmpct, extra), v::READ_STATUS_OK); + + EXPECT_TRUE(pdb.IsTxAvailable(0)); + EXPECT_FALSE(pdb.IsTxAvailable(1)); + EXPECT_TRUE(pdb.IsTxAvailable(2)); // from extra_txn + + std::vector missing{v::MakeTransactionRef(block.m_txs[1])}; + BlockType recon; + ASSERT_EQ(pdb.FillBlock(recon, missing), v::READ_STATUS_OK); + expect_block_eq(recon, block); +} + +// A null/empty header is rejected outright. +TEST(DashBlockReplay, InitDataRejectsNullHeader) { + v::CBlockHeaderAndShortTxIDs empty; // default header is null (m_bits == 0) + v::PartiallyDownloadedBlock pdb(no_mempool(), kMaxBlockTxCount); + EXPECT_EQ(pdb.InitData(empty, {}), v::READ_STATUS_INVALID); +} + +// A non-null header with no shorttxids and no prefilled txn is rejected: there +// is nothing to reassemble. +TEST(DashBlockReplay, InitDataRejectsEmptyBody) { + v::CBlockHeaderAndShortTxIDs cmpct; // leaves shorttxids/prefilledtxn empty + cmpct.header.m_bits = 0x1d00ffff; // make the header non-null + v::PartiallyDownloadedBlock pdb(no_mempool(), kMaxBlockTxCount); + EXPECT_EQ(pdb.InitData(cmpct, {}), v::READ_STATUS_INVALID); +} + +// FillBlock must reject a getblocktxn response that supplies too few +// transactions to fill the missing slots. +TEST(DashBlockReplay, FillBlockRejectsTooFewMissing) { + const BlockType block = make_block(4); // needs 3 missing (indexes 1..3) + + v::CBlockHeaderAndShortTxIDs cmpct(block); + v::PartiallyDownloadedBlock pdb(no_mempool(), kMaxBlockTxCount); + ASSERT_EQ(pdb.InitData(cmpct, {}), v::READ_STATUS_OK); + + std::vector too_few{ + v::MakeTransactionRef(block.m_txs[1]), + v::MakeTransactionRef(block.m_txs[2]), + }; + BlockType recon; + EXPECT_EQ(pdb.FillBlock(recon, too_few), v::READ_STATUS_INVALID); +} + +// FillBlock must also reject a response with too many transactions (leftover +// after the missing slots are filled). +TEST(DashBlockReplay, FillBlockRejectsTooManyMissing) { + const BlockType block = make_block(3); // needs 2 missing (indexes 1..2) + + v::CBlockHeaderAndShortTxIDs cmpct(block); + v::PartiallyDownloadedBlock pdb(no_mempool(), kMaxBlockTxCount); + ASSERT_EQ(pdb.InitData(cmpct, {}), v::READ_STATUS_OK); + + std::vector too_many{ + v::MakeTransactionRef(block.m_txs[1]), + v::MakeTransactionRef(block.m_txs[2]), + v::MakeTransactionRef(make_tx(9999)), // unwanted extra + }; + BlockType recon; + EXPECT_EQ(pdb.FillBlock(recon, too_many), v::READ_STATUS_INVALID); +} + +// getblocktxn request wire type: blockhash + a strictly-increasing index set +// encoded with the differential compact-size formatter. Round trip must +// preserve both fields exactly. +TEST(DashBlockReplay, BlockTransactionsRequestRoundTrip) { + v::BlockTransactionsRequest req; + req.blockhash = tx_hash(make_tx(7)); // any concrete uint256 + req.indexes = {0, 1, 5, 100, 4242, 65535}; // strictly increasing + + auto stream = pack(req); + v::BlockTransactionsRequest decoded; + stream >> decoded; + + EXPECT_EQ(decoded.blockhash, req.blockhash); + EXPECT_EQ(decoded.indexes, req.indexes); +} + +// getblocktxn response wire type: blockhash + a vector of full transactions. +TEST(DashBlockReplay, BlockTransactionsRoundTrip) { + v::BlockTransactions resp; + resp.blockhash = tx_hash(make_tx(11)); + resp.txn = { + v::MakeTransactionRef(make_tx(1)), + v::MakeTransactionRef(make_tx(2)), + v::MakeTransactionRef(make_tx(3)), + }; + + auto stream = pack(resp); + v::BlockTransactions decoded; + stream >> decoded; + + EXPECT_EQ(decoded.blockhash, resp.blockhash); + ASSERT_EQ(decoded.txn.size(), resp.txn.size()); + for (size_t i = 0; i < resp.txn.size(); ++i) { + ASSERT_TRUE(decoded.txn[i]); + EXPECT_EQ(tx_hash(*decoded.txn[i]), tx_hash(*resp.txn[i])) << "txn mismatch at " << i; + } +}