|
| 1 | +/// test_dash_embedded_relay_e2e.cpp |
| 2 | +/// |
| 3 | +/// Phase S8 — EMBEDDED won-block relay END-TO-END KAT (won-block-reaches-network). |
| 4 | +/// |
| 5 | +/// G3b proved the dashd `submitblock` RPC FALLBACK arm of the dual-path |
| 6 | +/// block-viability gate end-to-end on regtest (c2pool-dash won block 272, |
| 7 | +/// ACCEPTED, mempool drained). This KAT proves the OTHER arm — the EMBEDDED P2P |
| 8 | +/// relay — at the wire-framing level, which G3b did NOT exercise: |
| 9 | +/// |
| 10 | +/// DashBroadcaster (leaf, #405) --fan-out hook--> per-slot submit seam |
| 11 | +/// | |
| 12 | +/// WonBlockRelay (#432/#441) framer |
| 13 | +/// inv(MSG_BLOCK,h) -> getdata -> block |
| 14 | +/// | |
| 15 | +/// [peer reconstructs block] |
| 16 | +/// |
| 17 | +/// Both leaves are on master but had only ISOLATED unit coverage; the embedded |
| 18 | +/// arm is the COMPOSITION the (operator-gated) broadcaster_full.hpp keystone |
| 19 | +/// drives onto a live socket. Here we drive that composition socket-free and |
| 20 | +/// assert every LIVE peer reconstructs the EXACT won block (byte-parity vs the |
| 21 | +/// canonical serialization), and that a COLD pool reaches zero peers — which is |
| 22 | +/// precisely why the dashd-RPC fallback arm (proven in G3b) is never removed. |
| 23 | +/// |
| 24 | +/// Single dash tree, non-consensus, zero sockets. Block hash is producer- |
| 25 | +/// supplied (not recomputed), so this carries no consensus value. |
| 26 | + |
| 27 | +#include <gtest/gtest.h> |
| 28 | + |
| 29 | +#include <impl/dash/broadcaster.hpp> |
| 30 | +#include <impl/dash/block_relay.hpp> |
| 31 | +#include <impl/dash/config.hpp> |
| 32 | +#include <impl/dash/coin/p2p_messages.hpp> |
| 33 | + |
| 34 | +#include <core/netaddress.hpp> |
| 35 | +#include <core/uint256.hpp> |
| 36 | +#include <core/pack.hpp> |
| 37 | + |
| 38 | +#include <boost/asio.hpp> |
| 39 | + |
| 40 | +#include <array> |
| 41 | +#include <cstring> |
| 42 | +#include <memory> |
| 43 | +#include <span> |
| 44 | +#include <string> |
| 45 | +#include <vector> |
| 46 | + |
| 47 | +using namespace dash; |
| 48 | +using dash::coin::BlockType; |
| 49 | +using dash::coin::p2p::message_inv; |
| 50 | +using dash::coin::p2p::message_block; |
| 51 | +using dash::coin::p2p::inventory_type; |
| 52 | +using nlohmann::json; |
| 53 | + |
| 54 | +namespace { |
| 55 | + |
| 56 | +constexpr uint16_t kCanonicalPort = 19999; |
| 57 | +constexpr char kPrimaryHost[] = "10.9.9.9"; |
| 58 | + |
| 59 | +uint256 hash_seq(uint8_t base) { |
| 60 | + std::array<uint8_t, 32> p{}; |
| 61 | + for (size_t i = 0; i < 32; ++i) p[i] = static_cast<uint8_t>(base + i); |
| 62 | + uint256 h; std::memcpy(h.data(), p.data(), 32); return h; |
| 63 | +} |
| 64 | + |
| 65 | +BlockType make_block(uint8_t seed) { |
| 66 | + BlockType b; |
| 67 | + b.m_version = 0x20000000u | seed; |
| 68 | + b.m_previous_block = hash_seq(0x10 + seed); |
| 69 | + b.m_merkle_root = hash_seq(0x50 + seed); |
| 70 | + b.m_timestamp = 0x5f5e1000u + seed; |
| 71 | + b.m_bits = 0x1d00ffffu; |
| 72 | + b.m_nonce = 0xdeadbeefu - seed; |
| 73 | + return b; |
| 74 | +} |
| 75 | + |
| 76 | +// Non-destructive byte view of a PackStream (does not advance the read cursor). |
| 77 | +std::vector<unsigned char> bytes_of(PackStream& ps) { |
| 78 | + auto sp = ps.get_span(); |
| 79 | + auto* p = reinterpret_cast<const unsigned char*>(sp.data()); |
| 80 | + return std::vector<unsigned char>(p, p + sp.size()); |
| 81 | +} |
| 82 | + |
| 83 | +std::unique_ptr<Config> make_config() { |
| 84 | + auto cfg = std::make_unique<Config>("dash-s8-embedded-relay-e2e-kat"); |
| 85 | + cfg->coin()->m_p2p.address = |
| 86 | + NetService{std::string{"127.0.0.1"}, std::to_string(kCanonicalPort)}; |
| 87 | + return cfg; |
| 88 | +} |
| 89 | + |
| 90 | +json peer(const std::string& addr) { |
| 91 | + json p = json::object(); |
| 92 | + p["addr"] = addr; |
| 93 | + return p; |
| 94 | +} |
| 95 | + |
| 96 | +// Leaf broadcaster with a bare-NodeP2P factory + injected liveness, populated |
| 97 | +// with N live slots via a discovery pass (mirrors test_dash_broadcaster_full). |
| 98 | +struct PoolEnv { |
| 99 | + boost::asio::io_context ioc; |
| 100 | + std::unique_ptr<Config> cfg{make_config()}; |
| 101 | + bool all_live{true}; |
| 102 | + std::unique_ptr<DashBroadcaster> pool; |
| 103 | + |
| 104 | + explicit PoolEnv(size_t max_peers = 16) { |
| 105 | + pool = std::make_unique<DashBroadcaster>( |
| 106 | + &ioc, cfg.get(), |
| 107 | + NetService{std::string{kPrimaryHost}, std::to_string(kCanonicalPort)}, |
| 108 | + max_peers); |
| 109 | + pool->set_slot_factory([this](const NetService&) { |
| 110 | + return std::make_unique<dash::coin::p2p::NodeP2P>(&ioc); |
| 111 | + }); |
| 112 | + pool->set_live_predicate( |
| 113 | + [this](const dash::coin::p2p::NodeP2P&) { return all_live; }); |
| 114 | + } |
| 115 | + |
| 116 | + void dial(size_t n) { |
| 117 | + json peers = json::array(); |
| 118 | + for (size_t i = 0; i < n; ++i) |
| 119 | + peers.push_back(peer("10.0.0." + std::to_string(i + 1) + ":19999")); |
| 120 | + size_t got = pool->discover(peers); |
| 121 | + ASSERT_EQ(got, n); |
| 122 | + } |
| 123 | +}; |
| 124 | + |
| 125 | +// Wire the leaf's fan-out seam to the WonBlockRelay handshake: each LIVE peer |
| 126 | +// the fan-out reaches runs getdata(h) against the relay and reconstructs the |
| 127 | +// served block. Records one reconstructed block per peer reached. |
| 128 | +struct EmbeddedRelayHarness { |
| 129 | + WonBlockRelay relay; |
| 130 | + BlockType won; |
| 131 | + uint256 won_hash; |
| 132 | + std::vector<unsigned char> won_wire; // canonical serialized block |
| 133 | + std::vector<BlockType> delivered; // one per reached peer |
| 134 | + std::vector<std::vector<unsigned char>> fanned; // bytes the seam handed each peer |
| 135 | + |
| 136 | + EmbeddedRelayHarness(uint8_t seed, uint8_t hashbase) |
| 137 | + : won(make_block(seed)), won_hash(hash_seq(hashbase)) |
| 138 | + { |
| 139 | + won_wire = bytes_of(message_block::make_raw(won)->m_data); |
| 140 | + } |
| 141 | + |
| 142 | + // Announce once (records the won block + builds the inv to fan out), wire the |
| 143 | + // leaf seam to the per-peer getdata->block handshake, then drive the won |
| 144 | + // block through submit_block_raw_all. Returns the live-peer fan-out count. |
| 145 | + size_t broadcast(DashBroadcaster& pool) { |
| 146 | + auto inv = relay.announce(won_hash, won); |
| 147 | + // The inv that fans to every peer carries exactly our MSG_BLOCK hash. |
| 148 | + EXPECT_EQ(inv->m_command, "inv"); |
| 149 | + auto pinv = message_inv::make(inv->m_data); |
| 150 | + EXPECT_EQ(pinv->m_invs.size(), 1u); |
| 151 | + EXPECT_EQ(static_cast<uint32_t>(pinv->m_invs[0].m_type), |
| 152 | + static_cast<uint32_t>(inventory_type::block)); |
| 153 | + EXPECT_EQ(pinv->m_invs[0].m_hash, won_hash); |
| 154 | + |
| 155 | + pool.set_fan_out_hook([this](dash::coin::p2p::NodeP2P&, |
| 156 | + std::span<const unsigned char> b) { |
| 157 | + fanned.emplace_back(b.begin(), b.end()); |
| 158 | + // Peer side: getdata(MSG_BLOCK, h) -> full `block` message -> parse. |
| 159 | + auto blkmsg = relay.on_getdata_block(won_hash); |
| 160 | + ASSERT_NE(blkmsg, nullptr); |
| 161 | + EXPECT_EQ(blkmsg->m_command, "block"); |
| 162 | + auto parsed = message_block::make(blkmsg->m_data); |
| 163 | + delivered.push_back(parsed->m_block); |
| 164 | + }); |
| 165 | + return pool.submit_block_raw_all(won_wire); |
| 166 | + } |
| 167 | +}; |
| 168 | + |
| 169 | +void expect_block_eq(const BlockType& a, const BlockType& b) { |
| 170 | + EXPECT_EQ(a.m_version, b.m_version); |
| 171 | + EXPECT_EQ(a.m_previous_block, b.m_previous_block); |
| 172 | + EXPECT_EQ(a.m_merkle_root, b.m_merkle_root); |
| 173 | + EXPECT_EQ(a.m_timestamp, b.m_timestamp); |
| 174 | + EXPECT_EQ(a.m_bits, b.m_bits); |
| 175 | + EXPECT_EQ(a.m_nonce, b.m_nonce); |
| 176 | +} |
| 177 | + |
| 178 | +} // namespace |
| 179 | + |
| 180 | +// (1) Embedded arm delivers the EXACT won block to EVERY live peer ──────────── |
| 181 | +TEST(DashEmbeddedRelayE2E, DeliversExactWonBlockToEveryLivePeer) { |
| 182 | + PoolEnv env; |
| 183 | + env.dial(3); |
| 184 | + EmbeddedRelayHarness h{7, 0xA0}; |
| 185 | + |
| 186 | + size_t reached = h.broadcast(*env.pool); |
| 187 | + |
| 188 | + EXPECT_EQ(reached, 3u); // leaf fanned to all live slots |
| 189 | + ASSERT_EQ(h.delivered.size(), 3u); // 3 peers completed the handshake |
| 190 | + ASSERT_EQ(h.fanned.size(), 3u); |
| 191 | + for (size_t i = 0; i < h.delivered.size(); ++i) { |
| 192 | + expect_block_eq(h.delivered[i], h.won); // reconstructed == won block |
| 193 | + // byte-parity: relay-served bytes == canonical won-block wire bytes, and |
| 194 | + // the bytes the seam handed the peer are the same won-block bytes. |
| 195 | + auto served = bytes_of(message_block::make_raw(h.delivered[i])->m_data); |
| 196 | + EXPECT_EQ(served, h.won_wire); |
| 197 | + EXPECT_EQ(h.fanned[i], h.won_wire); |
| 198 | + } |
| 199 | +} |
| 200 | + |
| 201 | +// (2) COLD pool: zero live peers -> embedded arm reaches NO peer ────────────── |
| 202 | +// The embedded arm alone can silently drop a won block — exactly why the |
| 203 | +// authoritative dashd-RPC fallback arm (proven end-to-end in G3b) is kept. |
| 204 | +TEST(DashEmbeddedRelayE2E, ColdPoolReachesNoPeer) { |
| 205 | + PoolEnv env; |
| 206 | + env.dial(3); |
| 207 | + env.all_live = false; // every slot now dead |
| 208 | + EmbeddedRelayHarness h{4, 0x30}; |
| 209 | + |
| 210 | + size_t reached = h.broadcast(*env.pool); |
| 211 | + |
| 212 | + EXPECT_EQ(reached, 0u); |
| 213 | + EXPECT_TRUE(h.delivered.empty()); // no peer reconstructed a block |
| 214 | + EXPECT_TRUE(h.fanned.empty()); |
| 215 | +} |
| 216 | + |
| 217 | +// (3) The embedded arm never serves a block the producer did not announce ───── |
| 218 | +TEST(DashEmbeddedRelayE2E, NeverServesUnannouncedBlock) { |
| 219 | + WonBlockRelay relay; |
| 220 | + const uint256 announced = hash_seq(0x11); |
| 221 | + relay.announce(announced, make_block(2)); |
| 222 | + |
| 223 | + // getdata for a hash we never announced as won -> nothing on the wire. |
| 224 | + EXPECT_EQ(relay.on_getdata_block(hash_seq(0x99)), nullptr); |
| 225 | + |
| 226 | + // The announced won block is served intact. |
| 227 | + auto msg = relay.on_getdata_block(announced); |
| 228 | + ASSERT_NE(msg, nullptr); |
| 229 | + auto parsed = message_block::make(msg->m_data); |
| 230 | + expect_block_eq(parsed->m_block, make_block(2)); |
| 231 | +} |
| 232 | + |
| 233 | +// (4) Byte-parity is identical across ALL peers — no per-peer fan-out drift ─── |
| 234 | +TEST(DashEmbeddedRelayE2E, ByteParityIdenticalAcrossAllPeers) { |
| 235 | + PoolEnv env; |
| 236 | + env.dial(5); |
| 237 | + EmbeddedRelayHarness h{9, 0xC0}; |
| 238 | + |
| 239 | + size_t reached = h.broadcast(*env.pool); |
| 240 | + |
| 241 | + ASSERT_EQ(reached, 5u); |
| 242 | + ASSERT_EQ(h.delivered.size(), 5u); |
| 243 | + for (const auto& d : h.delivered) { |
| 244 | + auto served = bytes_of(message_block::make_raw(d)->m_data); |
| 245 | + EXPECT_EQ(served, h.won_wire); // every peer got identical bytes |
| 246 | + } |
| 247 | +} |
0 commit comments