diff --git a/.gitattributes b/.gitattributes index c334722..a74d695 100644 --- a/.gitattributes +++ b/.gitattributes @@ -1,3 +1,6 @@ # Track prebuilt test chains via Git LFS test/functional/test_chains/** filter=lfs diff=lfs merge=lfs -text + +# keep bash scripts in LF +*.sh text eol=lf \ No newline at end of file diff --git a/CMakeLists.txt b/CMakeLists.txt index 8e9b438..ed37870 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -31,6 +31,9 @@ set(CMAKE_CXX_STANDARD_REQUIRED ON) set(CMAKE_CXX_EXTENSIONS OFF) set(CMAKE_POSITION_INDEPENDENT_CODE ON) +# Enable PIC (Position Independent Code) for all targets +set(CMAKE_POSITION_INDEPENDENT_CODE ON) + # Default to Release build if(NOT CMAKE_BUILD_TYPE OR CMAKE_BUILD_TYPE STREQUAL "") set(CMAKE_BUILD_TYPE "Release") @@ -206,6 +209,31 @@ FetchContent_Declare( ) FetchContent_MakeAvailable(json) +# cpp-httplib - Single header HTTP library +FetchContent_Declare( + httplib + GIT_REPOSITORY https://github.com/yhirose/cpp-httplib.git + GIT_TAG v0.38.0 + GIT_SHALLOW TRUE +) +set(HTTPLIB_COMPILE OFF CACHE BOOL "" FORCE) +FetchContent_MakeAvailable(httplib) + +# libsecp256k1 - Optimized C library for EC operations on curve secp256k1 +FetchContent_Declare( + libsecp256k1 + GIT_REPOSITORY https://github.com/bitcoin-core/secp256k1.git + GIT_TAG v0.7.1 + GIT_SHALLOW TRUE +) +set(SECP256K1_BUILD_BENCHMARK OFF CACHE BOOL "" FORCE) +set(SECP256K1_BUILD_TESTS OFF CACHE BOOL "" FORCE) +set(SECP256K1_BUILD_EXHAUSTIVE_TESTS OFF CACHE BOOL "" FORCE) +set(SECP256K1_ECMULT_WINDOW_SIZE 15 CACHE STRING "" FORCE) +set(SECP256K1_ECMULT_GEN_WINDOW_SIZE 4 CACHE STRING "" FORCE) +set(SECP256K1_ENABLE_MODULE_RECOVERY ON CACHE BOOL "" FORCE) +FetchContent_MakeAvailable(libsecp256k1) + # miniupnpc - UPnP NAT traversal FetchContent_Declare( miniupnpc @@ -266,6 +294,11 @@ add_library(chain STATIC src/chain/chainstate_manager.cpp src/chain/timedata.cpp src/chain/miner.cpp + src/chain/token_generator.cpp + src/chain/token_manager.cpp + src/chain/trust_base.cpp + src/chain/bft_client.cpp + src/chain/trust_base_manager.cpp ) target_include_directories(chain PUBLIC @@ -278,10 +311,16 @@ if(randomx_POPULATED) target_include_directories(chain PUBLIC ${randomx_SOURCE_DIR}/src) endif() +FetchContent_GetProperties(httplib) +if(httplib_POPULATED) + target_include_directories(chain PUBLIC ${httplib_SOURCE_DIR}) +endif() + target_link_libraries(chain PUBLIC util randomx nlohmann_json::nlohmann_json + secp256k1 ) # Network Library - P2P networking and RPC diff --git a/README.md b/README.md index 49b0171..1dc55ec 100644 --- a/README.md +++ b/README.md @@ -30,7 +30,8 @@ Unicity adopts proven architectural patterns from Bitcoin Core for P2P networkin **Unicity-Specific Design:** - **Proof-of-work**: RandomX (ASIC-resistant) instead of SHA256d - **Difficulty adjustment**: ASERT per-block instead of 2016-block retargeting -- **Block headers**: 100 bytes (includes miner address + RandomX hash) +- **Block headers**: 112 bytes fixed header + variable-length payload +- **Block payload**: Contains mandatory 32-byte Miner Reward Token ID hash and optional BFT Trust Base snapshot - **Target spacing**: 2.4 hours instead of 10 minutes - **No transaction layer**: No mempool, UTXO set, or transaction processing @@ -123,6 +124,15 @@ See [test/README.md](test/README.md) for testing documentation. ./build/bin/unicity-cli startmining ``` +### BFT Integration + +Running on **testnet** and **mainnet** requires BFT integration to verify the genesis block and sync trust bases. +- **Default address**: `--bftaddr=http://127.0.0.1:25866` +- **Verification**: If the BFT node is not accessible at the specified address, genesis block verification will fail and the node will not start. +- **Disabling**: BFT integration can be explicitly disabled by setting the address to an empty string: `--bftaddr=""`. +- **Regtest**: In `regtest` mode, BFT integration is **disabled by default**. To enable it, you must explicitly provide an address, e.g., `--bftaddr=http://127.0.0.1:25866`. + + ### Using Docker For production deployments, Docker is recommended: diff --git a/docker-entrypoint.sh b/docker-entrypoint.sh index b460b92..ec59e47 100644 --- a/docker-entrypoint.sh +++ b/docker-entrypoint.sh @@ -12,6 +12,7 @@ PORT="${UNICITY_PORT:-9590}" LISTEN="${UNICITY_LISTEN:-1}" SERVER="${UNICITY_SERVER:-0}" VERBOSE="${UNICITY_VERBOSE:-0}" +BFTADDR="${UNICITY_BFTADDR:-}" NETWORK="${UNICITY_NETWORK:-mainnet}" LOGLEVEL="${UNICITY_LOGLEVEL:-}" DEBUG="${UNICITY_DEBUG:-}" @@ -66,6 +67,11 @@ fi # Note: RPC server is always enabled, no --server flag needed +# BFT RPC address +if [ -n "$BFTADDR" ]; then + ARGS+=("--bftaddr=$BFTADDR") +fi + # Verbose logging if [ "$VERBOSE" = "1" ]; then ARGS+=("--verbose") @@ -97,6 +103,9 @@ echo "Port: $PORT" echo "Listen: $LISTEN" echo "Server: $SERVER" echo "Verbose: $VERBOSE" +if [ -n "$BFTADDR" ]; then + echo "BFT Addr: $BFTADDR" +fi if [ -n "$LOGLEVEL" ]; then echo "Log Level: $LOGLEVEL" fi diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 7b5f869..53ed6a8 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -85,20 +85,19 @@ The chain layer validates block headers and maintains the blockchain state: ### Data Structures -#### Block Header (100 bytes) +#### Block Header (112 bytes) The fundamental unit of the blockchain: -| Field | Size | Description | -|-------|------|-------------| -| nVersion | 4 bytes | Block version | -| hashPrevBlock | 32 bytes | Previous block hash | -| minerAddress | 20 bytes | Miner reward address (replaces merkleRoot) | -| nTime | 4 bytes | Unix timestamp | -| nBits | 4 bytes | Difficulty target (compact format) | -| nNonce | 4 bytes | PoW nonce | -| hashRandomX | 32 bytes | RandomX hash (PoW commitment) | - +| Field | Size | Description | +|---------------|----------|----------------------------------------------------------| +| nVersion | 4 bytes | Block version | +| hashPrevBlock | 32 bytes | Previous block hash | +| payloadRoot | 32 bytes | Payload hash (Hash of reward token id hash and UTB hash) | +| nTime | 4 bytes | Unix timestamp | +| nBits | 4 bytes | Difficulty target (compact format) | +| nNonce | 4 bytes | PoW nonce | +| hashRandomX | 32 bytes | RandomX hash (PoW commitment) | #### Block Index @@ -188,6 +187,14 @@ After Reorg: - **Epoch-based**: Dataset changes periodically - **Two-phase verification**: Fast commitment check, then full RandomX +#### BFT Integration + +Unicity integrates with the BFT layer to establish periodic checkpoints. +- **Verification**: The node verifies the hardcoded genesis UTB against the epoch 1 trust base fetched from the BFT network. +- **Requirement**: BFT integration is required for **testnet** and **mainnet**. The node will fail to start if it cannot connect to the BFT node at the configured `bftaddr`. +- **Disabling**: Integration can be disabled by setting `bftaddr` to an empty string. This is the **default for regtest**. +- **Epoch 1**: When disabled, the node assumes the hardcoded genesis UTB is valid without network verification. + #### ASERT Difficulty Adjustment Formula: `target_new = target_ref * 2^((time_diff - ideal_time) / half_life)` diff --git a/docs/SPECIFICATION.md b/docs/SPECIFICATION.md index edfb10e..331bd8f 100644 --- a/docs/SPECIFICATION.md +++ b/docs/SPECIFICATION.md @@ -5,7 +5,7 @@ ## Overview -Unicity is a headers-only blockchain protocol. Nodes exchange block headers (100 bytes each) without transaction data. The protocol uses a Bitcoin like wire format with custom network identifiers. +Unicity is a headers-only blockchain protocol. Nodes exchange block headers (112 bytes each) without transaction data. The protocol uses a Bitcoin like wire format with custom network identifiers. ## Network Parameters @@ -69,19 +69,51 @@ All integers use little-endian byte order except where noted. | timestamp | 4 | uint32_t | Unix timestamp (LE) | | address | 26 | NetworkAddress | Network address | +### Block Header (112 bytes) +| Field | Size | Type | Description | +|---------------|------|----------|---------------------| +| nVersion | 4 | int32_t | Block version | +| hashPrevBlock | 32 | uint256 | Previous block hash | +| payloadRoot | 32 | uint256 | Hash of payload | +| nTime | 4 | uint32_t | Timestamp | +| nBits | 4 | uint32_t | Difficulty target | +| nNonce | 4 | uint32_t | Nonce | +| hashRandomX | 32 | uint256 | RandomX hash | -### Block Header (100 bytes) +### Block Payload -| Field | Size | Type | Description | -|-------|------|------|-------------| -| nVersion | 4 | int32_t | Block version | -| hashPrevBlock | 32 | uint256 | Previous block hash | -| minerAddress | 20 | uint160 | Miner address | -| nTime | 4 | uint32_t | Timestamp | -| nBits | 4 | uint32_t | Difficulty target | -| nNonce | 4 | uint32_t | Nonce | -| hashRandomX | 32 | uint256 | RandomX hash | +The block payload is committed to by the `payloadRoot` in the block header. It is transmitted alongside the header in +the `HEADERS` message. + +| Field | Size | Description | +|------------------------|------|------------------------------------------------| +| minerRewardTokenIdHash | 32 | SHA256 hash of the Miner Reward Token ID | +| trustBaseRecord | Var | Optional CBOR-encoded Unicity Trust Base (UTB) | + +The preimage of `minerRewardTokenIdHash` (the actual token ID) is generated by the miner and stored locally (e.g., in `reward_tokens.csv`). + +The `trustBaseRecord` field is only present if the BFT Trust Base has changed since the previous block. If no update is available, this field is omitted. + +The `payloadRoot` is calculated as `SHA256(leaf_0 || leaf_1)`, where `leaf_0` is the `minerRewardTokenIdHash` and `leaf_1` is the `SHA256` hash of the `trustBaseRecord` (or a zero-filled `uint256` if the record is absent). + +### Miner Reward Tokens + +For every block mined, the miner generates a unique **Reward Token ID**. + +- **TokenID Generation**: `TokenID = SHA256(Seed || Counter)` + - `Seed`: A persistent 32-byte cryptographically secure random value generated once per miner instance. + - `Counter`: An 8-byte (uint64_t) little-endian incrementing counter. +- **Commitment**: The block header commits to the **Hash of the Token ID** (`SHA256(TokenID)`). This hash is stored as the first 32 bytes of the block payload (`minerRewardTokenIdHash`). +- **Privacy**: Only the hash of the Token ID is public on the blockchain. The actual Token ID (preimage) is kept private by the miner. +- **Local Logging**: Successful miners must store the mapping between the block and the generated Token ID locally. By default, this is saved in `reward_tokens.csv` in the node's data directory. + +**Example `reward_tokens.csv` format:** +```csv +Height,BlockHash,TokenID +3,2caef7f1d57eeeef6b34d1abdadbf8b0a9937dd95ec38287c1d4e928d932bb3e,9621e355e0dc5fa287bafc36face5b7a2486a8860588f09156384a7489b59b7c +4,f250f586b509aba4f1790b0a0e25d0f4767ecccc0c1aaab590af6805da687d2f,112029fb4744e7fec8deb93f67d383b2aab51126815ce905a26194f442af7b35 +``` ## Messages @@ -148,12 +180,19 @@ Request block headers. ### HEADERS -Send block headers. +Send block headers. Headers are variable-length. -| Field | Type | Description | -|-------|------|-------------| -| count | VarInt | Number of headers (max 80000) | -| headers | BlockHeader[] | Block headers | +| Field | Type | Description | +|---------|---------------|-------------------------------------| +| count | VarInt | Number of block headers (max 80000) | +| headers | BlockHeader[] | List of block headers | + +**BlockHeader Structure:** + +| Field | Type | Description | +|-------|--------|----------------------------------------------| +| size | VarInt | Total size of the following block data | +| block | Bytes | Serialized block (112-byte header + payload) | ## Protocol Flows diff --git a/fuzz/fuzz_block_header.cpp b/fuzz/fuzz_block_header.cpp index 8d2ca2e..cdf030e 100644 --- a/fuzz/fuzz_block_header.cpp +++ b/fuzz/fuzz_block_header.cpp @@ -16,9 +16,9 @@ extern "C" int LLVMFuzzerTestOneInput(const uint8_t *data, size_t size) { if (success && !header.IsNull()) { auto serialized = header.Serialize(); - // CRITICAL: Validate serialized size is exactly BLOCK_HEADER_SIZE (100 bytes) - // Unicity headers: 4 + 32 + 20 + 4 + 4 + 4 + 32 = 100 bytes - // (version + prevHash + minerAddress + time + bits + nonce + hashRandomX) + // CRITICAL: Validate serialized size is exactly BLOCK_HEADER_SIZE (112 bytes) + // Unicity headers: 4 + 32 + 32 + 4 + 4 + 4 + 32 = 112 bytes + // (version + prevHash + payloadRoot + time + bits + nonce + hashRandomX) if (serialized.size() != CBlockHeader::HEADER_SIZE) { // Serialize() produced wrong size - BUG! __builtin_trap(); @@ -45,8 +45,8 @@ extern "C" int LLVMFuzzerTestOneInput(const uint8_t *data, size_t size) { __builtin_trap(); } - if (header.minerAddress != header2.minerAddress) { - // Miner address changed during round-trip - BUG! + if (header.payloadRoot != header2.payloadRoot) { + // Payload root changed during round-trip - BUG! __builtin_trap(); } diff --git a/fuzz/fuzz_chain_reorg.cpp b/fuzz/fuzz_chain_reorg.cpp index 006d7a5..06e0830 100644 --- a/fuzz/fuzz_chain_reorg.cpp +++ b/fuzz/fuzz_chain_reorg.cpp @@ -22,6 +22,9 @@ #include #include +#include "../test/common/mock_trust_base_manager.hpp" +#include "../test/common/test_chainstate_manager.hpp" + using namespace unicity; using namespace unicity::chain; using namespace unicity::validation; @@ -31,7 +34,8 @@ using namespace unicity::consensus; class FuzzChainstateManager : public ChainstateManager { public: FuzzChainstateManager(const ChainParams& params) - : ChainstateManager(params) {} + : FuzzChainstateManager(params, std::make_unique()) + {} // Override PoW check to always pass (we're fuzzing chain logic, not RandomX) bool CheckProofOfWork(const CBlockHeader& header, crypto::POWVerifyMode mode) const override { @@ -56,6 +60,14 @@ class FuzzChainstateManager : public ChainstateManager { } return true; } + +private: + FuzzChainstateManager(const chain::ChainParams& params, std::unique_ptr tbm) + : ChainstateManager(params, *tbm), + mock_tbm_(std::move(tbm)) + {} + + std::unique_ptr mock_tbm_; }; // Fuzz input parser @@ -115,7 +127,7 @@ CBlockHeader BuildFuzzHeader(FuzzInput& input, const uint256& prevHash, uint32_t // Fuzz miner address (just use random bytes) for (size_t i = 0; i < 20; i++) { - header.minerAddress.data()[i] = input.ReadByte(); + header.payloadRoot.data()[i] = input.ReadByte(); } // Time: base + small offset to keep roughly increasing @@ -124,6 +136,7 @@ CBlockHeader BuildFuzzHeader(FuzzInput& input, const uint256& prevHash, uint32_t // Difficulty: use easy target for fuzzing header.nBits = 0x207fffff; // Very easy target + header.payloadRoot.SetHex("00"); // Nonce and RandomX hash (not validated in fuzz mode) header.nNonce = input.ReadUInt32(); @@ -176,7 +189,7 @@ extern "C" int LLVMFuzzerTestOneInput(const uint8_t *data, size_t size) { baseTime = header.nTime + 120; ValidationState state; - auto* pindex = chainstate.AcceptBlockHeader(header, state, true); + auto* pindex = chainstate.AcceptBlockHeader(header, state); if (pindex) { chain_tips[0] = header.GetHash(); } @@ -198,7 +211,7 @@ extern "C" int LLVMFuzzerTestOneInput(const uint8_t *data, size_t size) { CBlockHeader header = BuildFuzzHeader(input, prevHash, baseTime); ValidationState state; - auto* pindex = chainstate.AcceptBlockHeader(header, state, true); + auto* pindex = chainstate.AcceptBlockHeader(header, state); if (pindex && chain_tips.size() < num_chains) { chain_tips.push_back(header.GetHash()); } @@ -216,7 +229,7 @@ extern "C" int LLVMFuzzerTestOneInput(const uint8_t *data, size_t size) { baseTime = header.nTime + 120; ValidationState state; - auto* pindex = chainstate.AcceptBlockHeader(header, state, true); + auto* pindex = chainstate.AcceptBlockHeader(header, state); if (pindex) { chain_tips[tip_idx] = header.GetHash(); } @@ -231,7 +244,7 @@ extern "C" int LLVMFuzzerTestOneInput(const uint8_t *data, size_t size) { CBlockHeader header = BuildFuzzHeader(input, fakeParent, baseTime); ValidationState state; - chainstate.AcceptBlockHeader(header, state, input.ReadByte()); + chainstate.AcceptBlockHeader(header, state); // Might be orphaned or rejected break; } diff --git a/fuzz/fuzz_header_validation.cpp b/fuzz/fuzz_header_validation.cpp index 878924b..7b32e97 100644 --- a/fuzz/fuzz_header_validation.cpp +++ b/fuzz/fuzz_header_validation.cpp @@ -25,6 +25,8 @@ #include #include +#include "../test/common/mock_trust_base_manager.hpp" + using namespace unicity; // FuzzInput: Parse structured fuzz data @@ -64,7 +66,7 @@ extern "C" int LLVMFuzzerTestOneInput(const uint8_t *data, size_t size) { FuzzInput input(data, size); uint8_t mode = input.read(); - // Parse fuzzed block header (100 bytes) + // Parse fuzzed block header (112 bytes) CBlockHeader header; header.nVersion = input.read(); @@ -75,12 +77,12 @@ extern "C" int LLVMFuzzerTestOneInput(const uint8_t *data, size_t size) { } memcpy(header.hashPrevBlock.begin(), prevHash, 32); - // minerAddress (20 bytes) - uint8_t minerAddr[20]; - for (int i = 0; i < 20; i++) { - minerAddr[i] = input.read(); + // payloadRoot (32 bytes) + uint8_t payloadRoot[32]; + for (int i = 0; i < 32; i++) { + payloadRoot[i] = input.read(); } - memcpy(header.minerAddress.begin(), minerAddr, 20); + memcpy(header.payloadRoot.begin(), payloadRoot, 32); header.nTime = input.read(); header.nBits = input.read(); @@ -100,7 +102,8 @@ extern "C" int LLVMFuzzerTestOneInput(const uint8_t *data, size_t size) { if ((mode & 0x07) == 0) { try { validation::ValidationState state; - bool result = validation::CheckBlockHeader(header, *params, state); + test::MockTrustBaseManager mock_tbm; + bool result = validation::CheckBlockHeader(header, *params, state, mock_tbm); // If it returns true, header must have valid PoW // If false, state should have rejection reason @@ -111,7 +114,7 @@ extern "C" int LLVMFuzzerTestOneInput(const uint8_t *data, size_t size) { // CheckBlockHeader should be deterministic validation::ValidationState state2; - bool result2 = validation::CheckBlockHeader(header, *params, state2); + bool result2 = validation::CheckBlockHeader(header, *params, state2, mock_tbm); if (result != result2) { // Non-deterministic validation - BUG! __builtin_trap(); @@ -176,9 +179,9 @@ extern "C" int LLVMFuzzerTestOneInput(const uint8_t *data, size_t size) { for (int j = 0; j < 32; j++) hash[j] = input.read(); memcpy(h.hashPrevBlock.begin(), hash, 32); - uint8_t addr[20]; - for (int j = 0; j < 20; j++) addr[j] = input.read(); - memcpy(h.minerAddress.begin(), addr, 20); + uint8_t addr[32]; + for (int j = 0; j < 32; j++) addr[j] = input.read(); + memcpy(h.payloadRoot.begin(), addr, 32); h.nTime = input.read(); h.nBits = input.read(); @@ -224,9 +227,9 @@ extern "C" int LLVMFuzzerTestOneInput(const uint8_t *data, size_t size) { memcpy(h.hashPrevBlock.begin(), hash, 32); } - uint8_t addr[20]; - for (int j = 0; j < 20; j++) addr[j] = input.read(); - memcpy(h.minerAddress.begin(), addr, 20); + uint8_t addr[32]; + for (int j = 0; j < 32; j++) addr[j] = input.read(); + memcpy(h.payloadRoot.begin(), addr, 32); h.nTime = input.read(); h.nBits = input.read(); @@ -265,9 +268,9 @@ extern "C" int LLVMFuzzerTestOneInput(const uint8_t *data, size_t size) { for (int j = 0; j < 32; j++) hash[j] = input.read(); memcpy(h.hashPrevBlock.begin(), hash, 32); - uint8_t addr[20]; - for (int j = 0; j < 20; j++) addr[j] = input.read(); - memcpy(h.minerAddress.begin(), addr, 20); + uint8_t addr[32]; + for (int j = 0; j < 32; j++) addr[j] = input.read(); + memcpy(h.payloadRoot.begin(), addr, 32); h.nTime = input.read(); h.nBits = input.read(); @@ -314,7 +317,8 @@ extern "C" int LLVMFuzzerTestOneInput(const uint8_t *data, size_t size) { try { validation::ValidationState state; - validation::CheckBlockHeader(test_header, *params, state); + test::MockTrustBaseManager mock_tbm; + validation::CheckBlockHeader(test_header, *params, state, mock_tbm); // Should not crash regardless of timestamp } catch (const std::exception&) { __builtin_trap(); @@ -339,7 +343,8 @@ extern "C" int LLVMFuzzerTestOneInput(const uint8_t *data, size_t size) { try { validation::ValidationState state; - validation::CheckBlockHeader(test_header, *params, state); + test::MockTrustBaseManager mock_tbm; + validation::CheckBlockHeader(test_header, *params, state, mock_tbm); // Should not crash regardless of nBits } catch (const std::exception&) { __builtin_trap(); diff --git a/fuzz/fuzz_messages.cpp b/fuzz/fuzz_messages.cpp index 957dcd9..2aff4e0 100644 --- a/fuzz/fuzz_messages.cpp +++ b/fuzz/fuzz_messages.cpp @@ -6,6 +6,7 @@ #include #include #include +#include extern "C" int LLVMFuzzerTestOneInput(const uint8_t *data, size_t size) { using namespace unicity::message; @@ -20,8 +21,8 @@ extern "C" int LLVMFuzzerTestOneInput(const uint8_t *data, size_t size) { std::unique_ptr msg; - // Create message based on type selector -switch (msg_type % 9) { + // Create message based on type selector (8 supported types) + switch (msg_type % 8) { case 0: msg = std::make_unique(); break; @@ -41,12 +42,9 @@ switch (msg_type % 9) { msg = std::make_unique(); break; case 6: - msg = std::make_unique(); - break; - case 7: msg = std::make_unique(); break; - case 8: + case 7: msg = std::make_unique(); break; } @@ -69,16 +67,15 @@ switch (msg_type % 9) { // Create new message of same type and deserialize std::unique_ptr msg2; -switch (msg_type % 9) { + switch (msg_type % 8) { case 0: msg2 = std::make_unique(); break; case 1: msg2 = std::make_unique(); break; case 2: msg2 = std::make_unique(); break; case 3: msg2 = std::make_unique(); break; case 4: msg2 = std::make_unique(); break; case 5: msg2 = std::make_unique(); break; - case 6: msg2 = std::make_unique(); break; -case 7: msg2 = std::make_unique(); break; - case 8: msg2 = std::make_unique(); break; + case 6: msg2 = std::make_unique(); break; + case 7: msg2 = std::make_unique(); break; } if (msg2) { diff --git a/fuzz/fuzz_randomx_pow.cpp b/fuzz/fuzz_randomx_pow.cpp index f2be900..4592621 100644 --- a/fuzz/fuzz_randomx_pow.cpp +++ b/fuzz/fuzz_randomx_pow.cpp @@ -52,8 +52,8 @@ class FuzzInput { }; extern "C" int LLVMFuzzerTestOneInput(const uint8_t *data, size_t size) { - // Need at least 100 bytes for a block header - if (size < 100) return 0; + // Need at least 112 bytes for a block header + if (size < 112) return 0; FuzzInput input(data, size); @@ -69,7 +69,7 @@ extern "C" int LLVMFuzzerTestOneInput(const uint8_t *data, size_t size) { } } - // Parse fuzzed block header (100 bytes for Unicity) + // Parse fuzzed block header (112 bytes for Unicity) CBlockHeader header; header.nVersion = input.read(); @@ -80,12 +80,12 @@ extern "C" int LLVMFuzzerTestOneInput(const uint8_t *data, size_t size) { } memcpy(header.hashPrevBlock.begin(), prevHash, 32); - // minerAddress (20 bytes) - uint8_t minerAddr[20]; - for (int i = 0; i < 20; i++) { - minerAddr[i] = input.read(); + // payloadRoot (32 bytes) + uint8_t payloadRoot[32]; + for (int i = 0; i < 32; i++) { + payloadRoot[i] = input.read(); } - memcpy(header.minerAddress.begin(), minerAddr, 20); + memcpy(header.payloadRoot.begin(), payloadRoot, 32); header.nTime = input.read(); header.nBits = input.read(); diff --git a/fuzz/generate_chain_seeds.py b/fuzz/generate_chain_seeds.py index 2fd09e2..8586305 100644 --- a/fuzz/generate_chain_seeds.py +++ b/fuzz/generate_chain_seeds.py @@ -29,8 +29,8 @@ def build_simple_chain(): # Action 0: Extend main chain (repeat 10 times) for i in range(10): data.append(0) # action = extend main chain - # Miner address (20 bytes) - data.extend([i] * 20) + # Payload root (32 bytes) + data.extend([i] * 32) data.append(i * 10) # time offset data.extend([0, 0, 0, i]) # nonce data.extend([i] * 32) # hashRandomX diff --git a/fuzz/oss-fuzz/build.sh b/fuzz/oss-fuzz/build.sh index 23c3870..d699283 100755 --- a/fuzz/oss-fuzz/build.sh +++ b/fuzz/oss-fuzz/build.sh @@ -38,10 +38,13 @@ cp fuzz/fuzz_lock_directory $OUT/ # Create seed corpora for better fuzzing -# Seed corpus for block headers (100 bytes each) +# Seed corpus for block headers (112-byte header + variable-length payload) mkdir -p $OUT/fuzz_block_header_seed_corpus -# Create a few valid block headers as seeds -echo -n "0100000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000" | xxd -r -p > $OUT/fuzz_block_header_seed_corpus/valid_header_1 +# Create a valid non-null block header (112 bytes) with a 32-byte payload (total 144 bytes) +# Offset 68 (nTime) is set to 1 (01000000 in LE) to ensure header is not considered null +printf '00%.0s' {1..68} | xxd -r -p > $OUT/fuzz_block_header_seed_corpus/valid_header_1 +printf '01000000' | xxd -r -p >> $OUT/fuzz_block_header_seed_corpus/valid_header_1 +printf '00%.0s' {1..72} | xxd -r -p >> $OUT/fuzz_block_header_seed_corpus/valid_header_1 zip -j $OUT/fuzz_block_header_seed_corpus.zip $OUT/fuzz_block_header_seed_corpus/* # Seed corpus for messages (various message types) diff --git a/include/application.hpp b/include/application.hpp index cdd32b0..b01e216 100644 --- a/include/application.hpp +++ b/include/application.hpp @@ -6,7 +6,9 @@ #include "chain/block_manager.hpp" #include "chain/chainparams.hpp" #include "chain/miner.hpp" +#include "chain/trust_base_manager.hpp" #include "network/network_manager.hpp" +#include "chain/token_manager.hpp" #include "chain/notifications.hpp" #include "network/rpc_server.hpp" #include "util/files.hpp" @@ -42,6 +44,9 @@ struct AppConfig { // Logging bool verbose = false; + // BFT RPC address + std::string bftaddr = "http://127.0.0.1:25866"; + AppConfig() : datadir(util::get_default_datadir()) { // Default data directory set via initialization list // Set network parameters based on chain type @@ -70,6 +75,7 @@ class Application { validation::ChainstateManager &chainstate_manager() { return *chainstate_manager_; } + chain::TrustBaseManager &trust_base_manager() { return *trust_base_manager_; } const chain::ChainParams &chain_params() const { return *chain_params_; } // Status @@ -90,6 +96,8 @@ class Application { // Components (initialized in order) std::unique_ptr chain_params_; std::unique_ptr chainstate_manager_; + std::unique_ptr trust_base_manager_; + std::unique_ptr token_manager_; std::unique_ptr network_manager_; std::unique_ptr miner_; std::unique_ptr rpc_server_; @@ -105,6 +113,7 @@ class Application { bool init_datadir(); bool init_randomx(); bool init_chain(); + bool init_trustbase(); bool init_network(); bool init_rpc(); diff --git a/include/chain/bft_client.hpp b/include/chain/bft_client.hpp new file mode 100644 index 0000000..7a2f78f --- /dev/null +++ b/include/chain/bft_client.hpp @@ -0,0 +1,62 @@ +#pragma once + +#include "chain/trust_base.hpp" + +#include +#include +#include + +namespace httplib { class Client; } +#include + +namespace unicity::chain { + +class BFTClient { +public: + virtual ~BFTClient() = default; + + // Fetches a single trust base for the given epoch. + virtual std::optional FetchTrustBase(uint64_t epoch) = 0; + + // Fetches all trust bases starting from the given epoch. + virtual std::vector FetchTrustBases(uint64_t from_epoch) = 0; +}; + +class RegtestBFTClient : public BFTClient { +public: + RegtestBFTClient() = default; + explicit RegtestBFTClient(RootTrustBaseV1 genesis_utb) : genesis_utb_(std::move(genesis_utb)) {} + + std::optional FetchTrustBase(uint64_t epoch) override { + if (epoch == 1) { + return genesis_utb_; + } + return std::nullopt; + } + std::vector FetchTrustBases(uint64_t from_epoch) override { return {}; } + +private: + std::optional genesis_utb_; +}; + +class HttpBFTClient : public BFTClient { +public: + explicit HttpBFTClient(std::string bftaddr); + ~HttpBFTClient() override; + + std::optional FetchTrustBase(uint64_t epoch) override; + std::vector FetchTrustBases(uint64_t from_epoch) override; + + // Maximum size for an HTTP response from the BFT server. + static constexpr size_t MAX_BFT_RESPONSE_SIZE = 1024 * 1024; // 1 MB + +private: + static std::vector ParseTrustBasesResponse(const std::vector& data); + + std::string bftaddr_; + std::unique_ptr cli_; + + std::vector FetchHttp(const std::string& target); +}; + +} // namespace unicity::chain diff --git a/include/chain/block.hpp b/include/chain/block.hpp index ee618a2..dc11e47 100644 --- a/include/chain/block.hpp +++ b/include/chain/block.hpp @@ -16,7 +16,7 @@ // CBlockHeader - Block header structure (represents entire block in headers-only chain) // Based on Bitcoin's block header: -// - Uses minerAddress (uint160) instead of hashMerkleRoot +// - Uses payloadRoot (uint256) instead of hashMerkleRoot // - Includes hashRandomX for RandomX PoW algorithm // - No transaction data (headers-only chain) // @@ -27,30 +27,34 @@ class CBlockHeader { // Block header fields (initialized to zero/null for safety) int32_t nVersion{0}; uint256 hashPrevBlock{}; // Hash of previous block header (copied byte-for-byte as stored, no endian swap) - uint160 minerAddress{}; // Miner's address (copied byte-for-byte as stored, no endian swap) + uint256 payloadRoot{}; // Root of block payload tree (Hash of reward token id hash and UTB hash) uint32_t nTime{0}; // Unix timestamp uint32_t nBits{0}; // Difficulty target (compact format) uint32_t nNonce{0}; // Nonce for proof-of-work uint256 hashRandomX{}; // RandomX hash for PoW verification (copied byte-for-byte as stored, no endian swap) + // Appended variable-length payload (not part of the 112-byte header hashing) + // Contains at least 32 bytes: hash of rewardTokenId and UTB_cbor (if UTB epoch changed) + std::vector vPayload{}; + // Wire format constants static constexpr size_t UINT256_BYTES = 32; - static constexpr size_t UINT160_BYTES = 20; + static constexpr size_t MAX_PAYLOAD_SIZE = 4096; // 32 bytes token hash + UTB CBOR - // Serialized header size: 4 + 32 + 20 + 4 + 4 + 4 + 32 = 100 bytes + // Serialized header size: 4 + 32 + 32 + 4 + 4 + 4 + 32 = 112 bytes static constexpr size_t HEADER_SIZE = 4 + // nVersion (int32_t) UINT256_BYTES + // hashPrevBlock - UINT160_BYTES + // minerAddress + UINT256_BYTES + // payloadRoot 4 + // nTime (uint32_t) 4 + // nBits (uint32_t) 4 + // nNonce (uint32_t) UINT256_BYTES; // hashRandomX - // Field offsets within the 100-byte header (for serialization/deserialization) + // Field offsets within the 112-byte header (for serialization/deserialization) static constexpr size_t OFF_VERSION = 0; static constexpr size_t OFF_PREV = OFF_VERSION + 4; - static constexpr size_t OFF_MINER = OFF_PREV + UINT256_BYTES; - static constexpr size_t OFF_TIME = OFF_MINER + UINT160_BYTES; + static constexpr size_t OFF_PAYLOAD_ROOT = OFF_PREV + UINT256_BYTES; + static constexpr size_t OFF_TIME = OFF_PAYLOAD_ROOT + UINT256_BYTES; static constexpr size_t OFF_BITS = OFF_TIME + 4; static constexpr size_t OFF_NONCE = OFF_BITS + 4; static constexpr size_t OFF_RANDOMX = OFF_NONCE + 4; @@ -61,10 +65,9 @@ class CBlockHeader { // Compile-time verification - hash/address types static_assert(sizeof(uint256) == UINT256_BYTES, "uint256 must be 32 bytes"); - static_assert(sizeof(uint160) == UINT160_BYTES, "uint160 must be 20 bytes"); // Compile-time verification - total header size and offset math - static_assert(HEADER_SIZE == 100, "Header size must be 100 bytes"); + static_assert(HEADER_SIZE == 112, "Header size must be 112 bytes"); static_assert(OFF_RANDOMX + UINT256_BYTES == HEADER_SIZE, "offset math must be correct"); // Type alias for fixed-size header serialization @@ -73,25 +76,42 @@ class CBlockHeader { void SetNull() noexcept { nVersion = 0; hashPrevBlock.SetNull(); - minerAddress.SetNull(); + payloadRoot.SetNull(); nTime = 0; nBits = 0; nNonce = 0; hashRandomX.SetNull(); + vPayload.clear(); } [[nodiscard]] bool IsNull() const noexcept { - return nTime == 0 && nBits == 0 && nNonce == 0 && hashPrevBlock.IsNull() && minerAddress.IsNull() && + return nTime == 0 && nBits == 0 && nNonce == 0 && hashPrevBlock.IsNull() && payloadRoot.IsNull() && hashRandomX.IsNull(); } - // Compute the hash of this header + // Access UTB CBOR record from the payload (if present) + // Payload layout: [32 bytes token id hash] [optional UTB CBOR bytes] + [[nodiscard]] std::span GetUTB() const noexcept; + + // Compute the hash of this header (double SHA-256 of the 112-byte header) [[nodiscard]] uint256 GetHash() const noexcept; + // Compute payload root from two leaves (e.g. TokenID hash and UTB hash) + [[nodiscard]] static uint256 ComputePayloadRoot(const uint256& leaf_0, const uint256& leaf_1) noexcept; + + // Serialize only the 112-byte header into a fixed-size array. + // Avoids heap allocation, useful for hashing and PoW verification. + [[nodiscard]] HeaderBytes SerializeHeader() const noexcept; + // Serialize to wire format - // Note: Hash blobs (hashPrevBlock, minerAddress, hashRandomX) are copied + // Note: Hash blobs (hashPrevBlock, payloadRoot, hashRandomX) are copied // byte-for-byte as stored (no endian swap). Scalar fields use little-endian. - [[nodiscard]] HeaderBytes Serialize() const noexcept; + // Optional includePayload appends vPayload contents. + [[nodiscard]] std::vector Serialize(bool includePayload = false) const noexcept; + + // Serialize into an existing buffer + // Returns false if the buffer is too small. + bool SerializeInto(uint8_t* buf, size_t len, bool includePayload = false) const noexcept; // Deserialize from wire format [[nodiscard]] bool Deserialize(const uint8_t* data, size_t size) noexcept; @@ -103,10 +123,6 @@ class CBlockHeader { [[nodiscard]] std::string ToString() const; }; -// Verify no padding in struct (sanity check that field layout matches wire format) -static_assert(sizeof(CBlockHeader) == CBlockHeader::HEADER_SIZE, - "CBlockHeader has unexpected padding - check field alignment"); - // CBlockLocator - Describes a position in the block chain (for finding common ancestor with peer) struct CBlockLocator { std::vector vHave; diff --git a/include/chain/block_index.hpp b/include/chain/block_index.hpp index 0956342..34f49b6 100644 --- a/include/chain/block_index.hpp +++ b/include/chain/block_index.hpp @@ -1,187 +1,194 @@ -// Copyright (c) 2009-2022 The Bitcoin Core developers -// Copyright (c) 2025 The Unicity Foundation -// Distributed under the MIT software license - -#pragma once - -#include "chain/block.hpp" -#include "util/arith_uint256.hpp" -#include "util/uint.hpp" - -#include -#include -#include -#include -#include - -namespace unicity { -namespace chain { - -// Median Time Past calculation span (number of previous blocks) -// Used by GetMedianTimePast() -static constexpr int MEDIAN_TIME_SPAN = 11; -static_assert(MEDIAN_TIME_SPAN % 2 == 1, "MEDIAN_TIME_SPAN must be odd for proper median calculation"); - -// BlockStatus - Tracks validation progress and failure state of a block header -// Separates validation level (how far validated) from failure state (is it failed). -struct BlockStatus { - // Validation progression (how far has this header been validated?) - enum ValidationLevel : uint8_t { - UNKNOWN = 0, // Not yet validated - HEADER = 1, // Reserved (unused - we validate fully before inserting into index) - TREE = 2 // Fully validated: PoW, context, ancestors all checked - }; - - // Failure state (is this block failed, and why?) - enum FailureState : uint8_t { - NOT_FAILED = 0, // Block is not failed - VALIDATION_FAILED = 1, // This block itself failed validation - ANCESTOR_FAILED = 2 // Descends from a failed ancestor - }; - - ValidationLevel validation{UNKNOWN}; - FailureState failure{NOT_FAILED}; - - // Query methods (maintain same API surface as before) - [[nodiscard]] bool IsFailed() const noexcept { return failure != NOT_FAILED; } - - [[nodiscard]] bool IsValid(ValidationLevel required = TREE) const noexcept { - return !IsFailed() && validation >= required; - } - - [[nodiscard]] bool RaiseValidity(ValidationLevel level) noexcept { - if (IsFailed()) - return false; - if (validation < level) { - validation = level; - return true; - } - return false; - } - - void MarkFailed() noexcept { failure = VALIDATION_FAILED; } - void MarkAncestorFailed() noexcept { failure = ANCESTOR_FAILED; } - - // For debugging - [[nodiscard]] std::string ToString() const; -}; - -// CBlockIndex - Metadata for a single block header -class CBlockIndex { -public: - BlockStatus status{}; - - // Block hash - // Set by BlockManager::AddToBlockIndex() or Load() after creation. - uint256 m_block_hash{}; - - // Pointer to previous block in chain (DOES NOT OWN). - // Forms the blockchain tree structure by linking to parent. - // nullptr for genesis block, otherwise points to parent block's CBlockIndex. - CBlockIndex* pprev{nullptr}; - - // Skip list pointer for O(log n) ancestor lookup. - // Points to an ancestor at a strategically chosen height to enable - // logarithmic-time traversal. Set by BuildSkip() when block is added to chain. - CBlockIndex* pskip{nullptr}; - - // Height of this block in the chain (genesis = 0) - int nHeight{0}; - - // Cumulative work up to and including this block - arith_uint256 nChainWork{}; - - // Block header fields (stored inline) - int32_t nVersion{0}; - uint160 minerAddress{}; // Default-initialized (SetNull()) - uint32_t nTime{0}; - uint32_t nBits{0}; - uint32_t nNonce{0}; - uint256 hashRandomX{}; // Default-initialized (SetNull()) - - // Time when we first learned about this block (for relay decisions) - // Blocks received recently (< MAX_BLOCK_RELAY_AGE) are relayed to peers - // Old blocks (from disk/reorgs) are not relayed (peers already know them) - int64_t nTimeReceived{0}; - - // Constructor - CBlockIndex() = default; - - explicit CBlockIndex(const CBlockHeader& block) - : nVersion{block.nVersion}, minerAddress{block.minerAddress}, nTime{block.nTime}, nBits{block.nBits}, - nNonce{block.nNonce}, hashRandomX{block.hashRandomX} {} - - // Returns block hash - [[nodiscard]] const uint256& GetBlockHash() const noexcept { return m_block_hash; } - - // Reconstruct full block header (self-contained, safe to use if CBlockIndex destroyed) - [[nodiscard]] CBlockHeader GetBlockHeader() const noexcept { - CBlockHeader block; - block.nVersion = nVersion; - if (pprev) - block.hashPrevBlock = pprev->GetBlockHash(); - block.minerAddress = minerAddress; - block.nTime = nTime; - block.nBits = nBits; - block.nNonce = nNonce; - block.hashRandomX = hashRandomX; - return block; - } - - [[nodiscard]] int64_t GetBlockTime() const noexcept { return static_cast(nTime); } - - // Calculate Median Time Past (MTP) for timestamp validation. - // Takes median of last MEDIAN_TIME_SPAN blocks (11) or fewer if - // near genesis. New block time must be > MTP. - // NOTE: MTP is only used for regtest. - // Mainnet/testnet use strictly increasing timestamps - [[nodiscard]] int64_t GetMedianTimePast() const noexcept { - std::array times{}; - int count = 0; - - for (const CBlockIndex* p = this; p && count < MEDIAN_TIME_SPAN; p = p->pprev) { - times[static_cast(count++)] = p->GetBlockTime(); - } - - std::sort(times.begin(), times.begin() + count); - return times[static_cast(count / 2)]; - } - - // Build skip list pointer - // Must be called when adding block to chain, after pprev and nHeight are set - void BuildSkip() noexcept; - - // Get ancestor at given height using skip list (O(log n) with skip list) - [[nodiscard]] const CBlockIndex* GetAncestor(int height) const noexcept; - [[nodiscard]] CBlockIndex* GetAncestor(int height) noexcept; - - [[nodiscard]] bool IsValid(BlockStatus::ValidationLevel level = BlockStatus::TREE) const noexcept { - return status.IsValid(level); - } - - // Raise validity level of this block, returns true if changed - [[nodiscard]] bool RaiseValidity(BlockStatus::ValidationLevel level) noexcept { return status.RaiseValidity(level); } - - // For debugging/testing only - produces human-readable representation - [[nodiscard]] std::string ToString() const; - - // Copy/move operations are DELETED to prevent dangling pointer bugs. - // Use GetBlockHeader() to extract a self-contained copy of block data. - CBlockIndex(const CBlockIndex&) = delete; - CBlockIndex& operator=(const CBlockIndex&) = delete; - CBlockIndex(CBlockIndex&&) = delete; - CBlockIndex& operator=(CBlockIndex&&) = delete; -}; - -// Calculate proof-of-work for a block -// Returns work = ~target / (target + 1) + 1 (mathematically equivalent to 2^256 -// / (target + 1)) Invalid targets return 0 work. -[[nodiscard]] arith_uint256 GetBlockProof(const CBlockIndex& block) noexcept; - -// Find last common ancestor of two blocks (aligns heights, then walks backward -// until they meet) Returns nullptr if either input is nullptr. All valid chains -// share genesis. -[[nodiscard]] const CBlockIndex* LastCommonAncestor(const CBlockIndex* pa, const CBlockIndex* pb) noexcept; - -} // namespace chain -} // namespace unicity +// Copyright (c) 2009-2022 The Bitcoin Core developers +// Copyright (c) 2025 The Unicity Foundation +// Distributed under the MIT software license + +#pragma once + +#include "chain/block.hpp" +#include "util/arith_uint256.hpp" +#include "util/uint.hpp" + +#include +#include +#include +#include +#include + +namespace unicity { +namespace chain { + +// Median Time Past calculation span (number of previous blocks) +// Used by GetMedianTimePast() +static constexpr int MEDIAN_TIME_SPAN = 11; +static_assert(MEDIAN_TIME_SPAN % 2 == 1, "MEDIAN_TIME_SPAN must be odd for proper median calculation"); + +// BlockStatus - Tracks validation progress and failure state of a block header +// Separates validation level (how far validated) from failure state (is it failed). +struct BlockStatus { + // Validation progression (how far has this header been validated?) + enum ValidationLevel : uint8_t { + UNKNOWN = 0, // Not yet validated + HEADER = 1, // Reserved (unused - we validate fully before inserting into index) + TREE = 2 // Fully validated: PoW, context, ancestors all checked + }; + + // Failure state (is this block failed, and why?) + enum FailureState : uint8_t { + NOT_FAILED = 0, // Block is not failed + VALIDATION_FAILED = 1, // This block itself failed validation + ANCESTOR_FAILED = 2 // Descends from a failed ancestor + }; + + ValidationLevel validation{UNKNOWN}; + FailureState failure{NOT_FAILED}; + + // Query methods (maintain same API surface as before) + [[nodiscard]] bool IsFailed() const noexcept { return failure != NOT_FAILED; } + + [[nodiscard]] bool IsValid(ValidationLevel required = TREE) const noexcept { + return !IsFailed() && validation >= required; + } + + [[nodiscard]] bool RaiseValidity(ValidationLevel level) noexcept { + if (IsFailed()) + return false; + if (validation < level) { + validation = level; + return true; + } + return false; + } + + void MarkFailed() noexcept { failure = VALIDATION_FAILED; } + void MarkAncestorFailed() noexcept { failure = ANCESTOR_FAILED; } + + // For debugging + [[nodiscard]] std::string ToString() const; +}; + +// CBlockIndex - Metadata for a single block header +class CBlockIndex { +public: + BlockStatus status{}; + + // Block hash + // Set by BlockManager::AddToBlockIndex() or Load() after creation. + uint256 m_block_hash{}; + + // Pointer to previous block in chain (DOES NOT OWN). + // Forms the blockchain tree structure by linking to parent. + // nullptr for genesis block, otherwise points to parent block's CBlockIndex. + CBlockIndex* pprev{nullptr}; + + // Skip list pointer for O(log n) ancestor lookup. + // Points to an ancestor at a strategically chosen height to enable + // logarithmic-time traversal. Set by BuildSkip() when block is added to chain. + CBlockIndex* pskip{nullptr}; + + // Height of this block in the chain (genesis = 0) + int nHeight{0}; + + // Cumulative work up to and including this block + arith_uint256 nChainWork{}; + + // BFT epoch number of the last included UTB (or the epoch of currently included UTB in the current block) + uint64_t bftEpoch{1}; + + // Block header fields (stored inline) + int32_t nVersion{0}; + uint256 payloadRoot{}; // Default-initialized (SetNull()) + uint32_t nTime{0}; + uint32_t nBits{0}; + uint32_t nNonce{0}; + uint256 hashRandomX{}; // Default-initialized (SetNull()) + + // Appended variable-length payload + std::vector vPayload{}; + + // Time when we first learned about this block (for relay decisions) + // Blocks received recently (< MAX_BLOCK_RELAY_AGE) are relayed to peers + // Old blocks (from disk/reorgs) are not relayed (peers already know them) + int64_t nTimeReceived{0}; + + // Constructor + CBlockIndex() = default; + + explicit CBlockIndex(const CBlockHeader& block) + : nVersion{block.nVersion}, payloadRoot{block.payloadRoot}, nTime{block.nTime}, nBits{block.nBits}, + nNonce{block.nNonce}, hashRandomX{block.hashRandomX}, vPayload{block.vPayload} {} + + // Returns block hash + [[nodiscard]] const uint256& GetBlockHash() const noexcept { return m_block_hash; } + + // Reconstruct full block header (self-contained, safe to use if CBlockIndex destroyed) + [[nodiscard]] CBlockHeader GetBlockHeader() const noexcept { + CBlockHeader block; + block.nVersion = nVersion; + if (pprev) + block.hashPrevBlock = pprev->GetBlockHash(); + block.payloadRoot = payloadRoot; + block.nTime = nTime; + block.nBits = nBits; + block.nNonce = nNonce; + block.hashRandomX = hashRandomX; + block.vPayload = vPayload; + return block; + } + + [[nodiscard]] int64_t GetBlockTime() const noexcept { return static_cast(nTime); } + + // Calculate Median Time Past (MTP) for timestamp validation. + // Takes median of last MEDIAN_TIME_SPAN blocks (11) or fewer if + // near genesis. New block time must be > MTP. + // NOTE: MTP is only used for regtest. + // Mainnet/testnet use strictly increasing timestamps + [[nodiscard]] int64_t GetMedianTimePast() const noexcept { + std::array times{}; + int count = 0; + + for (const CBlockIndex* p = this; p && count < MEDIAN_TIME_SPAN; p = p->pprev) { + times[static_cast(count++)] = p->GetBlockTime(); + } + + std::sort(times.begin(), times.begin() + count); + return times[static_cast(count / 2)]; + } + + // Build skip list pointer + // Must be called when adding block to chain, after pprev and nHeight are set + void BuildSkip() noexcept; + + // Get ancestor at given height using skip list (O(log n) with skip list) + [[nodiscard]] const CBlockIndex* GetAncestor(int height) const noexcept; + [[nodiscard]] CBlockIndex* GetAncestor(int height) noexcept; + + [[nodiscard]] bool IsValid(BlockStatus::ValidationLevel level = BlockStatus::TREE) const noexcept { + return status.IsValid(level); + } + + // Raise validity level of this block, returns true if changed + [[nodiscard]] bool RaiseValidity(BlockStatus::ValidationLevel level) noexcept { return status.RaiseValidity(level); } + + // For debugging/testing only - produces human-readable representation + [[nodiscard]] std::string ToString() const; + + // Copy/move operations are DELETED to prevent dangling pointer bugs. + // Use GetBlockHeader() to extract a self-contained copy of block data. + CBlockIndex(const CBlockIndex&) = delete; + CBlockIndex& operator=(const CBlockIndex&) = delete; + CBlockIndex(CBlockIndex&&) = delete; + CBlockIndex& operator=(CBlockIndex&&) = delete; +}; + +// Calculate proof-of-work for a block +// Returns work = ~target / (target + 1) + 1 (mathematically equivalent to 2^256 +// / (target + 1)) Invalid targets return 0 work. +[[nodiscard]] arith_uint256 GetBlockProof(const CBlockIndex& block) noexcept; + +// Find last common ancestor of two blocks (aligns heights, then walks backward +// until they meet) Returns nullptr if either input is nullptr. All valid chains +// share genesis. +[[nodiscard]] const CBlockIndex* LastCommonAncestor(const CBlockIndex* pa, const CBlockIndex* pb) noexcept; + +} // namespace chain +} // namespace unicity diff --git a/include/chain/chainparams.hpp b/include/chain/chainparams.hpp index bc54eae..f375b07 100644 --- a/include/chain/chainparams.hpp +++ b/include/chain/chainparams.hpp @@ -128,7 +128,7 @@ class GlobalChainParams { }; // Helper to create genesis block -CBlockHeader CreateGenesisBlock(uint32_t nTime, uint32_t nNonce, uint32_t nBits, int32_t nVersion = 1); +CBlockHeader CreateGenesisBlock(uint32_t nTime, uint32_t nNonce, uint32_t nBits, std::span utb_cbor, int32_t nVersion = 1); } // namespace chain } // namespace unicity diff --git a/include/chain/chainstate_manager.hpp b/include/chain/chainstate_manager.hpp index fb9e6ea..11b3316 100644 --- a/include/chain/chainstate_manager.hpp +++ b/include/chain/chainstate_manager.hpp @@ -25,6 +25,7 @@ namespace unicity { namespace chain { class ChainParams; class CBlockIndex; +class TrustBaseManager; } // namespace chain namespace crypto { @@ -43,7 +44,7 @@ namespace validation { class ChainstateManager { public: // LIFETIME: ChainParams reference must outlive this ChainstateManager - explicit ChainstateManager(const chain::ChainParams& params); + explicit ChainstateManager(const chain::ChainParams& params, chain::TrustBaseManager& tbm); // Accept a block header into the block index. // Returns pointer to CBlockIndex on success, nullptr on failure (check state). @@ -205,6 +206,7 @@ class ChainstateManager { chain::BlockManager block_manager_; ActiveTipCandidates active_tip_candidates_; const chain::ChainParams& params_; + chain::TrustBaseManager& tbm_; // Cached IBD status (latches false once complete, atomic for lock-free reads) mutable std::atomic m_cached_finished_ibd{false}; diff --git a/include/chain/miner.hpp b/include/chain/miner.hpp index 0153b35..338cf11 100644 --- a/include/chain/miner.hpp +++ b/include/chain/miner.hpp @@ -5,9 +5,11 @@ #include "chain/block.hpp" #include "chain/chainparams.hpp" +#include "chain/trust_base_manager.hpp" #include "util/uint.hpp" #include +#include #include #include #include @@ -28,18 +30,22 @@ class ChainstateManager; namespace mining { +class TokenManager; + // Block template for mining struct BlockTemplate { CBlockHeader header; uint32_t nBits; int nHeight; uint256 hashPrevBlock; + uint256 rewardTokenId; }; // Single-threaded CPU miner for regtest testing class CPUMiner { public: - CPUMiner(const chain::ChainParams& params, validation::ChainstateManager& chainstate); + CPUMiner(const chain::ChainParams& params, validation::ChainstateManager& chainstate, + chain::TrustBaseManager& trust_base_manager, TokenManager& token_manager); ~CPUMiner(); bool Start(int target_height = -1); // -1 = mine forever @@ -50,17 +56,6 @@ class CPUMiner { uint64_t GetTotalHashes() const { return total_hashes_.load(); } int GetBlocksFound() const { return blocks_found_.load(); } - // Set/get mining address (thread-safe, sticky across sessions) - void SetMiningAddress(const uint160& address) { - std::lock_guard lock(address_mutex_); - mining_address_ = address; - } - - uint160 GetMiningAddress() const { - std::lock_guard lock(address_mutex_); - return mining_address_; - } - // Invalidate current block template (called when chain tip changes) void InvalidateTemplate() { template_invalidated_.store(true); } @@ -75,9 +70,8 @@ class CPUMiner { const chain::ChainParams& params_; validation::ChainstateManager& chainstate_; - - uint160 mining_address_; - mutable std::mutex address_mutex_; + chain::TrustBaseManager& trust_base_manager_; + TokenManager& token_manager_; std::atomic mining_{false}; std::atomic total_hashes_{0}; diff --git a/include/chain/token_generator.hpp b/include/chain/token_generator.hpp new file mode 100644 index 0000000..a016d26 --- /dev/null +++ b/include/chain/token_generator.hpp @@ -0,0 +1,59 @@ +// Copyright (c) 2025 The Unicity Foundation +// Distributed under the MIT software license + +#pragma once + +#include "util/uint.hpp" + +#include +#include +#include + +namespace unicity::mining { + +// Generates Token IDs for mined blocks +// TokenID = Hash(seed || counter) +class TokenGenerator { +public: + struct State { + uint256 seed; + uint64_t counter; + }; + + explicit TokenGenerator(const std::filesystem::path& datadir); + + // Generate the next unique Token ID and persist the state. + [[nodiscard]] uint256 GenerateNextTokenId(); + + // Get current counter value + [[nodiscard]] uint64_t GetCounter() const; + + // Snapshot current state + [[nodiscard]] State GetState() const; + + // Reload state from disk (overwrites in-memory state) + bool LoadState(); + + // Persist current in-memory state + [[nodiscard]] bool SaveState() const; + + private: + // Create fresh seed + reset counter + void InitializeFreshState(); + + // Persistence helpers + std::optional ReadStateFile() const; + + bool WriteStateFile(const State& state) const; + + + std::filesystem::path datadir_; + std::filesystem::path state_file_; + + mutable std::mutex mutex_; + + uint256 seed_; + uint64_t counter_; +}; + +} // namespace unicity::mining \ No newline at end of file diff --git a/include/chain/token_manager.hpp b/include/chain/token_manager.hpp new file mode 100644 index 0000000..0b9343d --- /dev/null +++ b/include/chain/token_manager.hpp @@ -0,0 +1,45 @@ +// Copyright (c) 2025 The Unicity Foundation +// Distributed under the MIT software license + +#pragma once + +#include "chain/block.hpp" +#include "chain/token_generator.hpp" +#include "util/uint.hpp" + +#include +#include + +namespace unicity { +namespace validation { +class ChainstateManager; +} + +namespace mining { + +/** + * TokenManager - Management of miner reward tokens. + */ +class TokenManager { +public: + explicit TokenManager(const std::filesystem::path& datadir, validation::ChainstateManager& chainstate); + + /** + * Generates a new rewardTokenId. + */ + uint256 GenerateNextTokenId() { return generator_.GenerateNextTokenId(); } + + /** + * Records the reward token ID to the miner reward CSV file. + */ + void RecordReward(const uint256& blockHash, const uint256& rewardTokenId); + +private: + std::filesystem::path datadir_; + validation::ChainstateManager& chainstate_; + TokenGenerator generator_; + mutable std::mutex csv_mutex_; +}; + +} // namespace mining +} // namespace unicity diff --git a/include/chain/trust_base.hpp b/include/chain/trust_base.hpp new file mode 100644 index 0000000..df03e41 --- /dev/null +++ b/include/chain/trust_base.hpp @@ -0,0 +1,67 @@ +#pragma once + +#include +#include +#include +#include +#include +#include +#include + +namespace unicity { +namespace chain { + +// CBOR Tag for RootTrustBaseV1 +constexpr int TAG_ROOT_TRUST_BASE = 39000; + +struct NodeInfo { + std::string node_id; + std::vector sig_key; + uint64_t stake; + + // Serialization for CBOR array format + friend void to_json(nlohmann::json& j, const NodeInfo& n); + friend void from_json(const nlohmann::json& j, NodeInfo& n); +}; + +struct RootTrustBaseV1 { + uint32_t version = 1; + uint32_t network_id = 0; + uint64_t epoch = 0; + uint64_t epoch_start = 0; + std::vector root_nodes; + uint64_t quorum_threshold = 0; + std::vector state_hash; + std::vector change_record_hash; + std::vector previous_entry_hash; + std::map> signatures; + + friend void to_json(nlohmann::json& j, const RootTrustBaseV1& r); + friend void from_json(const nlohmann::json& j, RootTrustBaseV1& r); + + static RootTrustBaseV1 FromCBOR(std::span data); + + // Serialization for signing/hashing (excludes signatures) + std::vector SigBytes() const; + + std::vector ToCBOR() const; + + // Hash of the structure (including signatures) + std::vector Hash() const; + + // Verification + // IsValid verifies the trust base content consistency (without signatures) + bool IsValid(const std::optional& prev) const; + + // VerifySignatures verifies that the trust base is signed by the previous epoch's validators + bool VerifySignatures(const std::optional& prev) const; + + // Verify verifies both content consistency and signatures + bool Verify(const std::optional& prev) const; + + // Helper to get a node info by ID + const NodeInfo* GetNode(const std::string& node_id) const; +}; + +} // namespace chain +} // namespace unicity diff --git a/include/chain/trust_base_manager.hpp b/include/chain/trust_base_manager.hpp new file mode 100644 index 0000000..f9946cb --- /dev/null +++ b/include/chain/trust_base_manager.hpp @@ -0,0 +1,70 @@ +#pragma once + +#include "chain/bft_client.hpp" +#include "chain/trust_base.hpp" + +#include +#include +#include +#include +#include +#include + +namespace unicity::chain { + +class TrustBaseManager { +public: + virtual ~TrustBaseManager() = default; + + // Loads the locally stored trust bases into memory. + // Verifies and stores the genesis UTB if not already stored. + virtual void Initialize(const std::vector& genesis_utb_data) = 0; + + // Loads all trust bases from last seen trust base, stores them locally and returns them. + virtual std::vector SyncTrustBases() = 0; + + // Loads the locally stored trust bases into memory. + virtual void Load() = 0; + + // Returns the latest locally stored trust base. + virtual std::optional GetLatestTrustBase() const = 0; + + // Syncs trust bases from the BFT node up to the specified target epoch. + // Stores them locally and returns the synced trust bases. + virtual std::vector SyncToEpoch(uint64_t target_epoch) = 0; + + // Returns trust base by epoch from local cache. + virtual std::optional GetTrustBase(uint64_t epoch) const = 0; + + // Verifies and stores the trust base. + virtual std::optional ProcessTrustBase(const RootTrustBaseV1& tb) = 0; +}; + +class LocalTrustBaseManager : public TrustBaseManager { +public: + explicit LocalTrustBaseManager(const std::filesystem::path& data_dir, std::shared_ptr bft_client); + ~LocalTrustBaseManager() override = default; + + void Initialize(const std::vector& genesis_utb_data) override; + std::vector SyncTrustBases() override; + void Load() override; + std::optional GetLatestTrustBase() const override; + std::vector SyncToEpoch(uint64_t target_epoch) override; + std::optional GetTrustBase(uint64_t epoch) const override; + std::optional ProcessTrustBase(const RootTrustBaseV1& tb) override; + +private: + std::filesystem::path data_dir_; + std::shared_ptr bft_client_; + mutable std::mutex mutex_; + std::map trust_bases_; + + void SaveToDisk(const RootTrustBaseV1& tb) const; + + // Returns pointer to latest trust base if any, or nullptr. Must be called with mutex_ held. + const RootTrustBaseV1* GetLatest() const { + return trust_bases_.empty() ? nullptr : &trust_bases_.rbegin()->second; + } +}; + +} // namespace unicity::chain diff --git a/include/chain/validation.hpp b/include/chain/validation.hpp index 0dc98fa..06c21ce 100644 --- a/include/chain/validation.hpp +++ b/include/chain/validation.hpp @@ -17,6 +17,7 @@ namespace unicity { namespace chain { class ChainParams; class CBlockIndex; +class TrustBaseManager; } // namespace chain namespace validation { @@ -92,7 +93,7 @@ class ValidationState { // SECURITY: Does NOT validate that nBits is correct for chain position // Always call ContextualCheckBlockHeader() afterward to verify nBits is // expected value -bool CheckBlockHeader(const CBlockHeader& header, const chain::ChainParams& params, ValidationState& state); +bool CheckBlockHeader(const CBlockHeader& header, const chain::ChainParams& params, ValidationState& state, const chain::TrustBaseManager& tbm); // Validates header follows chain consensus rules // Checks: nBits matches expected difficulty (ASERT), timestamps, version diff --git a/include/network/rpc_server.hpp b/include/network/rpc_server.hpp index 6e772e2..a2b21c5 100644 --- a/include/network/rpc_server.hpp +++ b/include/network/rpc_server.hpp @@ -19,6 +19,7 @@ namespace unicity { // Forward declarations namespace chain { +class TrustBaseManager; class ChainParams; } namespace network { @@ -26,6 +27,7 @@ class NetworkManager; } namespace mining { class CPUMiner; +class TokenManager; } namespace validation { class ChainstateManager; @@ -48,8 +50,11 @@ class RPCServer { }; RPCServer(const std::string& socket_path, validation::ChainstateManager& chainstate_manager, - network::NetworkManager& network_manager, mining::CPUMiner* miner, const chain::ChainParams& params, - std::function shutdown_callback = nullptr); + network::NetworkManager& network_manager, mining::CPUMiner* miner, + mining::TokenManager& token_manager, + chain::TrustBaseManager& trust_base_manager, + const chain::ChainParams& params, std::function shutdown_callback = nullptr); + ~RPCServer(); bool Start(); @@ -118,6 +123,8 @@ class RPCServer { validation::ChainstateManager& chainstate_manager_; network::NetworkManager& network_manager_; mining::CPUMiner* miner_; // Optional, can be nullptr + mining::TokenManager& token_manager_; + chain::TrustBaseManager& trust_base_manager_; const chain::ChainParams& params_; std::function shutdown_callback_; diff --git a/include/util/hash.hpp b/include/util/hash.hpp index c877c9f..64b9f2a 100644 --- a/include/util/hash.hpp +++ b/include/util/hash.hpp @@ -57,3 +57,10 @@ inline uint256 Hash(std::span data) { CHash256().Write(data.data(), data.size()).Finalize(result); return result; } + +/** Compute the single SHA-256 hash of a byte span. */ +inline uint256 SingleHash(std::span data) { + uint256 result; + CSHA256().Write(data.data(), data.size()).Finalize(result.begin()); + return result; +} diff --git a/include/util/string_parsing.hpp b/include/util/string_parsing.hpp index f009b8d..6d99f94 100644 --- a/include/util/string_parsing.hpp +++ b/include/util/string_parsing.hpp @@ -26,7 +26,10 @@ #include #include +#include #include +#include +#include // uint256 must be fully defined for std::optional #include "util/uint.hpp" @@ -106,6 +109,22 @@ std::optional SafeParseInt64(const std::string& str, int64_t min, int64 */ bool IsValidHex(const std::string& str); +/** + * Convert byte span to hexadecimal string + * + * @param data Byte span to convert + * @return Hexadecimal string + */ +std::string ToHex(std::span data); + +/** + * Parse hexadecimal string into byte vector + * + * @param hex Hexadecimal string + * @return Vector of bytes + */ +std::vector ParseHex(std::string_view hex); + /** * Parse 64-character hexadecimal hash string * diff --git a/src/application.cpp b/src/application.cpp index 81e256a..4054cb9 100644 --- a/src/application.cpp +++ b/src/application.cpp @@ -2,6 +2,7 @@ // Distributed under the MIT software license #include "application.hpp" +#include "chain/bft_client.hpp" #include "chain/randomx_pow.hpp" #include "network/addr_relay_manager.hpp" #include "util/fs_lock.hpp" @@ -47,20 +48,27 @@ bool Application::initialize() { switch (config_.chain_type) { case chain::ChainType::MAIN: chain_name = "MAINNET"; + chain_params_ = chain::ChainParams::CreateMainNet(); break; case chain::ChainType::TESTNET: chain_name = "TESTNET"; + chain_params_ = chain::ChainParams::CreateTestNet(); break; case chain::ChainType::REGTEST: chain_name = "REGTEST"; + chain_params_ = chain::ChainParams::CreateRegTest(); break; } + // Select chain type globally (needed by NetworkManager) + chain::GlobalChainParams::Select(config_.chain_type); + // Print startup banner (use std::cout for immediate visibility before logger // fully initialized) std::cout << GetStartupBanner(chain_name) << std::flush; LOG_INFO("Initializing Unicity..."); + LOG_INFO("Using {}", chain_name); // Create data directory if (!init_datadir()) { @@ -74,6 +82,12 @@ bool Application::initialize() { return false; } + // Initialize Trust Base Manager + if (!init_trustbase()) { + LOG_ERROR("Failed to initialize Trust Base Manager"); + return false; + } + // Initialize blockchain (creates chainstate_manager) if (!init_chain()) { LOG_ERROR("Failed to initialize blockchain"); @@ -82,8 +96,10 @@ bool Application::initialize() { // Initialize miner (after chainstate is ready) LOG_INFO("Initializing miner..."); - miner_ = - std::make_unique(*chain_params_, *chainstate_manager_); + token_manager_ = std::make_unique(config_.datadir, *chainstate_manager_); + miner_ = std::make_unique(*chain_params_, *chainstate_manager_, *trust_base_manager_, + *token_manager_); + // Initialize network manager if (!init_network()) { @@ -338,28 +354,36 @@ bool Application::init_randomx() { return true; } -bool Application::init_chain() { - LOG_INFO("Initializing blockchain..."); +bool Application::init_trustbase() { + LOG_INFO("Initializing Trust Base Manager..."); - // Select chain type globally (needed by NetworkManager) - chain::GlobalChainParams::Select(config_.chain_type); + std::shared_ptr bft_client; + if (config_.bftaddr.empty()) { + LOG_INFO("BFT integration disabled (bftaddr is empty)"); + auto genesis_utb_span = chain_params_->GenesisBlock().GetUTB(); + auto genesis_tb = chain::RootTrustBaseV1::FromCBOR(genesis_utb_span); + bft_client = std::make_shared(genesis_tb); + } else { + bft_client = std::make_shared(config_.bftaddr); + } + trust_base_manager_ = std::make_unique(config_.datadir, bft_client); - // Create chain params based on type - switch (config_.chain_type) { - case chain::ChainType::MAIN: - chain_params_ = chain::ChainParams::CreateMainNet(); - LOG_INFO("Using mainnet"); - break; - case chain::ChainType::TESTNET: - chain_params_ = chain::ChainParams::CreateTestNet(); - LOG_INFO("Using testnet"); - break; - case chain::ChainType::REGTEST: - chain_params_ = chain::ChainParams::CreateRegTest(); - LOG_INFO("Using regtest"); - break; + // Initialize with Genesis UTB + auto genesis_utb_span = chain_params_->GenesisBlock().GetUTB(); + std::vector genesis_utb(genesis_utb_span.begin(), genesis_utb_span.end()); + try { + trust_base_manager_->Initialize(genesis_utb); + } catch (const std::exception& e) { + LOG_ERROR("Failed to initialize Trust Base Manager with Genesis UTB: {}", e.what()); + return false; } + return true; +} + +bool Application::init_chain() { + LOG_INFO("Initializing blockchain..."); + // Create chainstate manager (which owns BlockManager) // Apply command-line override to chain params if provided if (config_.suspicious_reorg_depth > 0 && @@ -369,7 +393,7 @@ bool Application::init_chain() { chain_params_->GetConsensus().nSuspiciousReorgDepth); chain_params_->SetSuspiciousReorgDepth(config_.suspicious_reorg_depth); } - chainstate_manager_ = std::make_unique(*chain_params_); + chainstate_manager_ = std::make_unique(*chain_params_, *trust_base_manager_); // Try to load headers from disk std::string headers_file = (config_.datadir / "headers.json").string(); @@ -430,7 +454,7 @@ bool Application::init_rpc() { rpc_server_ = std::make_unique( socket_path, *chainstate_manager_, *network_manager_, miner_.get(), - *chain_params_, shutdown_callback); + *token_manager_, *trust_base_manager_, *chain_params_, shutdown_callback); return true; } diff --git a/src/chain/bft_client.cpp b/src/chain/bft_client.cpp new file mode 100644 index 0000000..9675b4a --- /dev/null +++ b/src/chain/bft_client.cpp @@ -0,0 +1,77 @@ +#include "chain/bft_client.hpp" + +#include + +#include + +namespace unicity::chain { + +HttpBFTClient::HttpBFTClient(std::string bftaddr) : bftaddr_(bftaddr), cli_(std::make_unique(bftaddr)) { + cli_->set_connection_timeout(5, 0); + cli_->set_read_timeout(5, 0); + cli_->set_write_timeout(5, 0); +} + +HttpBFTClient::~HttpBFTClient() = default; + +std::optional HttpBFTClient::FetchTrustBase(const uint64_t epoch) { + const std::string target = "/api/v1/trustbases?from=" + std::to_string(epoch) + "&to=" + std::to_string(epoch); + auto tbs = ParseTrustBasesResponse(FetchHttp(target)); + if (tbs.empty()) { + return std::nullopt; + } + return tbs.front(); +} + +std::vector HttpBFTClient::FetchTrustBases(const uint64_t from_epoch) { + const std::string target = "/api/v1/trustbases?from=" + std::to_string(from_epoch); + return ParseTrustBasesResponse(FetchHttp(target)); +} + +std::vector HttpBFTClient::ParseTrustBasesResponse(const std::vector& data) { + if (data.empty()) { + return {}; + } + + const nlohmann::json j = nlohmann::json::from_cbor(data, true, true, nlohmann::json::cbor_tag_handler_t::ignore); + + // The response is serialized as an array of 1 element due to the struct{} field in the BFT API. + // The inner element is the array of RootTrustBaseV1. + if (!j.is_array() || j.size() != 1) { + throw std::runtime_error("ParseTrustBasesResponse: Expected an array of 1 element"); + } + + const auto& trust_bases_json = j.at(0); + if (!trust_bases_json.is_array()) { + throw std::runtime_error("ParseTrustBasesResponse: Inner element is not an array"); + } + + std::vector result; + result.reserve(trust_bases_json.size()); + + for (const auto& tb_json : trust_bases_json) { + RootTrustBaseV1 tb; + from_json(tb_json, tb); + result.push_back(std::move(tb)); + } + + return result; +} + +std::vector HttpBFTClient::FetchHttp(const std::string& target) { + if (auto res = cli_->Get(target)) { + if (res->body.size() > MAX_BFT_RESPONSE_SIZE) { + throw std::runtime_error("BFT response too large: " + std::to_string(res->body.size())); + } + if (res->status == 200) { + return std::vector(res->body.begin(), res->body.end()); + } + const std::string truncated = res->body.substr(0, 4096); + throw std::runtime_error("HTTP request failed with status code " + std::to_string(res->status) + + ". Response body (truncated): " + truncated); + } else { + throw std::runtime_error("HTTP request failed: " + httplib::to_string(res.error())); + } +} + +} // namespace unicity::chain diff --git a/src/chain/block.cpp b/src/chain/block.cpp index d9f161d..4e7fd50 100644 --- a/src/chain/block.cpp +++ b/src/chain/block.cpp @@ -6,61 +6,96 @@ #include "chain/block.hpp" #include "util/endian.hpp" +#include "util/hash.hpp" #include "util/sha256.hpp" #include #include +#include #include #include namespace { // Compile-time header size calculation -constexpr size_t kHeaderSize = 4 /*nVersion*/ + 32 /*hashPrevBlock*/ + 20 /*minerAddress*/ + 4 /*nTime*/ + +constexpr size_t kHeaderSize = 4 /*nVersion*/ + 32 /*hashPrevBlock*/ + 32 /*payloadRoot*/ + 4 /*nTime*/ + 4 /*nBits*/ + 4 /*nNonce*/ + 32 /*hashRandomX*/; static_assert(kHeaderSize == CBlockHeader::HEADER_SIZE, "HEADER_SIZE mismatch"); } // namespace uint256 CBlockHeader::GetHash() const noexcept { - const auto s = Serialize(); + return Hash(SerializeHeader()); +} + +std::span CBlockHeader::GetUTB() const noexcept { + // Payload layout: [32 bytes token id hash] [optional UTB CBOR bytes] + if (vPayload.size() <= 32) { + return {}; + } + return std::span(vPayload.data() + 32, vPayload.size() - 32); +} - uint8_t tmp[CSHA256::OUTPUT_SIZE]; - CSHA256().Write(s.data(), s.size()).Finalize(tmp); +// payloadRoot = SHA256(leaf_0 || leaf_1) +// leaf_0 = SHA256(rewardTokenId) +// leaf_1 = SHA256(UTB) +uint256 CBlockHeader::ComputePayloadRoot(const uint256& leaf_0, const uint256& leaf_1) noexcept { + uint256 root; + CSHA256() + .Write(leaf_0.begin(), 32) + .Write(leaf_1.begin(), 32) + .Finalize(root.begin()); + return root; +} + +CBlockHeader::HeaderBytes CBlockHeader::SerializeHeader() const noexcept { + HeaderBytes bytes; + SerializeInto(bytes.data(), bytes.size(), false); + return bytes; +} - uint256 out; - CSHA256().Write(tmp, sizeof(tmp)).Finalize(out.begin()); - return out; +std::vector CBlockHeader::Serialize(const bool includePayload) const noexcept { + std::vector data(HEADER_SIZE + (includePayload ? vPayload.size() : 0), 0); + SerializeInto(data.data(), data.size(), includePayload); + return data; } -CBlockHeader::HeaderBytes CBlockHeader::Serialize() const noexcept { - HeaderBytes data{}; +bool CBlockHeader::SerializeInto(uint8_t* buf, size_t len, const bool includePayload) const noexcept { + const size_t required_size = HEADER_SIZE + (includePayload ? vPayload.size() : 0); + if (len < required_size) { + return false; + } // nVersion (4 bytes, offset 0) - endian::WriteLE32(data.data() + OFF_VERSION, static_cast(nVersion)); + endian::WriteLE32(buf + OFF_VERSION, static_cast(nVersion)); // hashPrevBlock (32 bytes, offset 4) - std::copy(hashPrevBlock.begin(), hashPrevBlock.end(), data.begin() + OFF_PREV); + std::copy(hashPrevBlock.begin(), hashPrevBlock.end(), buf + OFF_PREV); - // minerAddress (20 bytes, offset 36) - std::copy(minerAddress.begin(), minerAddress.end(), data.begin() + OFF_MINER); + // payloadRoot (32 bytes, offset 36) + std::copy(payloadRoot.begin(), payloadRoot.end(), buf + OFF_PAYLOAD_ROOT); - // nTime (4 bytes, offset 56) - endian::WriteLE32(data.data() + OFF_TIME, nTime); + // nTime (4 bytes, offset 68) + endian::WriteLE32(buf + OFF_TIME, nTime); - // nBits (4 bytes, offset 60) - endian::WriteLE32(data.data() + OFF_BITS, nBits); + // nBits (4 bytes, offset 72) + endian::WriteLE32(buf + OFF_BITS, nBits); - // nNonce (4 bytes, offset 64) - endian::WriteLE32(data.data() + OFF_NONCE, nNonce); + // nNonce (4 bytes, offset 76) + endian::WriteLE32(buf + OFF_NONCE, nNonce); - // hashRandomX (32 bytes, offset 68) - std::copy(hashRandomX.begin(), hashRandomX.end(), data.begin() + OFF_RANDOMX); + // hashRandomX (32 bytes, offset 80) + std::copy(hashRandomX.begin(), hashRandomX.end(), buf + OFF_RANDOMX); - return data; + // Append payload if requested + if (includePayload && !vPayload.empty()) { + std::copy(vPayload.begin(), vPayload.end(), buf + HEADER_SIZE); + } + + return true; } bool CBlockHeader::Deserialize(const uint8_t* data, size_t size) noexcept { - // Consensus-critical: Reject if size doesn't exactly match HEADER_SIZE - if (size != HEADER_SIZE) { + // Consensus-critical: Reject if size doesn't at least match HEADER_SIZE + if (size < HEADER_SIZE) { return false; } @@ -70,21 +105,28 @@ bool CBlockHeader::Deserialize(const uint8_t* data, size_t size) noexcept { // hashPrevBlock (32 bytes, offset 4) std::copy(data + OFF_PREV, data + OFF_PREV + UINT256_BYTES, hashPrevBlock.begin()); - // minerAddress (20 bytes, offset 36) - std::copy(data + OFF_MINER, data + OFF_MINER + UINT160_BYTES, minerAddress.begin()); + // payloadRoot (32 bytes, offset 36) + std::copy(data + OFF_PAYLOAD_ROOT, data + OFF_PAYLOAD_ROOT + UINT256_BYTES, payloadRoot.begin()); - // nTime (4 bytes, offset 56) + // nTime (4 bytes, offset 68) nTime = endian::ReadLE32(data + OFF_TIME); - // nBits (4 bytes, offset 60) + // nBits (4 bytes, offset 72) nBits = endian::ReadLE32(data + OFF_BITS); - // nNonce (4 bytes, offset 64) + // nNonce (4 bytes, offset 76) nNonce = endian::ReadLE32(data + OFF_NONCE); - // hashRandomX (32 bytes, offset 68) + // hashRandomX (32 bytes, offset 80) std::copy(data + OFF_RANDOMX, data + OFF_RANDOMX + UINT256_BYTES, hashRandomX.begin()); + // Read appended payload + if (size > HEADER_SIZE) { + vPayload.assign(data + HEADER_SIZE, data + size); + } else { + vPayload.clear(); + } + return true; } @@ -93,12 +135,13 @@ std::string CBlockHeader::ToString() const { s << "CBlockHeader(\n"; s << " version=" << nVersion << "\n"; s << " hashPrevBlock=" << hashPrevBlock.GetHex() << "\n"; - s << " minerAddress=" << minerAddress.GetHex() << "\n"; + s << " payloadRoot=" << payloadRoot.GetHex() << "\n"; s << " nTime=" << nTime << "\n"; s << " nBits=0x" << std::hex << nBits << std::dec << "\n"; s << " nNonce=" << nNonce << "\n"; s << " hashRandomX=" << hashRandomX.GetHex() << "\n"; s << " hash=" << GetHash().GetHex() << "\n"; + s << " payloadSize=" << vPayload.size() << "\n"; s << ")\n"; return s.str(); } diff --git a/src/chain/block_index.cpp b/src/chain/block_index.cpp index 417ede8..73d06cb 100644 --- a/src/chain/block_index.cpp +++ b/src/chain/block_index.cpp @@ -112,7 +112,8 @@ std::string CBlockIndex::ToString() const { << "hash=" << m_block_hash.ToString().substr(0, 16) << ", height=" << nHeight << ", chainwork=0x" << nChainWork.GetHex() << ", status=" << status.ToString() << ", version=" << nVersion << ", time=" << nTime << ", bits=0x" << std::hex << nBits << std::dec << ", nonce=" << nNonce - << ", miner=" << minerAddress.ToString() << ", randomx=" << hashRandomX.ToString().substr(0, 16) + << ", payload_root=" << payloadRoot.ToString() + << ", randomx=" << hashRandomX.ToString().substr(0, 16) << ", pprev=" << pprev << ")"; return ss.str(); } diff --git a/src/chain/block_manager.cpp b/src/chain/block_manager.cpp index 0f74a73..ee1ce18 100644 --- a/src/chain/block_manager.cpp +++ b/src/chain/block_manager.cpp @@ -5,9 +5,11 @@ #include "chain/block_manager.hpp" #include "chain/block.hpp" +#include "chain/trust_base.hpp" #include "util/arith_uint256.hpp" #include "util/files.hpp" #include "util/logging.hpp" +#include "util/string_parsing.hpp" #include #include @@ -143,6 +145,27 @@ CBlockIndex* BlockManager::AddToBlockIndex(const CBlockHeader& header) { pindex->m_block_hash = hash; pindex->pprev = pprev; + // Extract BFT epoch from payload if present + if (pindex->vPayload.size() > 32) { + try { + const std::span utb_cbor(pindex->vPayload.data() + 32, pindex->vPayload.size() - 32); + const auto utb = RootTrustBaseV1::FromCBOR(utb_cbor); + pindex->bftEpoch = utb.epoch; + } catch (const std::exception& e) { + LOG_CHAIN_ERROR("Failed to parse UTB from block {} payload: {}", hash.ToString().substr(0, 16), e.what()); + // in case of any errors, carry forward the previous bftEpoch + if (pprev) { + pindex->bftEpoch = pprev->bftEpoch; + } + } + } else if (pprev) { + // If no new UTB, carry forward the previous one + pindex->bftEpoch = pprev->bftEpoch; + } else { + // Genesis block must contain the genesis UTB (epoch 1) + LOG_CHAIN_ERROR("Genesis block {} missing UTB in payload", hash.ToString().substr(0, 16)); + } + // Set height and chainwork (immutable after insertion - used for std::set ordering) if (pprev) { pindex->nHeight = pprev->nHeight + 1; @@ -196,15 +219,17 @@ bool BlockManager::Save(const std::string& filepath) const { // Header fields block_data["version"] = block_index->nVersion; - block_data["miner_address"] = block_index->minerAddress.ToString(); + block_data["payload_root"] = block_index->payloadRoot.ToString(); block_data["time"] = block_index->nTime; block_data["bits"] = block_index->nBits; block_data["nonce"] = block_index->nNonce; block_data["hash_randomx"] = block_index->hashRandomX.ToString(); + block_data["payload"] = util::ToHex(block_index->vPayload); // Chain metadata block_data["height"] = block_index->nHeight; block_data["chainwork"] = block_index->nChainWork.GetHex(); + block_data["bft_epoch"] = block_index->bftEpoch; // Canonical status representation { @@ -324,8 +349,8 @@ LoadResult BlockManager::Load(const std::string& filepath, const uint256& expect // Required fields for each block entry static const std::vector required_fields = { - "hash", "prev_hash", "version", "miner_address", "time", - "bits", "nonce", "hash_randomx", "height", "chainwork", "status"}; + "hash", "prev_hash", "version", "payload_root", "time", + "bits", "nonce", "hash_randomx", "height", "chainwork", "status", "bft_epoch"}; for (const auto& block_data : blocks) { // Validate required fields are present @@ -352,13 +377,18 @@ LoadResult BlockManager::Load(const std::string& filepath, const uint256& expect // Create header CBlockHeader header; header.nVersion = block_data["version"].get(); - header.minerAddress.SetHex(block_data["miner_address"].get()); + header.payloadRoot.SetHex(block_data["payload_root"].get()); header.nTime = block_data["time"].get(); header.nBits = block_data["bits"].get(); header.nNonce = block_data["nonce"].get(); header.hashRandomX.SetHex(block_data["hash_randomx"].get()); header.hashPrevBlock = prev_hash; + // Load payload if present + if (block_data.contains("payload") && block_data["payload"].is_string()) { + header.vPayload = util::ParseHex(block_data["payload"].get()); + } + // Verify reconstructed header hash matches stored hash // This detects corruption, tampering, or missing fields in the JSON uint256 recomputed_hash = header.GetHash(); @@ -390,6 +420,7 @@ LoadResult BlockManager::Load(const std::string& filepath, const uint256& expect // Restore metadata pindex->nHeight = block_data["height"].get(); pindex->nChainWork.SetHex(block_data["chainwork"].get()); + pindex->bftEpoch = block_data["bft_epoch"].get(); // Store for second pass block_map[hash] = {pindex, prev_hash}; diff --git a/src/chain/chainparams.cpp b/src/chain/chainparams.cpp index baf96ba..cb6d6bb 100644 --- a/src/chain/chainparams.cpp +++ b/src/chain/chainparams.cpp @@ -5,9 +5,14 @@ #include "network/protocol.hpp" #include "util/arith_uint256.hpp" +#include "util/hash.hpp" +#include "util/sha256.hpp" +#include "util/string_parsing.hpp" #include +#include #include +#include namespace unicity { namespace chain { @@ -17,29 +22,42 @@ namespace chain { // ============================================================================ // Mainnet -static constexpr uint256 MAINNET_GENESIS_HASH{"b675bea090e27659c91885afe341facf399cf84997918bac927948ee75409ebf"}; +static constexpr uint256 MAINNET_GENESIS_HASH{"cac1359df88f7aa4ba8639b44a4c30c06a3d87fcae07d7ae55abfd24220b2157"}; static constexpr uint256 MAINNET_POW_LIMIT{"000fffff00000000000000000000000000000000000000000000000000000000"}; +static constexpr std::string_view MAINNET_GENESIS_UTB_CBOR_HEX = "d998588a010301018383783531365569753248416b776456513347774234586f4b554c624b733174504b7671386e6664385941594450415a395341766d61427663582102d23be5a4932867088fd6fbf12cbc2744ff1393ec1cc7eb47addcec6d908a9e710183783531365569753248416d476b734c324674316d3156506a3774326463484c59343661686a6d726245483655316873486d346e72473268582103411c106956451afa8a596f9d3ef443c073f6d6d40c5d4539fa3e140465301f3e0183783531365569753248416d514d7052575343736b576773486e41507148436355487165396848533353787461337a6941737a37795531685821027f0fe7ba12dc544fe9d4f8bdc8f71de106b97d234e769c87a938cc7c5f9587570103f6f6f6a3783531365569753248416b776456513347774234586f4b554c624b733174504b7671386e6664385941594450415a395341766d614276635841dde5ce121337851277214f4cfdb12e1470115987bab6d66cc754df325a8b1ee21fbc022514dcafd8aa73e1b95a599a684a2d2c2f6b373c66becc7975af84588d00783531365569753248416d476b734c324674316d3156506a3774326463484c59343661686a6d726245483655316873486d346e724732685841027cc807c54a74c6530be68d4797b9d1e1154a34559a0f5c608d402db074ef6b71b26c04e16594ca7f63a82bf26b4253e94b987c2b92ac4ff72667f5b56d0ac601783531365569753248416d514d7052575343736b576773486e41507148436355487165396848533353787461337a6941737a377955316858413ddb9ce2a7f1ee255966b91f06409a791cd101ce865c2c5ff1054ff8ca0dc7db145de2c71a0b5ed7776532a6581e3fe6b685ea5d48568f898a9193bb0444c3ab01"; // Testnet -static constexpr uint256 TESTNET_GENESIS_HASH{"cb608755c4b2bee0b929fe5760dec6cc578b48976ee164bb06eb9597c17575f8"}; +static constexpr uint256 TESTNET_GENESIS_HASH{"e9351ab26030ff058ef75278e4ee3c2065a87c750e2df4fb8437a65c3bff7f35"}; static constexpr uint256 TESTNET_POW_LIMIT{"007fffff00000000000000000000000000000000000000000000000000000000"}; +static constexpr std::string_view TESTNET_GENESIS_UTB_CBOR_HEX = MAINNET_GENESIS_UTB_CBOR_HEX; // Regtest -static constexpr uint256 REGTEST_GENESIS_HASH{"0555faa88836f4ce189235a28279af4614432234b6f7e2f350e4fc0dadb1ffa7"}; +static constexpr uint256 REGTEST_GENESIS_HASH{"e970e2f0b5898feca76ff24ec35a17c3ab37e4ef5c4f06ee93752dcad26079a3"}; static constexpr uint256 REGTEST_POW_LIMIT{"7fffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff"}; +static constexpr std::string_view REGTEST_GENESIS_UTB_CBOR_HEX = MAINNET_GENESIS_UTB_CBOR_HEX; // Static instance std::unique_ptr GlobalChainParams::instance = nullptr; -CBlockHeader CreateGenesisBlock(uint32_t nTime, uint32_t nNonce, uint32_t nBits, int32_t nVersion) { +CBlockHeader CreateGenesisBlock(uint32_t nTime, uint32_t nNonce, uint32_t nBits, std::span utb_cbor, + int32_t nVersion) { CBlockHeader genesis; genesis.nVersion = nVersion; genesis.hashPrevBlock.SetNull(); - genesis.minerAddress.SetNull(); + + // Genesis Payload: [32 bytes rewardTokenIdHash (all zeros)] + [UTB CBOR bytes] + genesis.vPayload.assign(32, 0); + genesis.vPayload.insert(genesis.vPayload.end(), utb_cbor.begin(), utb_cbor.end()); + + // Compute leaf_1 by hashing the UTB + const uint256 leaf_1 = SingleHash(utb_cbor); + genesis.payloadRoot = CBlockHeader::ComputePayloadRoot(uint256::ZERO, leaf_1); + genesis.nTime = nTime; genesis.nBits = nBits; genesis.nNonce = nNonce; genesis.hashRandomX.SetNull(); + return genesis; } @@ -93,41 +111,23 @@ CMainParams::CMainParams() { consensus.nASERTHalfLife = 2 * 24 * 60 * 60; // 2 days (~20 blocks at 2.4h) // ASERT anchor: Use block 1 as the anchor - // This means block 0 (genesis) and block 1 both use powLimit - // Block 2 onwards uses ASERT relative to block 1's actual timestamp - // This eliminates timing issues - block 1 can be mined at any time consensus.nASERTAnchorHeight = 1; // Minimum chain work - // Update this value periodically as the chain grows - // Set to 0 for now since this is a fresh chain with no accumulated work - // Once mainnet has significant work, update this to ~90% of current chain work consensus.nMinimumChainWork = uint256::ZERO; // Network configuration nDefaultPort = protocol::ports::MAINNET; - // Genesis block: - // Mined on: 2025-10-24 19:20:12 UTC - // Difficulty: 0x1f06a000 (target: ~2.5 minutes at 50 H/s) - // Block hash: b675bea090e27659c91885afe341facf399cf84997918bac927948ee75409ebf - genesis = CreateGenesisBlock(1761330012, // nTime - Oct 24, 2025 - 8497, // nNonce - found by genesis miner - 0x1f06a000, // nBits - initial difficulty - 1 // nVersion - ); - + // Genesis block + genesis = CreateGenesisBlock(1761330012, 21006, 0x1f06a000, util::ParseHex(MAINNET_GENESIS_UTB_CBOR_HEX), 1); consensus.hashGenesisBlock = genesis.GetHash(); assert(consensus.hashGenesisBlock == MAINNET_GENESIS_HASH); - // TODO add timebomb to initial mainnet consensus.nNetworkExpirationInterval = 0; consensus.nNetworkExpirationGracePeriod = 0; + consensus.nSuspiciousReorgDepth = 2; - // Reorg protection - consensus.nSuspiciousReorgDepth = 2; // 2 blocks (~4.8 hours) - - // Hardcoded seed node addresses vFixedSeeds.push_back("178.18.251.16:9590"); vFixedSeeds.push_back("185.225.233.49:9590"); vFixedSeeds.push_back("207.244.248.15:9590"); @@ -144,42 +144,24 @@ CMainParams::CMainParams() { CTestNetParams::CTestNetParams() { chainType = ChainType::TESTNET; - // Easy difficulty for testnet consensus.powLimit = TESTNET_POW_LIMIT; - consensus.nPowTargetSpacing = 144 * 60; // 2.4 hours (same as mainnet) - consensus.nRandomXEpochDuration = 7 * 24 * 60 * 60; // 1 week (70 blocks at 2.4h) - consensus.nASERTHalfLife = 2 * 24 * 60 * 60; // 2 days (~20 blocks at 2.4h) - - // ASERT anchor: Use block 1 (same as mainnet) + consensus.nPowTargetSpacing = 144 * 60; + consensus.nRandomXEpochDuration = 7 * 24 * 60 * 60; + consensus.nASERTHalfLife = 2 * 24 * 60 * 60; consensus.nASERTAnchorHeight = 1; - - // Minimum chain work (eclipse attack protection) - // Set to 0 for fresh testnet - update as the network grows consensus.nMinimumChainWork = uint256::ZERO; - // Network expiration enabled for testnet - set to 1000 blocks for testing - consensus.nNetworkExpirationInterval = 1000; - consensus.nNetworkExpirationGracePeriod = 24; // 24 blocks (~2.4 days warning period) - - // Reorg protection - consensus.nSuspiciousReorgDepth = 100; // 100 blocks (~10 days) - testing flexibility - - // Network configuration nDefaultPort = protocol::ports::TESTNET; - // Testnet genesis - mined at Oct 15, 2025 - // Block hash: - // cb608755c4b2bee0b929fe5760dec6cc578b48976ee164bb06eb9597c17575f8 - genesis = CreateGenesisBlock(1760549555, // Oct 15, 2025 - 253, // Nonce found by genesis miner - 0x1f7fffff, // Easy difficulty for fast testing - 1); - + // Genesis block + genesis = CreateGenesisBlock(1760549555, 160, 0x1f7fffff, util::ParseHex(TESTNET_GENESIS_UTB_CBOR_HEX), 1); consensus.hashGenesisBlock = genesis.GetHash(); assert(consensus.hashGenesisBlock == TESTNET_GENESIS_HASH); - // Hardcoded seed node addresses (ct20-ct26) - // These are reliable seed nodes for initial peer discovery + consensus.nNetworkExpirationInterval = 1000; + consensus.nNetworkExpirationGracePeriod = 24; + consensus.nSuspiciousReorgDepth = 100; + vFixedSeeds.push_back("178.18.251.16:19590"); vFixedSeeds.push_back("185.225.233.49:19590"); vFixedSeeds.push_back("207.244.248.15:19590"); @@ -196,35 +178,23 @@ CTestNetParams::CTestNetParams() { CRegTestParams::CRegTestParams() { chainType = ChainType::REGTEST; - // Very easy difficulty - instant block generation consensus.powLimit = REGTEST_POW_LIMIT; consensus.nPowTargetSpacing = 2 * 60; - consensus.nRandomXEpochDuration = 365ULL * 24 * 60 * 60 * - 100; // 100 years (so all regtest blocks stay in same epoch) - - // Minimum chain work - disabled for regtest (generate chains from scratch) + consensus.nRandomXEpochDuration = 365ULL * 24 * 60 * 60 * 100; consensus.nMinimumChainWork = uint256::ZERO; - // Network expiration disabled for regtest (testing environment) - consensus.nNetworkExpirationInterval = 0; // Disabled - consensus.nNetworkExpirationGracePeriod = 0; // Disabled - - // Reorg protection - consensus.nSuspiciousReorgDepth = 100; // 100 blocks - - // Network configuration nDefaultPort = protocol::ports::REGTEST; - // Regtest genesis - instant mine - genesis = CreateGenesisBlock(1760549555, // Oct 15, 2025 (same as testnet) - 2, // Easy nonce - 0x207fffff, // Very easy difficulty - 1); - + // Genesis block + genesis = CreateGenesisBlock(1774378227, 20, 0x207fffff, util::ParseHex(REGTEST_GENESIS_UTB_CBOR_HEX), 1); consensus.hashGenesisBlock = genesis.GetHash(); assert(consensus.hashGenesisBlock == REGTEST_GENESIS_HASH); - vFixedSeeds.clear(); // No hardcoded seeds for local testing + consensus.nNetworkExpirationInterval = 0; + consensus.nNetworkExpirationGracePeriod = 0; + consensus.nSuspiciousReorgDepth = 100; + + vFixedSeeds.clear(); } // ============================================================================ @@ -257,4 +227,4 @@ bool GlobalChainParams::IsInitialized() { } } // namespace chain -} // namespace unicity \ No newline at end of file +} // namespace unicity diff --git a/src/chain/chainstate_manager.cpp b/src/chain/chainstate_manager.cpp index 3525b4c..3d0fd37 100644 --- a/src/chain/chainstate_manager.cpp +++ b/src/chain/chainstate_manager.cpp @@ -11,6 +11,8 @@ #include "chain/notifications.hpp" #include "chain/pow.hpp" #include "chain/randomx_pow.hpp" +#include "chain/trust_base.hpp" +#include "chain/trust_base_manager.hpp" #include "chain/validation.hpp" #include "network/protocol.hpp" #include "util/arith_uint256.hpp" @@ -34,9 +36,10 @@ namespace validation { // IBD (Initial Block Download) staleness threshold static constexpr int64_t IBD_STALE_TIP_SECONDS = 5 * 24 * 3600; // 5 days (432000 seconds) -ChainstateManager::ChainstateManager(const chain::ChainParams& params) +ChainstateManager::ChainstateManager(const chain::ChainParams& params, chain::TrustBaseManager& tbm) : block_manager_() , params_(params) + , tbm_(tbm) { } @@ -120,6 +123,19 @@ chain::CBlockIndex* ChainstateManager::AcceptBlockHeader(const CBlockHeader& hea state.Error("failed to add block to index"); return nullptr; } + + // Step 9: If block contains a UTB, record it in TrustBaseManager + if (header.vPayload.size() > 32) { + try { + const std::span utb_cbor(header.vPayload.data() + 32, header.vPayload.size() - 32); + const auto utb = chain::RootTrustBaseV1::FromCBOR(utb_cbor); + tbm_.ProcessTrustBase(utb); + } catch (const std::exception& e) { + // shouldn't happen as block is already validated in CheckBlockHeader + LOG_CHAIN_ERROR("Failed to store UTB from block {} into manager: {}", hash.ToString().substr(0, 16), e.what()); + } + } + pindex->nTimeReceived = util::GetTime(); // Mark validity - must succeed for newly added block @@ -720,7 +736,7 @@ bool ChainstateManager::CheckBlockHeaderWrapper(const CBlockHeader& header, Vali if (test_skip_pow_checks_.load(std::memory_order_acquire)) { return true; } - return CheckBlockHeader(header, params_, state); + return CheckBlockHeader(header, params_, state, tbm_); } bool ChainstateManager::ContextualCheckBlockHeaderWrapper(const CBlockHeader& header, @@ -805,7 +821,7 @@ bool ChainstateManager::InvalidateBlock(const uint256& hash) { } void ChainstateManager::TestSetSkipPoWChecks(bool enabled) { - if (params_.GetChainType() != chain::ChainType::REGTEST) { + if (enabled && params_.GetChainType() != chain::ChainType::REGTEST) { throw std::runtime_error("PoW skip is only allowed in regtest mode"); } test_skip_pow_checks_.store(enabled, std::memory_order_release); diff --git a/src/chain/miner.cpp b/src/chain/miner.cpp index 15a2f32..7cb06c9 100644 --- a/src/chain/miner.cpp +++ b/src/chain/miner.cpp @@ -7,23 +7,33 @@ #include "chain/miner.hpp" #include "chain/chainstate_manager.hpp" +#include "chain/token_manager.hpp" #include "chain/pow.hpp" #include "chain/randomx_pow.hpp" #include "chain/validation.hpp" #include "util/arith_uint256.hpp" +#include "util/hash.hpp" #include "util/logging.hpp" #include "util/time.hpp" #include "randomx.h" #include +#include #include namespace unicity { namespace mining { -CPUMiner::CPUMiner(const chain::ChainParams& params, validation::ChainstateManager& chainstate) - : params_(params), chainstate_(chainstate) {} +CPUMiner::CPUMiner(const chain::ChainParams& params, + validation::ChainstateManager& chainstate, + chain::TrustBaseManager& trust_base_manager, + TokenManager& token_manager) + : params_(params) + , chainstate_(chainstate) + , trust_base_manager_(trust_base_manager) + , token_manager_(token_manager) +{} CPUMiner::~CPUMiner() { try { @@ -114,92 +124,102 @@ double CPUMiner::GetHashrate() const { } void CPUMiner::MiningWorker() { - uint32_t nonce = 0; - - // Create block template locally for this mining iteration - BlockTemplate local_template = CreateBlockTemplate(); - uint256 template_prev_hash = local_template.hashPrevBlock; - - LOG_CHAIN_TRACE("Miner: Mining block at height {} (prev={}..., target=0x{:x})", local_template.nHeight, - local_template.hashPrevBlock.ToString().substr(0, 16), local_template.nBits); - - while (mining_.load()) { - // Check if we need to regenerate template (chain tip changed) - if (ShouldRegenerateTemplate(template_prev_hash)) { - LOG_CHAIN_TRACE("Miner: Chain tip changed, regenerating template"); - local_template = CreateBlockTemplate(); - template_prev_hash = local_template.hashPrevBlock; - nonce = 0; // Restart nonce - } - - // Update nonce - local_template.header.nNonce = nonce; - - // Count this hash attempt (including successful ones) - total_hashes_.fetch_add(1); - - // Try mining this nonce using RandomX - uint256 rx_hash; - bool found_block = consensus::CheckProofOfWork(local_template.header, local_template.nBits, params_, - crypto::POWVerifyMode::MINING, &rx_hash); - - // Check if we found a block - if (found_block) { - blocks_found_.fetch_add(1); - - CBlockHeader found_header = local_template.header; - found_header.hashRandomX = rx_hash; - - LOG_CHAIN_TRACE("Miner: *** BLOCK FOUND *** Height: {}, Nonce: {}, Hash: {}...", local_template.nHeight, nonce, - found_header.GetHash().ToString().substr(0, 16)); - - // Process block through chainstate manager - validation::ValidationState state; - if (!chainstate_.ProcessNewBlockHeader(found_header, state)) { - LOG_CHAIN_ERROR("Miner: Failed to process mined block: {} - {}", state.GetRejectReason(), - state.GetDebugMessage()); - continue; - } - - // Check if we've reached target height - // Use ACTUAL chain height, not template height (template might be ahead if validation failed) - int target = target_height_.load(); - const chain::CBlockIndex* tip = chainstate_.GetTip(); - int actual_height = tip ? tip->nHeight : -1; - if (target != -1 && actual_height >= target) { - LOG_CHAIN_TRACE("Miner: Reached target height {} (actual chain: {}), stopping", target, actual_height); - mining_.store(false); - break; + try { + uint32_t nonce = 0; + // Create block template locally for this mining iteration + BlockTemplate local_template = CreateBlockTemplate(); + uint256 template_prev_hash = local_template.hashPrevBlock; + + LOG_CHAIN_TRACE("Miner: Mining block at height {} (prev={}..., target=0x{:x})", local_template.nHeight, + local_template.hashPrevBlock.ToString().substr(0, 16), local_template.nBits); + + while (mining_.load()) { + // Check if we need to regenerate template (chain tip changed) + if (ShouldRegenerateTemplate(template_prev_hash)) { + LOG_CHAIN_TRACE("Miner: Chain tip changed, regenerating template"); + local_template = CreateBlockTemplate(); + template_prev_hash = local_template.hashPrevBlock; + nonce = 0; // Restart nonce } - // Continue mining next block - local_template = CreateBlockTemplate(); - template_prev_hash = local_template.hashPrevBlock; - nonce = 0; - continue; - } - - // Next nonce - nonce++; - if (nonce == 0) { - // Nonce space exhausted - increment timestamp for fresh search space - uint32_t current_time = static_cast(util::GetTime()); - uint32_t max_future_time = current_time + validation::MAX_FUTURE_BLOCK_TIME; - - if (local_template.header.nTime < max_future_time) { - local_template.header.nTime++; - LOG_CHAIN_TRACE("Miner: Nonce exhausted, incremented nTime to {}", local_template.header.nTime); - } else { - // Timestamp would exceed maximum future time - regenerate template - LOG_CHAIN_TRACE("Miner: Timestamp at maximum, regenerating template"); + // Update nonce + local_template.header.nNonce = nonce; + + // Count this hash attempt (including successful ones) + total_hashes_.fetch_add(1); + + // Try mining this nonce using RandomX + uint256 rx_hash; + bool found_block = consensus::CheckProofOfWork(local_template.header, local_template.nBits, params_, + crypto::POWVerifyMode::MINING, &rx_hash); + + // Check if we found a block + if (found_block) { + blocks_found_.fetch_add(1); + + CBlockHeader found_header = local_template.header; + found_header.hashRandomX = rx_hash; + + LOG_CHAIN_TRACE("Miner: *** BLOCK FOUND *** Height: {}, Nonce: {}, Hash: {}...", local_template.nHeight, nonce, + found_header.GetHash().ToString().substr(0, 16)); + + // Process block through chainstate manager + validation::ValidationState state; + if (!chainstate_.ProcessNewBlockHeader(found_header, state)) { + LOG_CHAIN_ERROR("Miner: Failed to process mined block: {} - {}", state.GetRejectReason(), + state.GetDebugMessage()); + continue; + } + + // Record reward to CSV + // Only record if the block was successfully accepted as the active tip (ignores stale blocks) + const chain::CBlockIndex* new_tip = chainstate_.GetTip(); + if (new_tip && new_tip->GetBlockHash() == found_header.GetHash()) { + token_manager_.RecordReward(found_header.GetHash(), local_template.rewardTokenId); + } + + // Check if we've reached target height + // Use ACTUAL chain height, not template height (template might be ahead if validation failed) + int target = target_height_.load(); + const chain::CBlockIndex* tip = chainstate_.GetTip(); + int actual_height = tip ? tip->nHeight : -1; + if (target != -1 && actual_height >= target) { + LOG_CHAIN_TRACE("Miner: Reached target height {} (actual chain: {}), stopping", target, actual_height); + mining_.store(false); + break; + } + + // Continue mining next block local_template = CreateBlockTemplate(); template_prev_hash = local_template.hashPrevBlock; nonce = 0; + continue; + } + + // Next nonce + nonce++; + if (nonce == 0) { + // Nonce space exhausted - increment timestamp for fresh search space + uint32_t current_time = static_cast(util::GetTime()); + uint32_t max_future_time = current_time + validation::MAX_FUTURE_BLOCK_TIME; + + if (local_template.header.nTime < max_future_time) { + local_template.header.nTime++; + LOG_CHAIN_TRACE("Miner: Nonce exhausted, incremented nTime to {}", local_template.header.nTime); + } else { + // Timestamp would exceed maximum future time - regenerate template + LOG_CHAIN_TRACE("Miner: Timestamp at maximum, regenerating template"); + local_template = CreateBlockTemplate(); + template_prev_hash = local_template.hashPrevBlock; + nonce = 0; + } } } + LOG_CHAIN_TRACE("Miner: Worker thread exiting normally"); + } catch (const std::exception& e) { + LOG_CHAIN_ERROR("Miner: Fatal error in worker thread: {}", e.what()); + mining_.store(false); } - - LOG_CHAIN_TRACE("Miner: Worker thread exiting normally"); } BlockTemplate CPUMiner::CreateBlockTemplate() { @@ -219,15 +239,47 @@ BlockTemplate CPUMiner::CreateBlockTemplate() { // Calculate difficulty tmpl.nBits = consensus::GetNextWorkRequired(tip, params_); + // Calculate reward token + const uint256 rewardTokenId = token_manager_.GenerateNextTokenId(); + const uint256 leaf_0 = SingleHash(std::span(rewardTokenId.begin(), rewardTokenId.size())); + uint256 leaf_1 = uint256::ZERO; + std::vector utb_cbor; + + // try to load the latest trust base from BFT node, + // if unsuccessful then log warning and proceed without + // including new UTB to block + uint64_t target_epoch = tip ? tip->bftEpoch + 1 : 1; + try { + trust_base_manager_.SyncToEpoch(target_epoch); + if (auto utb = trust_base_manager_.GetTrustBase(target_epoch)) { + utb_cbor = utb->ToCBOR(); + leaf_1 = SingleHash(utb_cbor); + } + } catch (const std::exception& e) { + LOG_CHAIN_WARN("Miner: Failed to sync trust bases to epoch {}: {}", target_epoch, e.what()); + } + + const uint256 root = CBlockHeader::ComputePayloadRoot(leaf_0, leaf_1); + tmpl.rewardTokenId = rewardTokenId; // store for recording later + // Fill header tmpl.header.nVersion = 1; tmpl.header.hashPrevBlock = tmpl.hashPrevBlock; - tmpl.header.minerAddress = GetMiningAddress(); // Thread-safe access via mutex + tmpl.header.payloadRoot = root; tmpl.header.nTime = static_cast(util::GetTime()); tmpl.header.nBits = tmpl.nBits; tmpl.header.nNonce = 0; tmpl.header.hashRandomX.SetNull(); + // Append physical payload + // Store the hash of the reward token ID in the payload (leaf_0) + tmpl.header.vPayload.assign(leaf_0.begin(), leaf_0.end()); + + // Store new UTB in the payload (if we found a new one) + if (!utb_cbor.empty()) { + tmpl.header.vPayload.insert(tmpl.header.vPayload.end(), utb_cbor.begin(), utb_cbor.end()); + } + // Ensure timestamp is strictly greater than previous block // (validation requires strictly increasing timestamps) if (tip) { diff --git a/src/chain/pow.cpp b/src/chain/pow.cpp index 36f7d00..82bd6b5 100644 --- a/src/chain/pow.cpp +++ b/src/chain/pow.cpp @@ -327,8 +327,9 @@ bool CheckProofOfWork(const CBlockHeader& block, uint32_t nBits, const chain::Ch CBlockHeader tmp(block); tmp.hashRandomX.SetNull(); - // Calculate hash (thread-safe via thread-local VM) - randomx_calculate_hash(vmRef->vm, &tmp, sizeof(tmp), rx_hash); + // Calculate hash using only the 112-byte static header (thread-safe via thread-local VM) + CBlockHeader::HeaderBytes bytes = tmp.SerializeHeader(); + randomx_calculate_hash(vmRef->vm, bytes.data(), bytes.size(), rx_hash); // If not mining, compare hash in block header with our computed value if (mode != crypto::POWVerifyMode::MINING) { diff --git a/src/chain/randomx_pow.cpp b/src/chain/randomx_pow.cpp index 5eb0f42..5dc1b82 100644 --- a/src/chain/randomx_pow.cpp +++ b/src/chain/randomx_pow.cpp @@ -174,9 +174,10 @@ uint256 GetRandomXCommitment(const CBlockHeader& block, uint256* inHash) { CBlockHeader rx_blockHeader(block); rx_blockHeader.hashRandomX.SetNull(); - // Calculate commitment + // Calculate commitment using only the 112-byte static header + const std::vector bytes = rx_blockHeader.Serialize(false); char rx_cm[RANDOMX_HASH_SIZE]; - randomx_calculate_commitment(&rx_blockHeader, sizeof(rx_blockHeader), rx_hash.data(), rx_cm); + randomx_calculate_commitment(bytes.data(), bytes.size(), rx_hash.data(), rx_cm); return uint256(std::vector(rx_cm, rx_cm + sizeof(rx_cm))); } diff --git a/src/chain/token_generator.cpp b/src/chain/token_generator.cpp new file mode 100644 index 0000000..302903a --- /dev/null +++ b/src/chain/token_generator.cpp @@ -0,0 +1,166 @@ +// Copyright (c) 2025 The Unicity Foundation +// Distributed under the MIT software license + +#include "chain/token_generator.hpp" + +#include "util/endian.hpp" +#include "util/files.hpp" +#include "util/logging.hpp" +#include "util/sha256.hpp" + +#include +#include +#include +#include +#include +#include +#include + +namespace unicity::mining { + +namespace { + +/** + * Fill a buffer with random bytes using OS-native primitives. + * On modern systems, std::random_device is typically non-deterministic and + * backed by /dev/urandom or equivalent (like BCryptGenRandom on Windows). + */ +void RandomBytes(uint8_t* data, size_t len) { + std::random_device rd; + for (size_t i = 0; i < len; ++i) { + data[i] = static_cast(rd() & 0xFF); + } +} + +} // namespace + +TokenGenerator::TokenGenerator(const std::filesystem::path& datadir) + : datadir_(datadir), + state_file_(datadir / "miner_state.json"), + counter_(0) { + util::ensure_directory(datadir_); + + if (!LoadState()) { + LOG_INFO("TokenGenerator: No valid state found, generating new seed"); + InitializeFreshState(); + } +} + +uint256 TokenGenerator::GenerateNextTokenId() { + std::lock_guard lock(mutex_); + + // Increment counter and store locally + const uint64_t next_counter = ++counter_; + const uint256 current_seed = seed_; + + // Persist state FIRST to ensure we never reuse a counter value after a crash. + // We throw an exception if I/O fails to avoid returning an ID that isn't safe. + if (!WriteStateFile({current_seed, next_counter})) { + throw std::runtime_error("TokenGenerator: Failed to persist incremented counter to disk"); + } + + // TokenID = Hash(seed || counter_le) + std::array counter_le; + endian::WriteLE64(counter_le.data(), next_counter); + + uint256 token_id; + CSHA256() + .Write(current_seed.begin(), current_seed.size()) + .Write(counter_le.data(), counter_le.size()) + .Finalize(token_id.begin()); + + return token_id; +} + +uint64_t TokenGenerator::GetCounter() const { + std::lock_guard lock(mutex_); + return counter_; +} + +bool TokenGenerator::LoadState() { + std::lock_guard lock(mutex_); + + const auto loaded = ReadStateFile(); + if (!loaded) { + return false; + } + + seed_ = loaded->seed; + counter_ = loaded->counter; + + LOG_INFO("TokenGenerator: Loaded state with counter = {}", counter_); + return true; +} + +bool TokenGenerator::SaveState() const { + std::lock_guard lock(mutex_); + return WriteStateFile({seed_, counter_}); +} + +void TokenGenerator::InitializeFreshState() { + // Generate secure seed and reset counter + RandomBytes(seed_.begin(), seed_.size()); + counter_ = 0; + + if (!WriteStateFile({seed_, counter_})) { + throw std::runtime_error("TokenGenerator: Failed to persist fresh state during initialization"); + } +} + +TokenGenerator::State TokenGenerator::GetState() const { + std::lock_guard lock(mutex_); + return {seed_, counter_}; +} + +std::optional TokenGenerator::ReadStateFile() const { + if (!std::filesystem::exists(state_file_)) { + return std::nullopt; + } + + std::vector data = util::read_file(state_file_); + if (data.empty()) { + LOG_ERROR("TokenGenerator: Failed to read state file or file is empty"); + return std::nullopt; + } + + try { + std::string content(data.begin(), data.end()); + auto json = nlohmann::json::parse(content); + + if (!json.contains("seed") || !json["seed"].is_string()) { + LOG_ERROR("TokenGenerator: Missing or invalid seed in state file"); + return std::nullopt; + } + + State s; + s.seed.SetHex(json["seed"].get()); + if (s.seed.IsNull()) { + LOG_ERROR("TokenGenerator: Invalid seed encoding in state file"); + return std::nullopt; + } + + s.counter = json.value("counter", uint64_t{0}); + return s; + + } catch (const std::exception& e) { + LOG_ERROR("TokenGenerator: Failed to parse state file: {}", e.what()); + return std::nullopt; + } +} + +bool TokenGenerator::WriteStateFile(const State& state) const { + nlohmann::json json; + json["seed"] = state.seed.GetHex(); + json["counter"] = state.counter; + + std::string content = json.dump(2) + "\n"; + + if (!util::atomic_write_file(state_file_, content, 0600)) { + LOG_ERROR("TokenGenerator: Failed to write state file"); + return false; + } + + return true; +} + +} // namespace unicity::mining diff --git a/src/chain/token_manager.cpp b/src/chain/token_manager.cpp new file mode 100644 index 0000000..46e038a --- /dev/null +++ b/src/chain/token_manager.cpp @@ -0,0 +1,52 @@ +// Copyright (c) 2025 The Unicity Foundation +// Distributed under the MIT software license + +#include "chain/token_manager.hpp" + +#include "chain/chainstate_manager.hpp" +#include "util/hash.hpp" +#include "util/logging.hpp" + +#include +#include + +namespace unicity { +namespace mining { + +TokenManager::TokenManager(const std::filesystem::path& datadir, + validation::ChainstateManager& chainstate) + : datadir_(datadir), chainstate_(chainstate), generator_(datadir) {} + + +void TokenManager::RecordReward(const uint256& blockHash, const uint256& rewardTokenId) { + const chain::CBlockIndex* index = chainstate_.LookupBlockIndex(blockHash); + if (!index) { + LOG_CHAIN_ERROR("RecordReward: Block {} not found in index", blockHash.GetHex()); + return; + } + + if (!chainstate_.IsOnActiveChain(index)) { + LOG_CHAIN_DEBUG("RecordReward: Block {} not on active chain, skipping reward logging", blockHash.GetHex()); + return; + } + + LOG_CHAIN_INFO("Recording found block reward: Height={}, Hash={}, TokenID={}", index->nHeight, blockHash.GetHex(), + rewardTokenId.GetHex()); + + std::lock_guard lock(csv_mutex_); + std::filesystem::path reward_file = datadir_ / "reward_tokens.csv"; + bool is_new_file = !std::filesystem::exists(reward_file); + + std::ofstream out(reward_file, std::ios::app); + if (out.is_open()) { + if (is_new_file) { + out << "Height,BlockHash,TokenID\n"; + } + out << index->nHeight << "," << blockHash.GetHex() << "," << rewardTokenId.GetHex() << "\n"; + } else { + LOG_CHAIN_ERROR("Failed to open {} for reward recording!", reward_file.string()); + } +} + +} // namespace mining +} // namespace unicity diff --git a/src/chain/trust_base.cpp b/src/chain/trust_base.cpp new file mode 100644 index 0000000..48f5fd8 --- /dev/null +++ b/src/chain/trust_base.cpp @@ -0,0 +1,256 @@ +#include "chain/trust_base.hpp" + +#include "util/hash.hpp" +#include "util/sha256.hpp" +#include "util/string_parsing.hpp" +#include "util/uint.hpp" + +#include + +#include + +namespace unicity::chain { + +namespace { +// Global secp256k1 context +const secp256k1_context* GetContext() { + return secp256k1_context_static; +} + +std::vector PrependCborTag(const std::vector& data) { + std::vector tagged; + tagged.reserve(data.size() + 3); + tagged.push_back(0xd9); + tagged.push_back(0x98); + tagged.push_back(0x58); + tagged.insert(tagged.end(), data.begin(), data.end()); + return tagged; +} +} // namespace + +void to_json(nlohmann::json& j, const NodeInfo& n) { + j = nlohmann::json::array(); + j.push_back(n.node_id); + j.push_back(nlohmann::json::binary(n.sig_key)); + j.push_back(n.stake); +} + +void from_json(const nlohmann::json& j, NodeInfo& n) { + if (!j.is_array() || j.size() < 3) { + throw std::runtime_error("NodeInfo CBOR must be an array of at least 3 elements"); + } + j.at(0).get_to(n.node_id); + if (j.at(1).is_binary()) { + n.sig_key = j.at(1).get_binary(); + } else { + // Fallback if not strictly binary (e.g. empty array) + n.sig_key = j.at(1).get>(); + } + j.at(2).get_to(n.stake); +} + +void to_json(nlohmann::json& j, const RootTrustBaseV1& r) { + j = nlohmann::json::array(); + j.push_back(r.version); + j.push_back(r.network_id); + j.push_back(r.epoch); + j.push_back(r.epoch_start); + j.push_back(r.root_nodes); // Uses NodeInfo to_json + j.push_back(r.quorum_threshold); + + // Handle empty vectors as null/empty bytes + if (r.state_hash.empty()) + j.push_back(nullptr); + else + j.push_back(nlohmann::json::binary(r.state_hash)); + + if (r.change_record_hash.empty()) + j.push_back(nullptr); + else + j.push_back(nlohmann::json::binary(r.change_record_hash)); + + if (r.previous_entry_hash.empty()) + j.push_back(nullptr); + else + j.push_back(nlohmann::json::binary(r.previous_entry_hash)); + + // Signatures map + // Go encodes map[string][]byte + if (r.signatures.empty()) { + j.push_back(nlohmann::json::object()); + } else { + auto sigs_json = nlohmann::json::object(); + for (const auto& [id, sig] : r.signatures) { + sigs_json[id] = nlohmann::json::binary(sig); + } + j.push_back(sigs_json); + } +} + +void from_json(const nlohmann::json& j, RootTrustBaseV1& r) { + if (!j.is_array() || j.size() < 10) { + throw std::runtime_error("RootTrustBaseV1 CBOR must be an array of at least 10 elements"); + } + + j.at(0).get_to(r.version); + j.at(1).get_to(r.network_id); + j.at(2).get_to(r.epoch); + j.at(3).get_to(r.epoch_start); + j.at(4).get_to(r.root_nodes); + j.at(5).get_to(r.quorum_threshold); + + auto extract_bytes = [](const nlohmann::json& val, std::vector& out) { + if (val.is_null()) { + out.clear(); + } else if (val.is_binary()) { + out = val.get_binary(); + } else { + out.clear(); // Or throw? Null usually means empty hash. + } + }; + + extract_bytes(j.at(6), r.state_hash); + extract_bytes(j.at(7), r.change_record_hash); + extract_bytes(j.at(8), r.previous_entry_hash); + + r.signatures.clear(); + if (j.at(9).is_object()) { + for (auto& [key, val] : j.at(9).items()) { + if (val.is_binary()) { + r.signatures[key] = val.get_binary(); + } else { + // Try vector + r.signatures[key] = val.get>(); + } + } + } +} + +RootTrustBaseV1 RootTrustBaseV1::FromCBOR(std::span data) { + const nlohmann::json j = nlohmann::json::from_cbor(data, true, true, nlohmann::json::cbor_tag_handler_t::ignore); + RootTrustBaseV1 tb; + from_json(j, tb); + return tb; +} + +std::vector RootTrustBaseV1::SigBytes() const { + nlohmann::json j; + to_json(j, *this); + j[9] = nullptr; // Force the last element (signatures) to be null + + return PrependCborTag(nlohmann::json::to_cbor(j)); +} + +std::vector RootTrustBaseV1::ToCBOR() const { + nlohmann::json j; + to_json(j, *this); + + return PrependCborTag(nlohmann::json::to_cbor(j)); +} + +std::vector RootTrustBaseV1::Hash() const { + std::vector tagged = ToCBOR(); + uint256 h = SingleHash(tagged); + return std::vector(h.begin(), h.end()); +} + +bool RootTrustBaseV1::IsValid(const std::optional& prev) const { + if (quorum_threshold == 0) { + return false; + } + if (root_nodes.empty()) { + return false; + } + + // Check for stake overflow and quorum_threshold consistency + uint64_t total_possible_stake = 0; + for (const auto& node : root_nodes) { + if (total_possible_stake > std::numeric_limits::max() - node.stake) { + return false; // overflow + } + total_possible_stake += node.stake; + } + + if (quorum_threshold > total_possible_stake) { + return false; // quorum is not possible + } + + if (!prev.has_value()) { + if (epoch != 1) + return false; + } else { + if (network_id != prev->network_id) + return false; + if (epoch != prev->epoch + 1) + return false; + if (epoch_start <= prev->epoch_start) + return false; + + // Verify previous entry hash + std::vector prev_hash = prev->Hash(); + if (previous_entry_hash != prev_hash) + return false; + } + return true; +} + +bool RootTrustBaseV1::VerifySignatures(const std::optional& prev) const { + const RootTrustBaseV1* trusted = nullptr; + if (epoch == 1) { + trusted = this; // Genesis is self-signed + } else if (!prev.has_value()) { + return false; + } else { + trusted = &prev.value(); + } + + std::vector sig_data = SigBytes(); + uint256 msg_hash = SingleHash(sig_data); + + uint64_t total_stake = 0; + + for (const auto& [node_id, sig] : signatures) { + const NodeInfo* node = trusted->GetNode(node_id); + if (!node) { + continue; // Unknown signer + } + // Verify signature + secp256k1_pubkey pubkey; + if (!secp256k1_ec_pubkey_parse(GetContext(), &pubkey, node->sig_key.data(), node->sig_key.size())) { + continue; + } + + if (sig.size() < 64) { + continue; + } + + // We only need the first 64 bytes (r, s). The 65th byte is recovery ID which parse_compact doesn't use. + secp256k1_ecdsa_signature algo_sig; + if (!secp256k1_ecdsa_signature_parse_compact(GetContext(), &algo_sig, sig.data())) { + continue; + } + + if (secp256k1_ecdsa_verify(GetContext(), &algo_sig, msg_hash.begin(), &pubkey)) { + if (total_stake > std::numeric_limits::max() - node->stake) { + return false; // overflow + } + total_stake += node->stake; + } + } + + return total_stake >= trusted->quorum_threshold; +} + +bool RootTrustBaseV1::Verify(const std::optional& prev) const { + return IsValid(prev) && VerifySignatures(prev); +} + +const NodeInfo* RootTrustBaseV1::GetNode(const std::string& node_id) const { + for (const auto& node : root_nodes) { + if (node.node_id == node_id) + return &node; + } + return nullptr; +} + +} // namespace unicity::chain diff --git a/src/chain/trust_base_manager.cpp b/src/chain/trust_base_manager.cpp new file mode 100644 index 0000000..0c575d0 --- /dev/null +++ b/src/chain/trust_base_manager.cpp @@ -0,0 +1,158 @@ +#include "chain/trust_base_manager.hpp" + +#include +#include +#include + +#include + +namespace unicity::chain { + +LocalTrustBaseManager::LocalTrustBaseManager(const std::filesystem::path& data_dir, std::shared_ptr bft_client) + : data_dir_(data_dir / "trustbases"), bft_client_(std::move(bft_client)) { + if (!bft_client_) { + throw std::invalid_argument("TrustBaseManager: BFTClient cannot be null"); + } + std::filesystem::create_directories(data_dir_); +} + +void LocalTrustBaseManager::Load() { + std::lock_guard lock(mutex_); + + // Load all .cbor files in data_dir_ + for (const auto& entry : std::filesystem::directory_iterator(data_dir_)) { + if (entry.path().extension() == ".cbor") { + std::ifstream file(entry.path(), std::ios::binary); + if (!file) { + throw std::runtime_error("Failed to open trust base file for reading: " + entry.path().string()); + } + + std::vector data((std::istreambuf_iterator(file)), std::istreambuf_iterator()); + try { + RootTrustBaseV1 tb = RootTrustBaseV1::FromCBOR(data); + trust_bases_[tb.epoch] = tb; + } catch (const std::exception& e) { + throw std::runtime_error("Failed to parse local trust base file " + entry.path().string() + ": " + e.what()); + } + } + } + + const auto* latest = GetLatest(); + spdlog::info("Loaded {} trust bases. Latest epoch: {}", trust_bases_.size(), + latest ? std::to_string(latest->epoch) : "None"); +} + +// Initialize loads the stored trust base files to memory and creates trust base file for genesis trust base, if needed. +void LocalTrustBaseManager::Initialize(const std::vector& genesis_utb_data) { + Load(); + + { + std::lock_guard lock(mutex_); + if (trust_bases_.contains(1)) { + return; // Already loaded, skipping verification + } + } + + const auto tb_opt = bft_client_->FetchTrustBase(1); + if (!tb_opt) { + throw std::runtime_error("Failed to fetch epoch 1 trust base from network"); + } + + const RootTrustBaseV1 tb = *tb_opt; + if (tb.epoch != 1) { + throw std::runtime_error("Fetched UTB is not epoch 1 (got " + std::to_string(tb.epoch) + ")"); + } + + const RootTrustBaseV1 genesis_tb = RootTrustBaseV1::FromCBOR(genesis_utb_data); + if (tb.Hash() != genesis_tb.Hash()) { + throw std::runtime_error("Genesis UTB hash does not match fetched UTB hash"); + } + + if (!ProcessTrustBase(tb)) { + throw std::runtime_error("Failed to process and store fetched genesis UTB"); + } +} + +std::optional LocalTrustBaseManager::GetLatestTrustBase() const { + std::lock_guard lock(mutex_); + const auto* latest = GetLatest(); + return latest ? std::make_optional(*latest) : std::nullopt; +} + +std::optional LocalTrustBaseManager::GetTrustBase(const uint64_t epoch) const { + std::lock_guard lock(mutex_); + if (const auto it = trust_bases_.find(epoch); it != trust_bases_.end()) { + return it->second; + } + return std::nullopt; +} + +std::optional LocalTrustBaseManager::ProcessTrustBase(const RootTrustBaseV1& tb) { + std::lock_guard lock(mutex_); + + if (tb.epoch == 0) { + throw std::invalid_argument("Trust base epoch cannot be 0"); + } + + if (trust_bases_.contains(tb.epoch)) { + return std::nullopt; + } + + std::optional prev_tb; + if (const auto it = trust_bases_.find(tb.epoch - 1); it != trust_bases_.end()) { + prev_tb = it->second; + } + + if (!tb.Verify(prev_tb)) { + throw std::invalid_argument("Failed to verify trust base for epoch " + std::to_string(tb.epoch)); + } + + SaveToDisk(tb); + + trust_bases_[tb.epoch] = tb; + + spdlog::info("Processed and saved new trust base for epoch {}", tb.epoch); + return tb; +} + +void LocalTrustBaseManager::SaveToDisk(const RootTrustBaseV1& tb) const { + const std::filesystem::path file_path = data_dir_ / ("epoch_" + std::to_string(tb.epoch) + ".cbor"); + std::ofstream file(file_path, std::ios::binary); + if (!file) { + throw std::runtime_error("Failed to open " + file_path.string() + " for writing"); + } + const auto data = tb.ToCBOR(); + file.write(reinterpret_cast(data.data()), data.size()); + if (!file) { + throw std::runtime_error("Failed to write data to " + file_path.string()); + } +} + +std::vector LocalTrustBaseManager::SyncToEpoch(uint64_t target_epoch) { + { + std::lock_guard lock(mutex_); + if (trust_bases_.contains(target_epoch)) { + return {}; + } + } + return SyncTrustBases(); +} + +std::vector LocalTrustBaseManager::SyncTrustBases() { + uint64_t from_epoch = 1; + if (const auto latest = GetLatestTrustBase()) { + from_epoch = latest->epoch; + } + + const auto record_blobs = bft_client_->FetchTrustBases(from_epoch); + + std::vector new_tbs; + for (const auto& tb : record_blobs) { + if (auto processed = ProcessTrustBase(tb)) { + new_tbs.push_back(std::move(*processed)); + } + } + return new_tbs; +} + +} // namespace unicity::chain diff --git a/src/chain/validation.cpp b/src/chain/validation.cpp index 02d85a7..e92ef74 100644 --- a/src/chain/validation.cpp +++ b/src/chain/validation.cpp @@ -6,9 +6,12 @@ #include "chain/block.hpp" #include "chain/block_index.hpp" +#include "chain/trust_base.hpp" +#include "chain/trust_base_manager.hpp" #include "chain/chainparams.hpp" #include "chain/pow.hpp" #include "chain/randomx_pow.hpp" +#include "util/hash.hpp" #include "util/logging.hpp" #include "util/time.hpp" #include "util/uint.hpp" @@ -16,7 +19,8 @@ namespace unicity { namespace validation { -bool CheckBlockHeader(const CBlockHeader& header, const chain::ChainParams& params, ValidationState& state) { +bool CheckBlockHeader(const CBlockHeader& header, const chain::ChainParams& params, ValidationState& state, + const chain::TrustBaseManager& tbm) { // 1. Version validation (basic sanity, context-free) // Reject obviously invalid versions (negative or zero) // This is a context-free check - specific version requirements may be contextual @@ -24,17 +28,56 @@ bool CheckBlockHeader(const CBlockHeader& header, const chain::ChainParams& para return state.Invalid("bad-version", "block version too old: " + std::to_string(header.nVersion)); } - // 2. Check that hashRandomX commitment is present (not null) + // 2. Validate Payload Size + if (header.vPayload.size() < 32) { + return state.Invalid("bad-payload-size", "block payload missing Token ID hash"); + } + if (header.vPayload.size() > CBlockHeader::MAX_PAYLOAD_SIZE) { + return state.Invalid("bad-payload-size", "block payload exceeds maximum size"); + } + + // 3. Check that hashRandomX commitment is present (not null) // A null hashRandomX indicates a malformed block header, not just failed PoW if (header.hashRandomX.IsNull()) { return state.Invalid("bad-randomx-hash", "block header missing RandomX hash commitment"); } - // 3. Check proof of work (RandomX) + // 4. Check proof of work (RandomX) if (!consensus::CheckProofOfWork(header, header.nBits, params, crypto::POWVerifyMode::FULL)) { return state.Invalid("high-hash", "proof of work failed"); } + // 5. Validate Payload Integrity + // Extract Token ID Hash (leaf_0) directly from payload + uint256 leaf_0; + std::memcpy(leaf_0.begin(), header.vPayload.data(), 32); + + // If payload size > 32, the remainder is the CBOR-encoded UTB record. + uint256 leaf_1 = uint256::ZERO; + if (header.vPayload.size() > 32) { + const std::span cbor_bytes(header.vPayload.data() + 32, header.vPayload.size() - 32); + // parse the trust base and verify it extends the previous entry + try { + const auto tb = chain::RootTrustBaseV1::FromCBOR(cbor_bytes); + if (tb.epoch == 0) { + return state.Invalid("bad-trustbase", "trust base epoch cannot be 0"); + } + if (!tb.Verify(tbm.GetTrustBase(tb.epoch - 1))) { + return state.Invalid("bad-trustbase", "trust base for epoch " + std::to_string(tb.epoch) + + " does not extend previous trust base (epoch " + + std::to_string(tb.epoch - 1) + ")"); + } + } catch (const std::exception& e) { + return state.Invalid("bad-trustbase", "failed to parse UTB from payload: " + std::string(e.what())); + } + leaf_1 = SingleHash(cbor_bytes); + } + + const uint256 calculated_root = CBlockHeader::ComputePayloadRoot(leaf_0, leaf_1); + if (calculated_root != header.payloadRoot) { + return state.Invalid("bad-payload-root", "calculated payload root does not match header commitment"); + } + return true; } @@ -87,6 +130,24 @@ bool ContextualCheckBlockHeader(const CBlockHeader& header, const chain::CBlockI // Note: Version validation is done in CheckBlockHeader (context-free) // Additional contextual version requirements (e.g., soft forks) would go here + // 5. Check BFT epoch continuity + if (pindexPrev) { + // Extract UTB from payload if present (payload > 32 bytes) + if (header.vPayload.size() > 32) { + try { + const std::span utb_cbor(header.vPayload.data() + 32, header.vPayload.size() - 32); + const auto utb = chain::RootTrustBaseV1::FromCBOR(utb_cbor); + uint64_t expected_epoch = pindexPrev->bftEpoch + 1; + if (utb.epoch != expected_epoch) { + return state.Invalid("bad-bft-epoch", "block included BFT epoch " + std::to_string(utb.epoch) + + ", expected " + std::to_string(expected_epoch)); + } + } catch (const std::exception& e) { + return state.Invalid("bad-utb-cbor", "failed to parse UTB from payload: " + std::string(e.what())); + } + } + } + return true; } diff --git a/src/main.cpp b/src/main.cpp index 11d22c0..7a0890d 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -17,6 +17,9 @@ void print_usage(const char *program_name) { << "Options:\n" << " --datadir= Data directory (default: ~/.unicity)\n" << " --port= Listen port (default: 9590 mainnet, 19590 testnet, 29590 regtest)\n" + << " --bftaddr= BFT rpc address (default: http://127.0.0.1:25866)\n" + << " Set to empty (e.g. --bftaddr=\"\") to disable BFT integration.\n" + << " Disabled by default in --regtest mode.\n" << " --nolisten Disable inbound connections (inbound is enabled by default)\n" << " --connect= Maximum outbound connections (0 = disable automatic outbound)\n" << " --maxinbound= Maximum inbound connections (default: 125)\n" @@ -64,6 +67,15 @@ int main(int argc, char *argv[]) { return 1; } config.datadir = datadir; + } else if (arg.starts_with("--bftaddr=")) { + std::string bftaddr = arg.substr(10); + if (bftaddr.empty()) { + config.bftaddr = ""; + } else if (!bftaddr.starts_with("http://") && !bftaddr.starts_with("https://")) { + config.bftaddr = "http://" + bftaddr; + } else { + config.bftaddr = bftaddr; + } } else if (arg.starts_with("--port=")) { auto port_opt = unicity::util::SafeParsePort(arg.substr(7)); if (!port_opt) { @@ -106,6 +118,8 @@ int main(int argc, char *argv[]) { unicity::protocol::magic::REGTEST; config.network_config.listen_port = unicity::protocol::ports::REGTEST; + // Disable BFT by default in regtest + config.bftaddr = ""; // Disable NAT for regtest (localhost testing doesn't need UPnP) config.network_config.enable_nat = false; } else if (arg == "--testnet") { diff --git a/src/network/message.cpp b/src/network/message.cpp index 0fdaa15..6618fb1 100644 --- a/src/network/message.cpp +++ b/src/network/message.cpp @@ -571,8 +571,9 @@ std::vector HeadersMessage::serialize() const { MessageSerializer s; s.write_varint(headers.size()); for (const auto& header : headers) { - auto header_bytes = header.Serialize(); - s.write_bytes(header_bytes.data(), header_bytes.size()); + auto full_block = header.Serialize(true); + s.write_varint(full_block.size()); + s.write_bytes(full_block.data(), full_block.size()); } return s.data(); } @@ -594,17 +595,23 @@ bool HeadersMessage::deserialize(const uint8_t* data, size_t size) { headers.reserve(allocated); } - // Read header bytes - auto header_bytes = d.read_bytes(CBlockHeader::HEADER_SIZE); - if (header_bytes.size() != CBlockHeader::HEADER_SIZE) + // Read total block size + const uint64_t block_size = d.read_varint(); + if (block_size < CBlockHeader::HEADER_SIZE || block_size > protocol::MAX_PROTOCOL_MESSAGE_LENGTH) { + return false; + } + + // Read block bytes + auto block_bytes = d.read_bytes(block_size); + if (block_bytes.size() != block_size) return false; CBlockHeader header; - if (!header.Deserialize(header_bytes.data(), header_bytes.size())) { + if (!header.Deserialize(block_bytes.data(), block_bytes.size())) { return false; } - headers.push_back(header); + headers.push_back(std::move(header)); } return !d.has_error(); } diff --git a/src/network/rpc_server.cpp b/src/network/rpc_server.cpp index 63262a9..a90ba58 100644 --- a/src/network/rpc_server.cpp +++ b/src/network/rpc_server.cpp @@ -21,6 +21,7 @@ #include "chain/chainparams.hpp" #include "chain/chainstate_manager.hpp" #include "chain/miner.hpp" +#include "chain/token_manager.hpp" #include "chain/notifications.hpp" #include "chain/pow.hpp" #include "chain/timedata.hpp" @@ -29,6 +30,7 @@ #include "network/network_manager.hpp" #include "network/addr_relay_manager.hpp" #include "network/connection_manager.hpp" +#include "util/hash.hpp" #include "util/logging.hpp" #include "util/netaddress.hpp" #include "util/string_parsing.hpp" @@ -74,12 +76,16 @@ RPCServer::RPCServer( validation::ChainstateManager& chainstate_manager, network::NetworkManager& network_manager, mining::CPUMiner* miner, + mining::TokenManager& token_manager, + chain::TrustBaseManager& trust_base_manager, const chain::ChainParams& params, std::function shutdown_callback) : socket_path_(socket_path) , chainstate_manager_(chainstate_manager) , network_manager_(network_manager) , miner_(miner) + , token_manager_(token_manager) + , trust_base_manager_(trust_base_manager) , params_(params) , shutdown_callback_(shutdown_callback) , server_fd_(-1) @@ -395,10 +401,16 @@ void RPCServer::HandleClient(int client_fd) { // Use proper JSON parsing std::string method; std::vector params; + std::string request_id = "0"; try { nlohmann::json j = nlohmann::json::parse(request); + // Extract ID (mirror in response) + if (j.contains("id")) { + request_id = j["id"].dump(); + } + // Extract method if (!j.contains("method") || !j["method"].is_string()) { std::string error = util::JsonError("Missing or invalid method field"); @@ -448,14 +460,14 @@ void RPCServer::HandleClient(int client_fd) { if (result.is_error) { // Error: use extracted error_message as the JSON-RPC error string json_rpc_response = "{\"result\":null,\"error\":\"" - + util::EscapeJSONString(result.error_message) + "\",\"id\":0}\n"; + + util::EscapeJSONString(result.error_message) + "\",\"id\":" + request_id + "}\n"; } else { // Success: embed handler JSON as the JSON-RPC result value std::string trimmed = result.json; while (!trimmed.empty() && (trimmed.back() == '\n' || trimmed.back() == '\r')) { trimmed.pop_back(); } - json_rpc_response = "{\"result\":" + trimmed + ",\"error\":null,\"id\":0}\n"; + json_rpc_response = "{\"result\":" + trimmed + ",\"error\":null,\"id\":" + request_id + "}\n"; } std::string http_response = "HTTP/1.1 200 OK\r\n" @@ -737,6 +749,7 @@ std::string RPCServer::HandleGetBlockHeader(const std::vector& para << " \"difficulty\": " << difficulty << ",\n" << " \"chainwork\": \"" << index->nChainWork.GetHex() << "\",\n" << " \"previousblockhash\": \"" << (index->pprev ? index->pprev->GetBlockHash().GetHex() : "null") << "\",\n" + << " \"payload_root\": \"" << index->payloadRoot.GetHex() << "\",\n" << " \"rx_hash\": \"" << index->hashRandomX.GetHex() << "\""; // nextblockhash (if block has a successor on the active chain) @@ -1593,23 +1606,6 @@ std::string RPCServer::HandleStartMining(const std::vector& params) return util::JsonError("Already mining"); } - // Parse optional mining address parameter - // Note: Address is "sticky" - if not provided, previous address is retained - if (!params.empty()) { - const std::string& address_str = params[0]; - - // Validate address is 40 hex characters (160 bits / 4 bits per hex char) - // Validate length and hex characters using centralized helper - if (address_str.length() != 40 || !util::IsValidHex(address_str)) { - return util::JsonError("Invalid mining address (must be 40 hex characters)"); - } - - // Parse and set mining address (persists across subsequent calls) - uint160 mining_address; - mining_address.SetHex(address_str); - miner_->SetMiningAddress(mining_address); - } - bool started = miner_->Start(); if (!started) { return util::JsonError("Failed to start mining"); @@ -1618,8 +1614,7 @@ std::string RPCServer::HandleStartMining(const std::vector& params) std::ostringstream oss; oss << "{\n" << " \"mining\": true,\n" - << " \"message\": \"Mining started\",\n" - << " \"address\": \"" << miner_->GetMiningAddress().GetHex() << "\"\n" + << " \"message\": \"Mining started\"\n" << "}\n"; return oss.str(); } @@ -1665,23 +1660,6 @@ std::string RPCServer::HandleGenerate(const std::vector& params) { int num_blocks = *num_blocks_opt; - // Parse optional mining address parameter (second parameter) - // Note: Address is "sticky" - if not provided, previous address is retained - if (params.size() >= 2) { - const std::string& address_str = params[1]; - - // Validate address is 40 hex characters (160 bits / 4 bits per hex char) - // Validate length and hex characters using centralized helper - if (address_str.length() != 40 || !util::IsValidHex(address_str)) { - return util::JsonError("Invalid mining address (must be 40 hex characters)"); - } - - // Parse and set mining address (persists across subsequent calls) - uint160 mining_address; - mining_address.SetHex(address_str); - miner_->SetMiningAddress(mining_address); - } - // Get starting height and calculate target const chain::CBlockIndex* start_tip = chainstate_manager_.GetTip(); int start_height = start_tip ? start_tip->nHeight : -1; @@ -1777,7 +1755,7 @@ std::string RPCServer::HandleSubmitHeader(const std::vector& params } if (params.empty()) { - return util::JsonError("Missing parameter: hex-encoded 100-byte header"); + return util::JsonError("Missing parameter: hex-encoded 112-byte header"); } const std::string& hex = params[0]; @@ -1796,14 +1774,14 @@ std::string RPCServer::HandleSubmitHeader(const std::vector& params } } - // Expect exactly 200 hex chars (100 bytes) - if (hex.size() != 200) { - return util::JsonError("Invalid header length (expect 200 hex chars)"); + // Expect at least 224 hex chars (112 bytes) + if (hex.size() < 224 || hex.size() % 2 != 0) { + return util::JsonError("Invalid header length (expect at least 224 hex chars)"); } // Decode hex std::vector bytes; - bytes.reserve(100); + bytes.reserve(hex.size() / 2); auto hex_to_nibble = [](char c) -> int { if (c >= '0' && c <= '9') return c - '0'; @@ -1823,7 +1801,7 @@ std::string RPCServer::HandleSubmitHeader(const std::vector& params bytes.push_back(static_cast((hi << 4) | lo)); } - if (bytes.size() != CBlockHeader::HEADER_SIZE) { + if (bytes.size() < CBlockHeader::HEADER_SIZE) { return util::JsonError("Decoded header size mismatch"); } @@ -1832,6 +1810,11 @@ std::string RPCServer::HandleSubmitHeader(const std::vector& params return util::JsonError("Failed to deserialize header"); } + // add default reward token id to payload + if (bytes.size() == CBlockHeader::HEADER_SIZE) { + header.vPayload.assign(32, 0); + } + // Apply temporary PoW-skip hook if requested (regtest-only) bool prev = chainstate_manager_.TestGetSkipPoWChecks(); chainstate_manager_.TestSetSkipPoWChecks(skip_pow); @@ -2105,59 +2088,78 @@ std::string RPCServer::HandleGetBlockTemplate(const std::vector& pa cur_time = tip->nTime + 1; } + // Calculate reward token + const uint256 rewardTokenId = token_manager_.GenerateNextTokenId(); + const uint256 leaf_0 = SingleHash(std::span(rewardTokenId.begin(), rewardTokenId.size())); + uint256 leaf_1 = uint256::ZERO; + std::vector utb_cbor; + + // try to load the latest trust base from BFT node, + // if unsuccessful then log warning and proceed without + // including new UTB to block + uint64_t target_epoch = tip->bftEpoch + 1; + try { + trust_base_manager_.SyncToEpoch(target_epoch); + if (auto utb = trust_base_manager_.GetTrustBase(target_epoch)) { + utb_cbor = utb->ToCBOR(); + leaf_1 = SingleHash(utb_cbor); + } + } catch (const std::exception& e) { + LOG_CHAIN_WARN("RPC: Failed to sync trust bases to epoch {}: {}", target_epoch, e.what()); + } + + const uint256 payload_root = CBlockHeader::ComputePayloadRoot(leaf_0, leaf_1); + + // Construct full payload (leaf_0 + UTB_cbor) + // This is what the miner actually puts into the block + std::vector payload_bytes; + payload_bytes.assign(leaf_0.begin(), leaf_0.end()); + if (!utb_cbor.empty()) { + payload_bytes.insert(payload_bytes.end(), utb_cbor.begin(), utb_cbor.end()); + } + // Build response JSON // Note: This is a simplified getblocktemplate for headers-only chain // No transactions, coinbase, or merkle tree - just header fields std::ostringstream oss; oss << "{\n" - << " \"version\": 1,\n" + << " \"version\": " << 1 << ",\n" << " \"previousblockhash\": \"" << prev_hash.GetHex() << "\",\n" << " \"height\": " << next_height << ",\n" << " \"curtime\": " << cur_time << ",\n" << " \"bits\": \"" << std::hex << std::setw(8) << std::setfill('0') << next_bits << std::dec << "\",\n" << " \"target\": \"" << target.GetHex() << "\",\n" + << " \"payloadroot\": \"" << payload_root.GetHex() << "\",\n" << " \"longpollid\": \"" << prev_hash.GetHex() << "\",\n" << " \"mintime\": " << (tip->nTime + 1) << ",\n" << " \"mutable\": [\"time\", \"nonce\"],\n" << " \"noncerange\": \"00000000ffffffff\",\n" - << " \"capabilities\": [\"longpoll\"]\n" + << " \"capabilities\": [\"longpoll\"],\n" + << " \"payload\": \"" << util::ToHex(payload_bytes) << "\",\n" + << " \"rewardtokenid\": \"" << rewardTokenId.GetHex() << "\"\n" << "}\n"; return oss.str(); } std::string RPCServer::HandleSubmitBlock(const std::vector& params) { - if (params.empty()) { - return util::JsonError("Missing hex-encoded block header"); + if (params.size() < 2) { + return util::JsonError("Usage: submitblock "); } const std::string& hex = params[0]; - // Expect exactly 200 hex chars (100 bytes) - if (hex.size() != 200) { - return util::JsonError("Invalid header length (expect 200 hex chars for 100-byte header)"); + // Expect at least 288 hex chars (112-byte header + 32-byte minimum payload) + if (hex.size() < 288) { + return util::JsonError("Invalid block data (expect at least 288 hex chars for 112-byte header + 32-byte payload)"); } // Decode hex to bytes - auto hex_to_nibble = [](char c) -> int { - if (c >= '0' && c <= '9') - return c - '0'; - if (c >= 'a' && c <= 'f') - return 10 + (c - 'a'); - if (c >= 'A' && c <= 'F') - return 10 + (c - 'A'); - return -1; - }; - std::vector bytes; - bytes.reserve(100); - for (size_t i = 0; i < hex.size(); i += 2) { - int hi = hex_to_nibble(hex[i]); - int lo = hex_to_nibble(hex[i + 1]); - if (hi < 0 || lo < 0) { - return util::JsonError("Invalid hex character in header"); - } - bytes.push_back(static_cast((hi << 4) | lo)); + try { + bytes = util::ParseHex(hex); + } catch (...) { + return util::JsonError("Invalid hex character in block data"); } // Deserialize block header @@ -2166,6 +2168,21 @@ std::string RPCServer::HandleSubmitBlock(const std::vector& params) return util::JsonError("Failed to deserialize block header"); } + // Sanity check: Token ID must match the payload + auto id_opt = util::SafeParseHash(params[1]); + if (!id_opt) { + return util::JsonError("Invalid rewardtokenid format (must be 64 hex characters)"); + } + uint256 rewardTokenId = *id_opt; + + if (header.vPayload.size() < 32) { + return util::JsonError("block payload too small to contain reward token ID commitment"); + } + const uint256 providedHash = SingleHash(std::span(rewardTokenId.begin(), rewardTokenId.size())); + if (std::memcmp(providedHash.begin(), header.vPayload.data(), 32) != 0) { + return util::JsonError("rewardtokenid does not match the commitment in the block payload"); + } + // Validate and process the block validation::ValidationState state; bool accepted = chainstate_manager_.ProcessNewBlockHeader(header, state); @@ -2180,6 +2197,9 @@ std::string RPCServer::HandleSubmitBlock(const std::vector& params) return oss.str(); } + // Record reward token ID on success + token_manager_.RecordReward(header.GetHash(), rewardTokenId); + // Success - return block hash std::ostringstream oss; oss << "{\n" diff --git a/src/util/string_parsing.cpp b/src/util/string_parsing.cpp index acfea16..f87cf8e 100644 --- a/src/util/string_parsing.cpp +++ b/src/util/string_parsing.cpp @@ -8,10 +8,23 @@ #include #include #include +#include namespace unicity { namespace util { +namespace { +uint8_t hexValue(char c) { + if ('0' <= c && c <= '9') + return c - '0'; + if ('a' <= c && c <= 'f') + return c - 'a' + 10; + if ('A' <= c && c <= 'F') + return c - 'A' + 10; + throw std::invalid_argument("Invalid hex character"); +} +} // namespace + std::optional SafeParseInt(const std::string& str, int min, int max) { try { // Reject empty or whitespace-only strings @@ -103,6 +116,36 @@ bool IsValidHex(const std::string& str) { return true; } +std::string ToHex(const std::span data) { + static constexpr char hex_chars[] = "0123456789abcdef"; + std::string out; + out.reserve(data.size() * 2); + + for (const uint8_t b : data) { + out.push_back(hex_chars[b >> 4]); + out.push_back(hex_chars[b & 0x0f]); + } + + return out; +} + +std::vector ParseHex(const std::string_view hex) { + if (hex.size() % 2 != 0) { + throw std::invalid_argument("Hex string must have even length"); + } + + std::vector out; + out.reserve(hex.size() / 2); + + for (size_t i = 0; i < hex.size(); i += 2) { + const uint8_t high = hexValue(hex[i]); + const uint8_t low = hexValue(hex[i + 1]); + out.push_back((high << 4) | low); + } + + return out; +} + std::optional SafeParseHash(const std::string& str) { // Check length if (str.size() != 64) { diff --git a/test/benchmark/chain/randomx_bench.cpp b/test/benchmark/chain/randomx_bench.cpp index ae6960e..b26bfe1 100644 --- a/test/benchmark/chain/randomx_bench.cpp +++ b/test/benchmark/chain/randomx_bench.cpp @@ -1,263 +1,263 @@ -// Copyright (c) 2025 The Unicity Foundation -// RandomX benchmarks - Measures PoW hashing performance - -// CATCH_CONFIG_ENABLE_BENCHMARKING defined via CMake -#include "catch_amalgamated.hpp" - -#include "chain/randomx_pow.hpp" -#include "chain/pow.hpp" -#include "chain/chainparams.hpp" -#include "chain/block.hpp" -#include -#include - -using namespace unicity::crypto; -using namespace unicity::consensus; - -namespace { - -// Helper to create a random block header -CBlockHeader CreateRandomHeader(uint32_t nTime) { - static std::random_device rd; - static std::mt19937_64 gen(rd()); - static std::uniform_int_distribution nonce_dist; - static std::uniform_int_distribution byte_dist(0, 255); - - CBlockHeader header; - header.nVersion = 1; - header.hashPrevBlock.SetNull(); - header.nTime = nTime; - header.nBits = 0x207fffff; // Easy target for regtest - header.nNonce = nonce_dist(gen); - header.hashRandomX.SetNull(); - - for (int j = 0; j < 20; j++) { - header.minerAddress.data()[j] = byte_dist(gen); - } - - return header; -} - -} // anonymous namespace - -TEST_CASE("RandomX initialization", "[benchmark][randomx][init]") { - SECTION("Cache and VM creation time") { - // Shutdown if already initialized from previous test - ShutdownRandomX(); - - auto start_init = std::chrono::steady_clock::now(); - InitRandomX(); - auto end_init = std::chrono::steady_clock::now(); - auto init_us = std::chrono::duration_cast(end_init - start_init).count(); - - INFO("RandomX InitRandomX(): " << init_us << " us"); - - // First VM creation (includes cache creation) - uint32_t epoch = 0; - auto start_vm = std::chrono::steady_clock::now(); - auto vm = GetCachedVM(epoch); - auto end_vm = std::chrono::steady_clock::now(); - auto vm_us = std::chrono::duration_cast(end_vm - start_vm).count(); - - REQUIRE(vm != nullptr); - INFO("First VM creation (epoch 0, includes cache): " << vm_us << " us (" << (vm_us / 1000.0) << " ms)"); - - // Second VM access (should be cached) - auto start_cached = std::chrono::steady_clock::now(); - auto vm2 = GetCachedVM(epoch); - auto end_cached = std::chrono::steady_clock::now(); - auto cached_us = std::chrono::duration_cast(end_cached - start_cached).count(); - - REQUIRE(vm2 != nullptr); - INFO("Cached VM access: " << cached_us << " us"); - - // Different epoch (creates new cache and VM) - uint32_t epoch2 = 1; - auto start_new = std::chrono::steady_clock::now(); - auto vm3 = GetCachedVM(epoch2); - auto end_new = std::chrono::steady_clock::now(); - auto new_us = std::chrono::duration_cast(end_new - start_new).count(); - - REQUIRE(vm3 != nullptr); - INFO("New epoch VM creation: " << new_us << " us (" << (new_us / 1000.0) << " ms)"); - } -} - -TEST_CASE("RandomX hashing performance", "[benchmark][randomx][hash]") { - // Ensure RandomX is initialized - InitRandomX(); - - auto params = unicity::chain::ChainParams::CreateRegTest(); - uint32_t epoch = GetEpoch(params->GenesisBlock().nTime, params->GetConsensus().nRandomXEpochDuration); - - // Pre-warm the VM cache - auto vm = GetCachedVM(epoch); - REQUIRE(vm != nullptr); - - SECTION("Single hash computation") { - auto header = CreateRandomHeader(params->GenesisBlock().nTime); - - BENCHMARK("randomx_calculate_hash - single") { - char rx_hash[RANDOMX_HASH_SIZE]; - randomx_calculate_hash(vm->vm, &header, sizeof(header), rx_hash); - return rx_hash[0]; // Return something to prevent optimization - }; - } - - SECTION("Hash throughput - 10 hashes") { - std::vector headers; - for (int i = 0; i < 10; i++) { - headers.push_back(CreateRandomHeader(params->GenesisBlock().nTime + i)); - } - - BENCHMARK("10 RandomX hashes") { - char rx_hash[RANDOMX_HASH_SIZE]; - int result = 0; - for (const auto& header : headers) { - randomx_calculate_hash(vm->vm, &header, sizeof(header), rx_hash); - result += rx_hash[0]; - } - return result; - }; - } - - SECTION("Hash throughput - detailed timing") { - auto header = CreateRandomHeader(params->GenesisBlock().nTime); - - constexpr int NUM_HASHES = 100; - std::vector times; - times.reserve(NUM_HASHES); - - for (int i = 0; i < NUM_HASHES; i++) { - header.nNonce = i; // Vary the input - - auto start = std::chrono::steady_clock::now(); - char rx_hash[RANDOMX_HASH_SIZE]; - randomx_calculate_hash(vm->vm, &header, sizeof(header), rx_hash); - auto end = std::chrono::steady_clock::now(); - - times.push_back(std::chrono::duration_cast(end - start).count()); - } - - // Calculate statistics - int64_t total = 0; - int64_t min_time = times[0]; - int64_t max_time = times[0]; - for (auto t : times) { - total += t; - if (t < min_time) min_time = t; - if (t > max_time) max_time = t; - } - double avg = static_cast(total) / NUM_HASHES; - double hashes_per_sec = 1000000.0 / avg; - - INFO("RandomX hash timing (" << NUM_HASHES << " samples):"); - INFO(" Average: " << avg << " us (" << (avg / 1000.0) << " ms)"); - INFO(" Min: " << min_time << " us"); - INFO(" Max: " << max_time << " us"); - INFO(" Throughput: " << hashes_per_sec << " hashes/sec"); - - // Sanity check - RandomX should take at least 1ms per hash - REQUIRE(avg > 1000); - } -} - -TEST_CASE("RandomX commitment verification", "[benchmark][randomx][commitment]") { - InitRandomX(); - - auto params = unicity::chain::ChainParams::CreateRegTest(); - auto header = CreateRandomHeader(params->GenesisBlock().nTime); - - // First compute a valid hash - uint32_t epoch = GetEpoch(header.nTime, params->GetConsensus().nRandomXEpochDuration); - auto vm = GetCachedVM(epoch); - REQUIRE(vm != nullptr); - - char rx_hash[RANDOMX_HASH_SIZE]; - randomx_calculate_hash(vm->vm, &header, sizeof(header), rx_hash); - header.hashRandomX = uint256(std::vector(rx_hash, rx_hash + RANDOMX_HASH_SIZE)); - - SECTION("Commitment calculation only") { - BENCHMARK("GetRandomXCommitment") { - auto commitment = GetRandomXCommitment(header); - int result = commitment.IsNull() ? 0 : 1; - return result; - }; - } - - SECTION("Full PoW verification") { - // This includes commitment check + full RandomX hash verification - BENCHMARK("CheckProofOfWork - FULL mode") { - uint256 outHash; - bool success = CheckProofOfWork(header, header.nBits, *params, POWVerifyMode::FULL, &outHash); - return success ? 1 : 0; - }; - } - - SECTION("Commitment-only verification") { - // Just commitment check, no RandomX hash - BENCHMARK("CheckProofOfWork - COMMITMENT_ONLY") { - bool success = CheckProofOfWork(header, header.nBits, *params, POWVerifyMode::COMMITMENT_ONLY, nullptr); - return success ? 1 : 0; - }; - } -} - -TEST_CASE("RandomX epoch transition", "[benchmark][randomx][epoch]") { - InitRandomX(); - - auto params = unicity::chain::ChainParams::CreateRegTest(); - - // Test epoch transitions - std::vector epochs = {0, 1, 2, 0, 1}; // Switch back and forth - - SECTION("Epoch switch timing") { - for (size_t i = 0; i < epochs.size(); i++) { - uint32_t epoch = epochs[i]; - - auto start = std::chrono::steady_clock::now(); - auto vm = GetCachedVM(epoch); - auto end = std::chrono::steady_clock::now(); - - REQUIRE(vm != nullptr); - auto elapsed_us = std::chrono::duration_cast(end - start).count(); - - INFO("Epoch " << epoch << " access #" << i << ": " << elapsed_us << " us"); - } - } -} - -TEST_CASE("ASERT difficulty calculation", "[benchmark][pow][asert]") { - auto params = unicity::chain::ChainParams::CreateRegTest(); - const auto& consensus = params->GetConsensus(); - - // Use genesis block's nBits as reference target in compact form - arith_uint256 refTarget; - refTarget.SetCompact(params->GenesisBlock().nBits); - arith_uint256 powLimit = UintToArith256(consensus.powLimit); - - BENCHMARK("CalculateASERT - typical") { - auto result = CalculateASERT(refTarget, consensus.nPowTargetSpacing, - 86400, // 1 day elapsed - 10, // 10 blocks - powLimit, consensus.nASERTHalfLife); - uint32_t compact = result.GetCompact(); - return compact; - }; - - SECTION("ASERT with various time diffs") { - std::vector time_diffs = {60, 600, 3600, 86400, 604800}; // 1min to 1week - - for (int64_t td : time_diffs) { - auto start = std::chrono::steady_clock::now(); - for (int i = 0; i < 1000; i++) { - CalculateASERT(refTarget, consensus.nPowTargetSpacing, - td, 10, powLimit, consensus.nASERTHalfLife); - } - auto end = std::chrono::steady_clock::now(); - auto elapsed_us = std::chrono::duration_cast(end - start).count(); - - INFO("ASERT with time_diff=" << td << "s: " << (elapsed_us / 1000.0) << " us/call"); - } - } -} +// Copyright (c) 2025 The Unicity Foundation +// RandomX benchmarks - Measures PoW hashing performance + +// CATCH_CONFIG_ENABLE_BENCHMARKING defined via CMake +#include "catch_amalgamated.hpp" + +#include "chain/randomx_pow.hpp" +#include "chain/pow.hpp" +#include "chain/chainparams.hpp" +#include "chain/block.hpp" +#include +#include + +using namespace unicity::crypto; +using namespace unicity::consensus; + +namespace { + +// Helper to create a random block header +CBlockHeader CreateRandomHeader(uint32_t nTime) { + static std::random_device rd; + static std::mt19937_64 gen(rd()); + static std::uniform_int_distribution nonce_dist; + static std::uniform_int_distribution byte_dist(0, 255); + + CBlockHeader header; + header.nVersion = 1; + header.hashPrevBlock.SetNull(); + header.nTime = nTime; + header.nBits = 0x207fffff; // Easy target for regtest + header.nNonce = nonce_dist(gen); + header.hashRandomX.SetNull(); + + for (int j = 0; j < 20; j++) { + header.payloadRoot.data()[j] = byte_dist(gen); + } + + return header; +} + +} // anonymous namespace + +TEST_CASE("RandomX initialization", "[benchmark][randomx][init]") { + SECTION("Cache and VM creation time") { + // Shutdown if already initialized from previous test + ShutdownRandomX(); + + auto start_init = std::chrono::steady_clock::now(); + InitRandomX(); + auto end_init = std::chrono::steady_clock::now(); + auto init_us = std::chrono::duration_cast(end_init - start_init).count(); + + INFO("RandomX InitRandomX(): " << init_us << " us"); + + // First VM creation (includes cache creation) + uint32_t epoch = 0; + auto start_vm = std::chrono::steady_clock::now(); + auto vm = GetCachedVM(epoch); + auto end_vm = std::chrono::steady_clock::now(); + auto vm_us = std::chrono::duration_cast(end_vm - start_vm).count(); + + REQUIRE(vm != nullptr); + INFO("First VM creation (epoch 0, includes cache): " << vm_us << " us (" << (vm_us / 1000.0) << " ms)"); + + // Second VM access (should be cached) + auto start_cached = std::chrono::steady_clock::now(); + auto vm2 = GetCachedVM(epoch); + auto end_cached = std::chrono::steady_clock::now(); + auto cached_us = std::chrono::duration_cast(end_cached - start_cached).count(); + + REQUIRE(vm2 != nullptr); + INFO("Cached VM access: " << cached_us << " us"); + + // Different epoch (creates new cache and VM) + uint32_t epoch2 = 1; + auto start_new = std::chrono::steady_clock::now(); + auto vm3 = GetCachedVM(epoch2); + auto end_new = std::chrono::steady_clock::now(); + auto new_us = std::chrono::duration_cast(end_new - start_new).count(); + + REQUIRE(vm3 != nullptr); + INFO("New epoch VM creation: " << new_us << " us (" << (new_us / 1000.0) << " ms)"); + } +} + +TEST_CASE("RandomX hashing performance", "[benchmark][randomx][hash]") { + // Ensure RandomX is initialized + InitRandomX(); + + auto params = unicity::chain::ChainParams::CreateRegTest(); + uint32_t epoch = GetEpoch(params->GenesisBlock().nTime, params->GetConsensus().nRandomXEpochDuration); + + // Pre-warm the VM cache + auto vm = GetCachedVM(epoch); + REQUIRE(vm != nullptr); + + SECTION("Single hash computation") { + auto header = CreateRandomHeader(params->GenesisBlock().nTime); + + BENCHMARK("randomx_calculate_hash - single") { + char rx_hash[RANDOMX_HASH_SIZE]; + randomx_calculate_hash(vm->vm, &header, sizeof(header), rx_hash); + return rx_hash[0]; // Return something to prevent optimization + }; + } + + SECTION("Hash throughput - 10 hashes") { + std::vector headers; + for (int i = 0; i < 10; i++) { + headers.push_back(CreateRandomHeader(params->GenesisBlock().nTime + i)); + } + + BENCHMARK("10 RandomX hashes") { + char rx_hash[RANDOMX_HASH_SIZE]; + int result = 0; + for (const auto& header : headers) { + randomx_calculate_hash(vm->vm, &header, sizeof(header), rx_hash); + result += rx_hash[0]; + } + return result; + }; + } + + SECTION("Hash throughput - detailed timing") { + auto header = CreateRandomHeader(params->GenesisBlock().nTime); + + constexpr int NUM_HASHES = 100; + std::vector times; + times.reserve(NUM_HASHES); + + for (int i = 0; i < NUM_HASHES; i++) { + header.nNonce = i; // Vary the input + + auto start = std::chrono::steady_clock::now(); + char rx_hash[RANDOMX_HASH_SIZE]; + randomx_calculate_hash(vm->vm, &header, sizeof(header), rx_hash); + auto end = std::chrono::steady_clock::now(); + + times.push_back(std::chrono::duration_cast(end - start).count()); + } + + // Calculate statistics + int64_t total = 0; + int64_t min_time = times[0]; + int64_t max_time = times[0]; + for (auto t : times) { + total += t; + if (t < min_time) min_time = t; + if (t > max_time) max_time = t; + } + double avg = static_cast(total) / NUM_HASHES; + double hashes_per_sec = 1000000.0 / avg; + + INFO("RandomX hash timing (" << NUM_HASHES << " samples):"); + INFO(" Average: " << avg << " us (" << (avg / 1000.0) << " ms)"); + INFO(" Min: " << min_time << " us"); + INFO(" Max: " << max_time << " us"); + INFO(" Throughput: " << hashes_per_sec << " hashes/sec"); + + // Sanity check - RandomX should take at least 1ms per hash + REQUIRE(avg > 1000); + } +} + +TEST_CASE("RandomX commitment verification", "[benchmark][randomx][commitment]") { + InitRandomX(); + + auto params = unicity::chain::ChainParams::CreateRegTest(); + auto header = CreateRandomHeader(params->GenesisBlock().nTime); + + // First compute a valid hash + uint32_t epoch = GetEpoch(header.nTime, params->GetConsensus().nRandomXEpochDuration); + auto vm = GetCachedVM(epoch); + REQUIRE(vm != nullptr); + + char rx_hash[RANDOMX_HASH_SIZE]; + randomx_calculate_hash(vm->vm, &header, sizeof(header), rx_hash); + header.hashRandomX = uint256(std::vector(rx_hash, rx_hash + RANDOMX_HASH_SIZE)); + + SECTION("Commitment calculation only") { + BENCHMARK("GetRandomXCommitment") { + auto commitment = GetRandomXCommitment(header); + int result = commitment.IsNull() ? 0 : 1; + return result; + }; + } + + SECTION("Full PoW verification") { + // This includes commitment check + full RandomX hash verification + BENCHMARK("CheckProofOfWork - FULL mode") { + uint256 outHash; + bool success = CheckProofOfWork(header, header.nBits, *params, POWVerifyMode::FULL, &outHash); + return success ? 1 : 0; + }; + } + + SECTION("Commitment-only verification") { + // Just commitment check, no RandomX hash + BENCHMARK("CheckProofOfWork - COMMITMENT_ONLY") { + bool success = CheckProofOfWork(header, header.nBits, *params, POWVerifyMode::COMMITMENT_ONLY, nullptr); + return success ? 1 : 0; + }; + } +} + +TEST_CASE("RandomX epoch transition", "[benchmark][randomx][epoch]") { + InitRandomX(); + + auto params = unicity::chain::ChainParams::CreateRegTest(); + + // Test epoch transitions + std::vector epochs = {0, 1, 2, 0, 1}; // Switch back and forth + + SECTION("Epoch switch timing") { + for (size_t i = 0; i < epochs.size(); i++) { + uint32_t epoch = epochs[i]; + + auto start = std::chrono::steady_clock::now(); + auto vm = GetCachedVM(epoch); + auto end = std::chrono::steady_clock::now(); + + REQUIRE(vm != nullptr); + auto elapsed_us = std::chrono::duration_cast(end - start).count(); + + INFO("Epoch " << epoch << " access #" << i << ": " << elapsed_us << " us"); + } + } +} + +TEST_CASE("ASERT difficulty calculation", "[benchmark][pow][asert]") { + auto params = unicity::chain::ChainParams::CreateRegTest(); + const auto& consensus = params->GetConsensus(); + + // Use genesis block's nBits as reference target in compact form + arith_uint256 refTarget; + refTarget.SetCompact(params->GenesisBlock().nBits); + arith_uint256 powLimit = UintToArith256(consensus.powLimit); + + BENCHMARK("CalculateASERT - typical") { + auto result = CalculateASERT(refTarget, consensus.nPowTargetSpacing, + 86400, // 1 day elapsed + 10, // 10 blocks + powLimit, consensus.nASERTHalfLife); + uint32_t compact = result.GetCompact(); + return compact; + }; + + SECTION("ASERT with various time diffs") { + std::vector time_diffs = {60, 600, 3600, 86400, 604800}; // 1min to 1week + + for (int64_t td : time_diffs) { + auto start = std::chrono::steady_clock::now(); + for (int i = 0; i < 1000; i++) { + CalculateASERT(refTarget, consensus.nPowTargetSpacing, + td, 10, powLimit, consensus.nASERTHalfLife); + } + auto end = std::chrono::steady_clock::now(); + auto elapsed_us = std::chrono::duration_cast(end - start).count(); + + INFO("ASERT with time_diff=" << td << "s: " << (elapsed_us / 1000.0) << " us/call"); + } + } +} diff --git a/test/common/mock_bft_client.hpp b/test/common/mock_bft_client.hpp new file mode 100644 index 0000000..0a91a5b --- /dev/null +++ b/test/common/mock_bft_client.hpp @@ -0,0 +1,35 @@ +#pragma once + +#include "chain/bft_client.hpp" + +#include +#include + +namespace unicity::test { + +class MockBFTClient : public chain::BFTClient { +public: + std::optional FetchTrustBase(uint64_t epoch) override { + if (auto it = trust_bases_.find(epoch); it != trust_bases_.end()) { + return it->second; + } + return std::nullopt; + } + + std::vector FetchTrustBases(uint64_t from_epoch) override { + std::vector result; + for (auto it = trust_bases_.lower_bound(from_epoch); it != trust_bases_.end(); ++it) { + result.push_back(it->second); + } + return result; + } + + void SetTrustBase(const chain::RootTrustBaseV1& tb) { trust_bases_[tb.epoch] = tb; } + + void Clear() { trust_bases_.clear(); } + +private: + std::map trust_bases_; +}; + +} // namespace unicity::test diff --git a/test/common/mock_trust_base_manager.hpp b/test/common/mock_trust_base_manager.hpp new file mode 100644 index 0000000..35bd7d8 --- /dev/null +++ b/test/common/mock_trust_base_manager.hpp @@ -0,0 +1,59 @@ +#pragma once + +#include "chain/trust_base_manager.hpp" +#include "chain/chainparams.hpp" +#include "chain/trust_base.hpp" + +#include +#include +#include +#include + +namespace unicity::test { + +class MockTrustBaseManager : public chain::TrustBaseManager { +public: + MockTrustBaseManager() { + const auto params = chain::ChainParams::CreateRegTest(); + const auto utb_span = params->GenesisBlock().GetUTB(); + trust_bases_[1] = chain::RootTrustBaseV1::FromCBOR(utb_span); + } + + ~MockTrustBaseManager() override = default; + + void Initialize(const std::vector& /*genesis_utb_data*/) override {} + + std::vector SyncTrustBases() override { return {}; } + + void Load() override {} + + std::optional GetLatestTrustBase() const override { + if (trust_bases_.empty()) return std::nullopt; + return trust_bases_.rbegin()->second; + } + + std::vector SyncToEpoch(uint64_t /*target_epoch*/) override { return {}; } + + std::optional GetTrustBase(uint64_t epoch) const override { + auto it = trust_bases_.find(epoch); + if (it != trust_bases_.end()) { + return it->second; + } + return std::nullopt; + } + + std::optional ProcessTrustBase(const chain::RootTrustBaseV1& tb) override { + trust_bases_[tb.epoch] = tb; + return tb; + } + + // Test helper to inject trust bases + void SetTrustBase(uint64_t epoch, const chain::RootTrustBaseV1& tb) { + trust_bases_[epoch] = tb; + } + +private: + std::map trust_bases_; +}; + +} // namespace unicity::test diff --git a/test/common/test_chainstate_manager.hpp b/test/common/test_chainstate_manager.hpp index 7e11572..f84e3f0 100644 --- a/test/common/test_chainstate_manager.hpp +++ b/test/common/test_chainstate_manager.hpp @@ -7,6 +7,8 @@ #include "chain/chainstate_manager.hpp" #include "chain/chainparams.hpp" #include "chain/randomx_pow.hpp" +#include "mock_trust_base_manager.hpp" +#include namespace unicity { namespace test { @@ -26,10 +28,17 @@ namespace test { class TestChainstateManager : public validation::ChainstateManager { public: /** - * Constructor - same as ChainstateManager + * Constructor - initializes its own MockTrustBaseManager */ explicit TestChainstateManager(const chain::ChainParams& params) - : ChainstateManager(params) + : TestChainstateManager(params, std::make_unique()) + {} + + /** + * Constructor - same as ChainstateManager + */ + TestChainstateManager(const chain::ChainParams& params, chain::TrustBaseManager& tbm) + : ChainstateManager(params, tbm) , bypass_pow_validation_(true) , bypass_contextual_validation_(true) { @@ -64,6 +73,8 @@ class TestChainstateManager : public validation::ChainstateManager { bypass_contextual_validation_ = bypass; } + MockTrustBaseManager* GetMockTBM() const { return owned_tbm_.get(); } + protected: /** * Override CheckProofOfWork to conditionally bypass validation @@ -83,17 +94,13 @@ class TestChainstateManager : public validation::ChainstateManager { return ChainstateManager::CheckProofOfWork(header, mode); } -private: - bool bypass_pow_validation_; - bool bypass_contextual_validation_; - - /** - * Override CheckBlockHeaderWrapper to conditionally bypass validation - * - * When bypass_pow_validation_ is true (default), returns true without checking. - * When false, calls real ChainstateManager::CheckBlockHeaderWrapper for actual validation. - * This is ONLY safe for unit tests where we control all inputs. - */ + /** + * Override CheckBlockHeaderWrapper to conditionally bypass validation + * + * When bypass_pow_validation_ is true (default), returns true without checking. + * When false, calls real ChainstateManager::CheckBlockHeaderWrapper for actual validation. + * This is ONLY safe for unit tests where we control all inputs. + */ bool CheckBlockHeaderWrapper(const CBlockHeader& header, validation::ValidationState& state) const override { @@ -121,6 +128,22 @@ class TestChainstateManager : public validation::ChainstateManager { } return ChainstateManager::ContextualCheckBlockHeaderWrapper(header, pindexPrev, adjusted_time, state); } + +private: + TestChainstateManager(const chain::ChainParams& params, std::unique_ptr tbm) + : ChainstateManager(params, *tbm) + , owned_tbm_(std::move(tbm)) + , bypass_pow_validation_(true) + , bypass_contextual_validation_(true) + { + if (params.GetChainType() == chain::ChainType::REGTEST) { + TestSetSkipPoWChecks(true); + } + } + + std::unique_ptr owned_tbm_; + bool bypass_pow_validation_; + bool bypass_contextual_validation_; }; } // namespace test diff --git a/test/common/test_trust_base_data.hpp b/test/common/test_trust_base_data.hpp new file mode 100644 index 0000000..e0afced --- /dev/null +++ b/test/common/test_trust_base_data.hpp @@ -0,0 +1,19 @@ +#pragma once + +#include + +namespace unicity::test { + +inline constexpr std::string_view epoch1_cbor = "d998588a010301018383783531365569753248416b776456513347774234586f4b554c624b733174504b7671386e6664385941594450415a395341766d61427663582102d23be5a4932867088fd6fbf12cbc2744ff1393ec1cc7eb47addcec6d908a9e710183783531365569753248416d476b734c324674316d3156506a3774326463484c59343661686a6d726245483655316873486d346e72473268582103411c106956451afa8a596f9d3ef443c073f6d6d40c5d4539fa3e140465301f3e0183783531365569753248416d514d7052575343736b576773486e41507148436355487165396848533353787461337a6941737a37795531685821027f0fe7ba12dc544fe9d4f8bdc8f71de106b97d234e769c87a938cc7c5f9587570103f6f6f6a3783531365569753248416b776456513347774234586f4b554c624b733174504b7671386e6664385941594450415a395341766d61427663584104859560f3c63293f77e9ea02ece280ae1b16580718e965928d895cbb47c21c15a68e1b2ba4d3ac9ca2816dd1b5671d3b98ba1e9a140509b234293d1fa3f34ee00783531365569753248416d476b734c324674316d3156506a3774326463484c59343661686a6d726245483655316873486d346e7247326858416665a4b0b73aa3e1e1d12d0a9479058f89c9234baa0a596ac16e7818ce4127d97d3920103e998e884f16d7975e283411917d8d12976a868fdbbf2d9c986b1b1200783531365569753248416d514d7052575343736b576773486e41507148436355487165396848533353787461337a6941737a377955316858413c8842ac4d163000858fbb4577a7b3d23f8ec80bfe7a6bd41c6599bc71d1412f2d58c7598f352618c45a049bfa006ebaa095148b05ee7fb1fba9610a6953daf400"; +inline constexpr std::string_view epoch1_hash = "7031fb4035cb0085145daeeaba46c918165ed41ad715efe6e4570d3c3ef4d7ad"; + +inline constexpr std::string_view epoch2_cbor = "d998588a01030218c88483783531365569753248416b776456513347774234586f4b554c624b733174504b7671386e6664385941594450415a395341766d61427663582102d23be5a4932867088fd6fbf12cbc2744ff1393ec1cc7eb47addcec6d908a9e710183783531365569753248416d34504469796269337572455a4245796f324d4e423571636758675633693535487576486b784777746a677959582102ca80e240b1b1b812f4042104fdbb341f857e36eaca3985f4bb17f1e61c29bed20183783531365569753248416d476b734c324674316d3156506a3774326463484c59343661686a6d726245483655316873486d346e72473268582103411c106956451afa8a596f9d3ef443c073f6d6d40c5d4539fa3e140465301f3e0183783531365569753248416d514d7052575343736b576773486e41507148436355487165396848533353787461337a6941737a37795531685821027f0fe7ba12dc544fe9d4f8bdc8f71de106b97d234e769c87a938cc7c5f9587570103f6f658207031fb4035cb0085145daeeaba46c918165ed41ad715efe6e4570d3c3ef4d7ada3783531365569753248416b776456513347774234586f4b554c624b733174504b7671386e6664385941594450415a395341766d6142766358415498eba796393fb95ce800d643f194c5e5cf2159f073d89b7c609156ce3c85855e1bc4ff30bd4d3d04f453095d09684f75442c4620eb950f99a9221dd2e4f90000783531365569753248416d476b734c324674316d3156506a3774326463484c59343661686a6d726245483655316873486d346e72473268584129ca7108bbeb69d2193b240eb5a064e6173fe080a3686e666b856aa9fadab13f2812bc0d95040ec3d96d557bebb4e42bcee6fd505de74bbc0f343d9211feb97e00783531365569753248416d514d7052575343736b576773486e41507148436355487165396848533353787461337a6941737a377955316858411cdfafef98ecfef17c8e5c692aa653c4e4e5749409ee3149b1d5745891b31526273d5b8e02d0b0775e0c26f356d36cc2780b4e562114306d268c21c9f54287c400"; +inline constexpr std::string_view epoch2_hash = "4146838c546ef46c6dc49073a9c17b0815ba8cbd76aea57edfd2cc667f6ff940"; + +inline constexpr std::string_view epoch3_cbor = "d998588a01030319012c8583783531365569753248416b776456513347774234586f4b554c624b733174504b7671386e6664385941594450415a395341766d61427663582102d23be5a4932867088fd6fbf12cbc2744ff1393ec1cc7eb47addcec6d908a9e710183783531365569753248416d34504469796269337572455a4245796f324d4e423571636758675633693535487576486b784777746a677959582102ca80e240b1b1b812f4042104fdbb341f857e36eaca3985f4bb17f1e61c29bed20183783531365569753248416d415a315037766b663475547169394d4d32446e7a3744766a43445a43625958416441704832383676676d414158210364c5ff41407afad8bbc5af28ed872691fcb0ded759c3447779166aa5ba9836640183783531365569753248416d476b734c324674316d3156506a3774326463484c59343661686a6d726245483655316873486d346e72473268582103411c106956451afa8a596f9d3ef443c073f6d6d40c5d4539fa3e140465301f3e0183783531365569753248416d514d7052575343736b576773486e41507148436355487165396848533353787461337a6941737a37795531685821027f0fe7ba12dc544fe9d4f8bdc8f71de106b97d234e769c87a938cc7c5f9587570104f6f658204146838c546ef46c6dc49073a9c17b0815ba8cbd76aea57edfd2cc667f6ff940a4783531365569753248416b776456513347774234586f4b554c624b733174504b7671386e6664385941594450415a395341766d614276635841880485ef574fc5af03623971fbc8e29b6a429a99f97a64bb4f3d80616ab9d1cc7b95596f97f80f498cc69356397fcd4336be6fafdb82bff4b1673902a278be9600783531365569753248416d34504469796269337572455a4245796f324d4e423571636758675633693535487576486b784777746a67795958411786758b8a9bee9712f82453b88f9fa3873f2df6977b31ff825f96d2c2c4d3bd5f8d912e9581986dada7a52d8fec2ce1c89b055b0cf8cd8d3634b6b9a77201d401783531365569753248416d476b734c324674316d3156506a3774326463484c59343661686a6d726245483655316873486d346e72473268584153dce1afc7e823907790b59fd652d7b9874f596aa845ec1c0100152f77f5681c5e11b3195f9dcabf87ae9ac150e65f89f2dab9b8a5ef97525f0763c30357584000783531365569753248416d514d7052575343736b576773486e41507148436355487165396848533353787461337a6941737a3779553168584163d7dab8ac1fbe819d1eee86da56c317ef2042eba02c20df061ea4f2451993a803207245fa23f4bbfbf490d69006df66fd5f485119a3be346a49f32c5197b44000"; +inline constexpr std::string_view epoch3_hash = "84d97b924190843dac180e91fd4dfb13f575d66ae64a6a4bfa3a4744187134c5"; + +inline constexpr std::string_view epoch3_invalid_cbor = "d998588a01030319012c8583783531365569753248416b776456513347774234586f4b554c624b733174504b7671386e6664385941594450415a395341766d61427663582102d23be5a4932867088fd6fbf12cbc2744ff1393ec1cc7eb47addcec6d908a9e710183783531365569753248416d34504469796269337572455a4245796f324d4e423571636758675633693535487576486b784777746a677959582102ca80e240b1b1b812f4042104fdbb341f857e36eaca3985f4bb17f1e61c29bed20183783531365569753248416d415a315037766b663475547169394d4d32446e7a3744766a43445a43625958416441704832383676676d414158210364c5ff41407afad8bbc5af28ed872691fcb0ded759c3447779166aa5ba9836640183783531365569753248416d476b734c324674316d3156506a3774326463484c59343661686a6d726245483655316873486d346e72473268582103411c106956451afa8a596f9d3ef443c073f6d6d40c5d4539fa3e140465301f3e0183783531365569753248416d514d7052575343736b576773486e41507148436355487165396848533353787461337a6941737a37795531685821027f0fe7ba12dc544fe9d4f8bdc8f71de106b97d234e769c87a938cc7c5f9587570104f6f658204146838c546ef46c6dc49073a9c17b0815ba8cbd76aea57edfd2cc667f6ff940a1783531365569753248416d514d7052575343736b576773486e41507148436355487165396848533353787461337a6941737a3779553168584163d7dab8ac1fbe819d1eee86da56c317ef2042eba02c20df061ea4f2451993a803207245fa23f4bbfbf490d69006df66fd5f485119a3be346a49f32c5197b44000"; +inline constexpr std::string_view epoch3_invalid_hash = "edb2f97abff603d3d1c9e2b9197ac04dd2e4f2eb43e503f1db33cd0b23dce0d7"; + +} // namespace unicity::test \ No newline at end of file diff --git a/test/common/test_util.hpp b/test/common/test_util.hpp new file mode 100644 index 0000000..304e575 --- /dev/null +++ b/test/common/test_util.hpp @@ -0,0 +1,46 @@ +#pragma once + +#include +#include +#include + +#include + +namespace unicity::test { + +struct TempDir { + std::filesystem::path path; + + explicit TempDir(const std::string& prefix) { + std::filesystem::path temp_base = std::filesystem::temp_directory_path() / (prefix + "_XXXXXX"); + char dir_template[1024]; + std::strncpy(dir_template, temp_base.string().c_str(), sizeof(dir_template)); + if (mkdtemp(dir_template)) { + path = dir_template; + } else { + // Fallback if mkdtemp fails + path = std::filesystem::temp_directory_path() / prefix; + std::filesystem::create_directories(path); + } + } + + ~TempDir() { + std::error_code ec; + std::filesystem::remove_all(path, ec); + } + + // Prevent copying + TempDir(const TempDir&) = delete; + TempDir& operator=(const TempDir&) = delete; + + // Allow moving + TempDir(TempDir&& other) noexcept : path(std::move(other.path)) {} + TempDir& operator=(TempDir&& other) noexcept { + path = std::move(other.path); + return *this; + } + + operator const std::filesystem::path&() const { return path; } +}; + +} // namespace unicity::test diff --git a/test/functional/consensus_asert_difficulty.py b/test/functional/consensus_asert_difficulty.py index 89e80bd..d58891e 100644 --- a/test/functional/consensus_asert_difficulty.py +++ b/test/functional/consensus_asert_difficulty.py @@ -30,16 +30,16 @@ def hex_to_le32(hex_str: str) -> bytes: return b[::-1] -def hex_to_20(hex_str: str) -> bytes: +def hex_to_32(hex_str: str) -> bytes: b = bytes.fromhex(hex_str) - if len(b) != 20: - raise ValueError("address must be 20 bytes (40 hex chars)") + if len(b) != 32: + raise ValueError("hash must be 32 bytes (64 hex chars)") return b def build_header_hex(prev_hash_hex_be: str, n_time: int, n_bits_u32: int, n_nonce: int = 0, version: int = 1, miner_addr_hex: str | None = None) -> str: prev_le = hex_to_le32(prev_hash_hex_be) - miner = hex_to_20(miner_addr_hex) if miner_addr_hex else (b"\x00" * 20) + miner = hex_to_32(miner_addr_hex) if miner_addr_hex else (b"\x00" * 32) rx = b"\x00" * 32 header = b"".join([ u32le(version), @@ -50,7 +50,7 @@ def build_header_hex(prev_hash_hex_be: str, n_time: int, n_bits_u32: int, n_nonc u32le(n_nonce), rx, ]) - assert len(header) == 100 + assert len(header) == 112 return header.hex() diff --git a/test/functional/consensus_asert_difficulty_testnet.py b/test/functional/consensus_asert_difficulty_testnet.py index 6347bff..fff3343 100644 --- a/test/functional/consensus_asert_difficulty_testnet.py +++ b/test/functional/consensus_asert_difficulty_testnet.py @@ -41,14 +41,13 @@ def hex_to_le32(hex_str: str) -> bytes: def build_header_hex(prev_hash_hex_be: str, n_time: int, n_bits_u32: int, n_nonce: int = 0, version: int = 1) -> str: - """Build a 100-byte Unicity header for testing. + """Build a 112-byte Unicity header for testing. - Layout: nVersion(4) | prevhash(32 LE) | miner(20) | nTime(4 LE) | + Layout: nVersion(4) | prevhash(32 LE) | payloadRoot(32) | nTime(4 LE) | nBits(4 LE) | nNonce(4 LE) | hashRandomX(32) """ prev_le = hex_to_le32(prev_hash_hex_be) - miner = b"\x00" * 20 - rx = b"\x00" * 32 + miner = b"\x00" * 32 header = b"".join([ u32le(version), prev_le, @@ -56,9 +55,9 @@ def build_header_hex(prev_hash_hex_be: str, n_time: int, n_bits_u32: int, n_nonc u32le(n_time), u32le(n_bits_u32), u32le(n_nonce), - rx, + b"\x00" * 32, ]) - assert len(header) == 100 + assert len(header) == 112 return header.hex() @@ -70,7 +69,9 @@ def main(): node = None try: - node = TestNode(0, test_dir / "node0", binary_path, extra_args=["--nolisten"], chain="testnet") + # Start node on testnet + # Disable BFT integration (--bftaddr="") + node = TestNode(0, test_dir / "node0", binary_path, extra_args=["--nolisten", "--bftaddr="], chain="testnet") node.start() # Genesis diff --git a/test/functional/consensus_difficulty_regtest.py b/test/functional/consensus_difficulty_regtest.py index 1159760..22b0a40 100644 --- a/test/functional/consensus_difficulty_regtest.py +++ b/test/functional/consensus_difficulty_regtest.py @@ -32,7 +32,7 @@ def hex_to_le32(hex_str: str) -> bytes: def build_header_hex(prev_hash_hex_be: str, n_time: int, n_bits_u32: int, n_nonce: int = 0, version: int = 1) -> str: prev_le = hex_to_le32(prev_hash_hex_be) - miner = b"\x00" * 20 + miner = b"\x00" * 32 rx = b"\x00" * 32 header = b"".join([ u32le(version), @@ -43,7 +43,7 @@ def build_header_hex(prev_hash_hex_be: str, n_time: int, n_bits_u32: int, n_nonc u32le(n_nonce), rx, ]) - assert len(header) == 100 + assert len(header) == 112 return header.hex() diff --git a/test/functional/consensus_timestamp_bounds.py b/test/functional/consensus_timestamp_bounds.py index 6284751..5547e10 100644 --- a/test/functional/consensus_timestamp_bounds.py +++ b/test/functional/consensus_timestamp_bounds.py @@ -32,17 +32,17 @@ def hex_to_le32(hex_str: str) -> bytes: return b[::-1] -def hex_to_20(hex_str: str) -> bytes: +def hex_to_32(hex_str: str) -> bytes: b = bytes.fromhex(hex_str) - if len(b) != 20: - raise ValueError("address must be 20 bytes (40 hex chars)") + if len(b) != 32: + raise ValueError("hash must be 32 bytes (64 hex chars)") return b def build_header_hex(prev_hash_hex_be: str, n_time: int, n_bits_u32: int, n_nonce: int = 0, version: int = 1, miner_addr_hex: str | None = None) -> str: - # Fields: nVersion(4) | prevhash(32 LE raw) | minerAddress(20) | nTime(4 LE) | nBits(4 LE) | nNonce(4 LE) | hashRandomX(32) + # Fields: nVersion(4) | prevhash(32 LE raw) | payloadRoot(32) | nTime(4 LE) | nBits(4 LE) | nNonce(4 LE) | hashRandomX(32) prev_le = hex_to_le32(prev_hash_hex_be) - miner = hex_to_20(miner_addr_hex) if miner_addr_hex else (b"\x00" * 20) + miner = hex_to_32(miner_addr_hex) if miner_addr_hex else (b"\x00" * 32) rx = b"\x00" * 32 header = b"".join([ u32le(version), @@ -53,7 +53,7 @@ def build_header_hex(prev_hash_hex_be: str, n_time: int, n_bits_u32: int, n_nonc u32le(n_nonce), rx, ]) - assert len(header) == 100 + assert len(header) == 112 return header.hex() diff --git a/test/functional/docker_eclipse/run_eclipse_tests.py b/test/functional/docker_eclipse/run_eclipse_tests.py index ebc983a..e6e7ea7 100755 --- a/test/functional/docker_eclipse/run_eclipse_tests.py +++ b/test/functional/docker_eclipse/run_eclipse_tests.py @@ -260,8 +260,8 @@ def create_version(): time.sleep(0.5) # Send invalid headers (all zeros = invalid PoW) - # Header is 100 bytes in Unicity: 4+32+20+4+4+4+32 - fake_header = b"\\x00" * 100 # Block header with invalid PoW + # Header is 112 bytes in Unicity: 4+32+32+4+4+4+32 + fake_header = b"\\x00" * 112 # Block header with invalid PoW headers_payload = b"\\x01" + fake_header # count=1 + header s.sendall(create_msg("headers", headers_payload)) @@ -859,10 +859,10 @@ def create_version(): def create_small_headers(): # Create a minimal headers message with 1 header - # Header structure: version(4) + prevhash(32) + randomx(20) + timestamp(4) + bits(4) + nonce(4) + hash(32) = 100 bytes + # Header structure: version(4) + prevhash(32) + payloadRoot(32) + timestamp(4) + bits(4) + nonce(4) + hash(32) = 112 bytes header = b"\\x01\\x00\\x00\\x00" # version header += b"\\x00" * 32 # prevhash (genesis) - header += b"\\x00" * 20 # randomx hash + header += b"\\x00" * 32 # payloadRoot header += struct.pack(" BuildHeaderChain(const uint256& startHash, uint32_t startTime, - size_t count, uint32_t nonceStart = 1000) { - std::vector headers; - headers.reserve(count); - - uint256 prevHash = startHash; - for (size_t i = 0; i < count; i++) { - CBlockHeader h = CreateTestHeader(prevHash, startTime + 120 * (i + 1), nonceStart + i); - prevHash = h.GetHash(); - headers.push_back(h); - } - return headers; -} - -// Helper to accept headers and add to candidates -bool AcceptHeadersToChain(TestChainstateManager& chainstate, - const std::vector& headers) { - for (const auto& header : headers) { - ValidationState st; - chain::CBlockIndex* pindex = chainstate.AcceptBlockHeader(header, st); - if (!pindex) return false; - chainstate.TryAddBlockIndexCandidate(pindex); - } - return true; -} - -} // anonymous namespace - -TEST_CASE("E2E: Header chain advances tip correctly", "[e2e][chain][critical]") { - auto params = ChainParams::CreateRegTest(); - TestChainstateManager chainstate(*params); - chainstate.Initialize(params->GenesisBlock()); - chainstate.SetBypassPOWValidation(true); - const auto& genesis = params->GenesisBlock(); - - SECTION("Single header advances tip by 1") { - REQUIRE(chainstate.GetChainHeight() == 0); - - CBlockHeader h1 = CreateTestHeader(genesis.GetHash(), genesis.nTime + 120, 1000); - - ValidationState st; - chain::CBlockIndex* pindex = chainstate.AcceptBlockHeader(h1, st); - REQUIRE(pindex != nullptr); - chainstate.TryAddBlockIndexCandidate(pindex); - - REQUIRE(chainstate.ActivateBestChain(nullptr)); - - // E2E verification: chain height advanced - REQUIRE(chainstate.GetChainHeight() == 1); - // E2E verification: tip is correct block - REQUIRE(chainstate.GetTip()->GetBlockHash() == h1.GetHash()); - } - - SECTION("Chain of 10 headers advances tip to height 10") { - REQUIRE(chainstate.GetChainHeight() == 0); - - auto headers = BuildHeaderChain(genesis.GetHash(), genesis.nTime, 10); - REQUIRE(AcceptHeadersToChain(chainstate, headers)); - REQUIRE(chainstate.ActivateBestChain(nullptr)); - - // E2E verification: full chain height - REQUIRE(chainstate.GetChainHeight() == 10); - // E2E verification: tip is last header - REQUIRE(chainstate.GetTip()->GetBlockHash() == headers.back().GetHash()); - } - - SECTION("Chain of 100 headers advances tip to height 100") { - REQUIRE(chainstate.GetChainHeight() == 0); - - auto headers = BuildHeaderChain(genesis.GetHash(), genesis.nTime, 100); - REQUIRE(AcceptHeadersToChain(chainstate, headers)); - REQUIRE(chainstate.ActivateBestChain(nullptr)); - - // E2E verification: full chain height - REQUIRE(chainstate.GetChainHeight() == 100); - // E2E verification: tip is last header - REQUIRE(chainstate.GetTip()->GetBlockHash() == headers.back().GetHash()); - } - - SECTION("Headers in batches result in correct final height") { - REQUIRE(chainstate.GetChainHeight() == 0); - - // Batch 1: first 5 headers - auto batch1 = BuildHeaderChain(genesis.GetHash(), genesis.nTime, 5, 1000); - REQUIRE(AcceptHeadersToChain(chainstate, batch1)); - REQUIRE(chainstate.ActivateBestChain(nullptr)); - REQUIRE(chainstate.GetChainHeight() == 5); - - // Batch 2: next 5 headers - auto batch2 = BuildHeaderChain(batch1.back().GetHash(), genesis.nTime + 600, 5, 2000); - REQUIRE(AcceptHeadersToChain(chainstate, batch2)); - REQUIRE(chainstate.ActivateBestChain(nullptr)); - - // E2E verification: combined height - REQUIRE(chainstate.GetChainHeight() == 10); - REQUIRE(chainstate.GetTip()->GetBlockHash() == batch2.back().GetHash()); - } -} - -TEST_CASE("E2E: Reorg results in correct final chain", "[e2e][chain][reorg][critical]") { - auto params = ChainParams::CreateRegTest(); - TestChainstateManager chainstate(*params); - chainstate.Initialize(params->GenesisBlock()); - chainstate.SetBypassPOWValidation(true); - const auto& genesis = params->GenesisBlock(); - - SECTION("Longer chain wins reorg") { - // Build initial chain: genesis -> A -> B -> C (height 3) - auto chainA = BuildHeaderChain(genesis.GetHash(), genesis.nTime, 3, 1000); - REQUIRE(AcceptHeadersToChain(chainstate, chainA)); - REQUIRE(chainstate.ActivateBestChain(nullptr)); - - REQUIRE(chainstate.GetChainHeight() == 3); - uint256 oldTip = chainstate.GetTip()->GetBlockHash(); - REQUIRE(oldTip == chainA.back().GetHash()); - - // Build competing chain: genesis -> A' -> B' -> C' -> D' (height 4) - auto chainB = BuildHeaderChain(genesis.GetHash(), genesis.nTime, 4, 2000); - REQUIRE(AcceptHeadersToChain(chainstate, chainB)); - REQUIRE(chainstate.ActivateBestChain(nullptr)); - - // E2E verification: longer chain wins - REQUIRE(chainstate.GetChainHeight() == 4); - REQUIRE(chainstate.GetTip()->GetBlockHash() == chainB.back().GetHash()); - // E2E verification: old tip is NOT the current tip - REQUIRE(chainstate.GetTip()->GetBlockHash() != oldTip); - } - - SECTION("Same-length chain does NOT cause reorg (first-seen wins)") { - // Build initial chain: height 3 - auto chainA = BuildHeaderChain(genesis.GetHash(), genesis.nTime, 3, 1000); - REQUIRE(AcceptHeadersToChain(chainstate, chainA)); - REQUIRE(chainstate.ActivateBestChain(nullptr)); - - uint256 originalTip = chainstate.GetTip()->GetBlockHash(); - REQUIRE(chainstate.GetChainHeight() == 3); - - // Build competing chain: also height 3 (different blocks) - auto chainB = BuildHeaderChain(genesis.GetHash(), genesis.nTime, 3, 2000); - REQUIRE(AcceptHeadersToChain(chainstate, chainB)); - REQUIRE(chainstate.ActivateBestChain(nullptr)); - - // E2E verification: original chain retained (first-seen wins for same height) - REQUIRE(chainstate.GetChainHeight() == 3); - REQUIRE(chainstate.GetTip()->GetBlockHash() == originalTip); - } - - SECTION("Deep reorg replaces entire chain") { - // Build initial chain: height 10 - auto chainA = BuildHeaderChain(genesis.GetHash(), genesis.nTime, 10, 1000); - REQUIRE(AcceptHeadersToChain(chainstate, chainA)); - REQUIRE(chainstate.ActivateBestChain(nullptr)); - REQUIRE(chainstate.GetChainHeight() == 10); - - // Build competing chain: height 15 (completely different from genesis) - auto chainB = BuildHeaderChain(genesis.GetHash(), genesis.nTime, 15, 2000); - REQUIRE(AcceptHeadersToChain(chainstate, chainB)); - REQUIRE(chainstate.ActivateBestChain(nullptr)); - - // E2E verification: new chain is active - REQUIRE(chainstate.GetChainHeight() == 15); - REQUIRE(chainstate.GetTip()->GetBlockHash() == chainB.back().GetHash()); - - // E2E verification: verify chain structure by walking back - const chain::CBlockIndex* tip = chainstate.LookupBlockIndex(chainB.back().GetHash()); - REQUIRE(tip != nullptr); - REQUIRE(tip->nHeight == 15); - - // Walk back and verify all blocks are from chainB - const chain::CBlockIndex* current = tip; - for (int i = 14; i >= 0; i--) { - REQUIRE(current->pprev != nullptr); - current = current->pprev; - REQUIRE(current->nHeight == i); - if (i > 0) { - REQUIRE(current->GetBlockHash() == chainB[i-1].GetHash()); - } - } - // Should end at genesis - REQUIRE(current->GetBlockHash() == genesis.GetHash()); - } - - SECTION("Partial reorg at fork point") { - // Build common prefix: genesis -> A -> B (height 2) - auto common = BuildHeaderChain(genesis.GetHash(), genesis.nTime, 2, 1000); - REQUIRE(AcceptHeadersToChain(chainstate, common)); - REQUIRE(chainstate.ActivateBestChain(nullptr)); - REQUIRE(chainstate.GetChainHeight() == 2); - - // Extend with chain1: B -> C1 -> D1 (height 4) - auto chain1 = BuildHeaderChain(common.back().GetHash(), genesis.nTime + 240, 2, 3000); - REQUIRE(AcceptHeadersToChain(chainstate, chain1)); - REQUIRE(chainstate.ActivateBestChain(nullptr)); - REQUIRE(chainstate.GetChainHeight() == 4); - uint256 chain1Tip = chainstate.GetTip()->GetBlockHash(); - - // Competing fork from B: B -> C2 -> D2 -> E2 (height 5) - auto chain2 = BuildHeaderChain(common.back().GetHash(), genesis.nTime + 240, 3, 4000); - REQUIRE(AcceptHeadersToChain(chainstate, chain2)); - REQUIRE(chainstate.ActivateBestChain(nullptr)); - - // E2E verification: longer fork wins - REQUIRE(chainstate.GetChainHeight() == 5); - REQUIRE(chainstate.GetTip()->GetBlockHash() == chain2.back().GetHash()); - REQUIRE(chainstate.GetTip()->GetBlockHash() != chain1Tip); - - // E2E verification: common prefix is still in chain - REQUIRE(chainstate.LookupBlockIndex(common[0].GetHash()) != nullptr); - REQUIRE(chainstate.LookupBlockIndex(common[1].GetHash()) != nullptr); - } -} - -TEST_CASE("E2E: Chain walk from tip to genesis is valid", "[e2e][chain][structure]") { - auto params = ChainParams::CreateRegTest(); - TestChainstateManager chainstate(*params); - chainstate.Initialize(params->GenesisBlock()); - chainstate.SetBypassPOWValidation(true); - const auto& genesis = params->GenesisBlock(); - - SECTION("50-block chain has valid structure") { - const int CHAIN_LENGTH = 50; - auto headers = BuildHeaderChain(genesis.GetHash(), genesis.nTime, CHAIN_LENGTH); - REQUIRE(AcceptHeadersToChain(chainstate, headers)); - REQUIRE(chainstate.ActivateBestChain(nullptr)); - - REQUIRE(chainstate.GetChainHeight() == CHAIN_LENGTH); - - // Walk from tip to genesis, verify structure - const chain::CBlockIndex* current = chainstate.LookupBlockIndex(chainstate.GetTip()->GetBlockHash()); - REQUIRE(current != nullptr); - - int expectedHeight = CHAIN_LENGTH; - while (current != nullptr) { - // Verify height is correct - REQUIRE(current->nHeight == expectedHeight); - - // Verify hash matches (except genesis) - if (expectedHeight > 0) { - REQUIRE(current->GetBlockHash() == headers[expectedHeight - 1].GetHash()); - } else { - REQUIRE(current->GetBlockHash() == genesis.GetHash()); - } - - // Verify pprev relationship - if (expectedHeight > 0) { - REQUIRE(current->pprev != nullptr); - REQUIRE(current->pprev->nHeight == expectedHeight - 1); - } - - current = current->pprev; - expectedHeight--; - } - - // Should have walked exactly to before genesis - REQUIRE(expectedHeight == -1); - } -} - -TEST_CASE("E2E: Multiple competing forks resolve correctly", "[e2e][chain][fork]") { - auto params = ChainParams::CreateRegTest(); - TestChainstateManager chainstate(*params); - chainstate.Initialize(params->GenesisBlock()); - chainstate.SetBypassPOWValidation(true); - const auto& genesis = params->GenesisBlock(); - - SECTION("Three competing forks - longest wins") { - // Fork 1: height 5 - auto fork1 = BuildHeaderChain(genesis.GetHash(), genesis.nTime, 5, 1000); - REQUIRE(AcceptHeadersToChain(chainstate, fork1)); - REQUIRE(chainstate.ActivateBestChain(nullptr)); - REQUIRE(chainstate.GetChainHeight() == 5); - - // Fork 2: height 7 (should become tip) - auto fork2 = BuildHeaderChain(genesis.GetHash(), genesis.nTime, 7, 2000); - REQUIRE(AcceptHeadersToChain(chainstate, fork2)); - REQUIRE(chainstate.ActivateBestChain(nullptr)); - REQUIRE(chainstate.GetChainHeight() == 7); - REQUIRE(chainstate.GetTip()->GetBlockHash() == fork2.back().GetHash()); - - // Fork 3: height 6 (should NOT become tip) - auto fork3 = BuildHeaderChain(genesis.GetHash(), genesis.nTime, 6, 3000); - REQUIRE(AcceptHeadersToChain(chainstate, fork3)); - REQUIRE(chainstate.ActivateBestChain(nullptr)); - - // E2E verification: fork2 (longest) is still tip - REQUIRE(chainstate.GetChainHeight() == 7); - REQUIRE(chainstate.GetTip()->GetBlockHash() == fork2.back().GetHash()); - } - - SECTION("Fork extension makes shorter fork win") { - // Initial: fork1 at height 5, fork2 at height 3 - auto fork1 = BuildHeaderChain(genesis.GetHash(), genesis.nTime, 5, 1000); - auto fork2 = BuildHeaderChain(genesis.GetHash(), genesis.nTime, 3, 2000); - - REQUIRE(AcceptHeadersToChain(chainstate, fork1)); - REQUIRE(AcceptHeadersToChain(chainstate, fork2)); - REQUIRE(chainstate.ActivateBestChain(nullptr)); - - REQUIRE(chainstate.GetChainHeight() == 5); - REQUIRE(chainstate.GetTip()->GetBlockHash() == fork1.back().GetHash()); - - // Extend fork2 to height 7 - auto fork2_ext = BuildHeaderChain(fork2.back().GetHash(), genesis.nTime + 360, 4, 3000); - REQUIRE(AcceptHeadersToChain(chainstate, fork2_ext)); - REQUIRE(chainstate.ActivateBestChain(nullptr)); - - // E2E verification: extended fork2 is now tip - REQUIRE(chainstate.GetChainHeight() == 7); - REQUIRE(chainstate.GetTip()->GetBlockHash() == fork2_ext.back().GetHash()); - } -} - -// Note: Test "E2E: Headers received out of order still result in correct chain" -// was removed because orphan pool infrastructure was removed. Out-of-order headers -// are now discarded and trigger GETHEADERS requests to fill the gap. +// End-to-End Chain State Verification Tests +// +// These tests verify FINAL OBSERVABLE BEHAVIOR, not intermediate state. +// Every test checks GetChainHeight() and GetBestBlockHash() to ensure +// chain operations actually result in correct tip advancement. + +#include "catch_amalgamated.hpp" +#include "common/test_chainstate_manager.hpp" +#include "chain/validation.hpp" +#include "chain/chainparams.hpp" +#include "chain/block.hpp" + +using namespace unicity; +using namespace unicity::test; +using namespace unicity::chain; +using unicity::validation::ValidationState; + +namespace { + +CBlockHeader CreateTestHeader(const uint256& prevHash, uint32_t nTime, uint32_t nNonce = 12345) { + CBlockHeader header; + header.nVersion = 1; + header.hashPrevBlock = prevHash; + header.payloadRoot.SetNull(); + header.nTime = nTime; + header.nBits = 0x207fffff; + header.nNonce = nNonce; + header.hashRandomX.SetNull(); + return header; +} + +// Helper to build a chain of headers +std::vector BuildHeaderChain(const uint256& startHash, uint32_t startTime, + size_t count, uint32_t nonceStart = 1000) { + std::vector headers; + headers.reserve(count); + + uint256 prevHash = startHash; + for (size_t i = 0; i < count; i++) { + CBlockHeader h = CreateTestHeader(prevHash, startTime + 120 * (i + 1), nonceStart + i); + prevHash = h.GetHash(); + headers.push_back(h); + } + return headers; +} + +// Helper to accept headers and add to candidates +bool AcceptHeadersToChain(TestChainstateManager& chainstate, + const std::vector& headers) { + for (const auto& header : headers) { + ValidationState st; + chain::CBlockIndex* pindex = chainstate.AcceptBlockHeader(header, st); + if (!pindex) return false; + chainstate.TryAddBlockIndexCandidate(pindex); + } + return true; +} + +} // anonymous namespace + +TEST_CASE("E2E: Header chain advances tip correctly", "[e2e][chain][critical]") { + auto params = ChainParams::CreateRegTest(); + TestChainstateManager chainstate(*params); + chainstate.Initialize(params->GenesisBlock()); + chainstate.SetBypassPOWValidation(true); + const auto& genesis = params->GenesisBlock(); + + SECTION("Single header advances tip by 1") { + REQUIRE(chainstate.GetChainHeight() == 0); + + CBlockHeader h1 = CreateTestHeader(genesis.GetHash(), genesis.nTime + 120, 1000); + + ValidationState st; + chain::CBlockIndex* pindex = chainstate.AcceptBlockHeader(h1, st); + REQUIRE(pindex != nullptr); + chainstate.TryAddBlockIndexCandidate(pindex); + + REQUIRE(chainstate.ActivateBestChain(nullptr)); + + // E2E verification: chain height advanced + REQUIRE(chainstate.GetChainHeight() == 1); + // E2E verification: tip is correct block + REQUIRE(chainstate.GetTip()->GetBlockHash() == h1.GetHash()); + } + + SECTION("Chain of 10 headers advances tip to height 10") { + REQUIRE(chainstate.GetChainHeight() == 0); + + auto headers = BuildHeaderChain(genesis.GetHash(), genesis.nTime, 10); + REQUIRE(AcceptHeadersToChain(chainstate, headers)); + REQUIRE(chainstate.ActivateBestChain(nullptr)); + + // E2E verification: full chain height + REQUIRE(chainstate.GetChainHeight() == 10); + // E2E verification: tip is last header + REQUIRE(chainstate.GetTip()->GetBlockHash() == headers.back().GetHash()); + } + + SECTION("Chain of 100 headers advances tip to height 100") { + REQUIRE(chainstate.GetChainHeight() == 0); + + auto headers = BuildHeaderChain(genesis.GetHash(), genesis.nTime, 100); + REQUIRE(AcceptHeadersToChain(chainstate, headers)); + REQUIRE(chainstate.ActivateBestChain(nullptr)); + + // E2E verification: full chain height + REQUIRE(chainstate.GetChainHeight() == 100); + // E2E verification: tip is last header + REQUIRE(chainstate.GetTip()->GetBlockHash() == headers.back().GetHash()); + } + + SECTION("Headers in batches result in correct final height") { + REQUIRE(chainstate.GetChainHeight() == 0); + + // Batch 1: first 5 headers + auto batch1 = BuildHeaderChain(genesis.GetHash(), genesis.nTime, 5, 1000); + REQUIRE(AcceptHeadersToChain(chainstate, batch1)); + REQUIRE(chainstate.ActivateBestChain(nullptr)); + REQUIRE(chainstate.GetChainHeight() == 5); + + // Batch 2: next 5 headers + auto batch2 = BuildHeaderChain(batch1.back().GetHash(), genesis.nTime + 600, 5, 2000); + REQUIRE(AcceptHeadersToChain(chainstate, batch2)); + REQUIRE(chainstate.ActivateBestChain(nullptr)); + + // E2E verification: combined height + REQUIRE(chainstate.GetChainHeight() == 10); + REQUIRE(chainstate.GetTip()->GetBlockHash() == batch2.back().GetHash()); + } +} + +TEST_CASE("E2E: Reorg results in correct final chain", "[e2e][chain][reorg][critical]") { + auto params = ChainParams::CreateRegTest(); + TestChainstateManager chainstate(*params); + chainstate.Initialize(params->GenesisBlock()); + chainstate.SetBypassPOWValidation(true); + const auto& genesis = params->GenesisBlock(); + + SECTION("Longer chain wins reorg") { + // Build initial chain: genesis -> A -> B -> C (height 3) + auto chainA = BuildHeaderChain(genesis.GetHash(), genesis.nTime, 3, 1000); + REQUIRE(AcceptHeadersToChain(chainstate, chainA)); + REQUIRE(chainstate.ActivateBestChain(nullptr)); + + REQUIRE(chainstate.GetChainHeight() == 3); + uint256 oldTip = chainstate.GetTip()->GetBlockHash(); + REQUIRE(oldTip == chainA.back().GetHash()); + + // Build competing chain: genesis -> A' -> B' -> C' -> D' (height 4) + auto chainB = BuildHeaderChain(genesis.GetHash(), genesis.nTime, 4, 2000); + REQUIRE(AcceptHeadersToChain(chainstate, chainB)); + REQUIRE(chainstate.ActivateBestChain(nullptr)); + + // E2E verification: longer chain wins + REQUIRE(chainstate.GetChainHeight() == 4); + REQUIRE(chainstate.GetTip()->GetBlockHash() == chainB.back().GetHash()); + // E2E verification: old tip is NOT the current tip + REQUIRE(chainstate.GetTip()->GetBlockHash() != oldTip); + } + + SECTION("Same-length chain does NOT cause reorg (first-seen wins)") { + // Build initial chain: height 3 + auto chainA = BuildHeaderChain(genesis.GetHash(), genesis.nTime, 3, 1000); + REQUIRE(AcceptHeadersToChain(chainstate, chainA)); + REQUIRE(chainstate.ActivateBestChain(nullptr)); + + uint256 originalTip = chainstate.GetTip()->GetBlockHash(); + REQUIRE(chainstate.GetChainHeight() == 3); + + // Build competing chain: also height 3 (different blocks) + auto chainB = BuildHeaderChain(genesis.GetHash(), genesis.nTime, 3, 2000); + REQUIRE(AcceptHeadersToChain(chainstate, chainB)); + REQUIRE(chainstate.ActivateBestChain(nullptr)); + + // E2E verification: original chain retained (first-seen wins for same height) + REQUIRE(chainstate.GetChainHeight() == 3); + REQUIRE(chainstate.GetTip()->GetBlockHash() == originalTip); + } + + SECTION("Deep reorg replaces entire chain") { + // Build initial chain: height 10 + auto chainA = BuildHeaderChain(genesis.GetHash(), genesis.nTime, 10, 1000); + REQUIRE(AcceptHeadersToChain(chainstate, chainA)); + REQUIRE(chainstate.ActivateBestChain(nullptr)); + REQUIRE(chainstate.GetChainHeight() == 10); + + // Build competing chain: height 15 (completely different from genesis) + auto chainB = BuildHeaderChain(genesis.GetHash(), genesis.nTime, 15, 2000); + REQUIRE(AcceptHeadersToChain(chainstate, chainB)); + REQUIRE(chainstate.ActivateBestChain(nullptr)); + + // E2E verification: new chain is active + REQUIRE(chainstate.GetChainHeight() == 15); + REQUIRE(chainstate.GetTip()->GetBlockHash() == chainB.back().GetHash()); + + // E2E verification: verify chain structure by walking back + const chain::CBlockIndex* tip = chainstate.LookupBlockIndex(chainB.back().GetHash()); + REQUIRE(tip != nullptr); + REQUIRE(tip->nHeight == 15); + + // Walk back and verify all blocks are from chainB + const chain::CBlockIndex* current = tip; + for (int i = 14; i >= 0; i--) { + REQUIRE(current->pprev != nullptr); + current = current->pprev; + REQUIRE(current->nHeight == i); + if (i > 0) { + REQUIRE(current->GetBlockHash() == chainB[i-1].GetHash()); + } + } + // Should end at genesis + REQUIRE(current->GetBlockHash() == genesis.GetHash()); + } + + SECTION("Partial reorg at fork point") { + // Build common prefix: genesis -> A -> B (height 2) + auto common = BuildHeaderChain(genesis.GetHash(), genesis.nTime, 2, 1000); + REQUIRE(AcceptHeadersToChain(chainstate, common)); + REQUIRE(chainstate.ActivateBestChain(nullptr)); + REQUIRE(chainstate.GetChainHeight() == 2); + + // Extend with chain1: B -> C1 -> D1 (height 4) + auto chain1 = BuildHeaderChain(common.back().GetHash(), genesis.nTime + 240, 2, 3000); + REQUIRE(AcceptHeadersToChain(chainstate, chain1)); + REQUIRE(chainstate.ActivateBestChain(nullptr)); + REQUIRE(chainstate.GetChainHeight() == 4); + uint256 chain1Tip = chainstate.GetTip()->GetBlockHash(); + + // Competing fork from B: B -> C2 -> D2 -> E2 (height 5) + auto chain2 = BuildHeaderChain(common.back().GetHash(), genesis.nTime + 240, 3, 4000); + REQUIRE(AcceptHeadersToChain(chainstate, chain2)); + REQUIRE(chainstate.ActivateBestChain(nullptr)); + + // E2E verification: longer fork wins + REQUIRE(chainstate.GetChainHeight() == 5); + REQUIRE(chainstate.GetTip()->GetBlockHash() == chain2.back().GetHash()); + REQUIRE(chainstate.GetTip()->GetBlockHash() != chain1Tip); + + // E2E verification: common prefix is still in chain + REQUIRE(chainstate.LookupBlockIndex(common[0].GetHash()) != nullptr); + REQUIRE(chainstate.LookupBlockIndex(common[1].GetHash()) != nullptr); + } +} + +TEST_CASE("E2E: Chain walk from tip to genesis is valid", "[e2e][chain][structure]") { + auto params = ChainParams::CreateRegTest(); + TestChainstateManager chainstate(*params); + chainstate.Initialize(params->GenesisBlock()); + chainstate.SetBypassPOWValidation(true); + const auto& genesis = params->GenesisBlock(); + + SECTION("50-block chain has valid structure") { + const int CHAIN_LENGTH = 50; + auto headers = BuildHeaderChain(genesis.GetHash(), genesis.nTime, CHAIN_LENGTH); + REQUIRE(AcceptHeadersToChain(chainstate, headers)); + REQUIRE(chainstate.ActivateBestChain(nullptr)); + + REQUIRE(chainstate.GetChainHeight() == CHAIN_LENGTH); + + // Walk from tip to genesis, verify structure + const chain::CBlockIndex* current = chainstate.LookupBlockIndex(chainstate.GetTip()->GetBlockHash()); + REQUIRE(current != nullptr); + + int expectedHeight = CHAIN_LENGTH; + while (current != nullptr) { + // Verify height is correct + REQUIRE(current->nHeight == expectedHeight); + + // Verify hash matches (except genesis) + if (expectedHeight > 0) { + REQUIRE(current->GetBlockHash() == headers[expectedHeight - 1].GetHash()); + } else { + REQUIRE(current->GetBlockHash() == genesis.GetHash()); + } + + // Verify pprev relationship + if (expectedHeight > 0) { + REQUIRE(current->pprev != nullptr); + REQUIRE(current->pprev->nHeight == expectedHeight - 1); + } + + current = current->pprev; + expectedHeight--; + } + + // Should have walked exactly to before genesis + REQUIRE(expectedHeight == -1); + } +} + +TEST_CASE("E2E: Multiple competing forks resolve correctly", "[e2e][chain][fork]") { + auto params = ChainParams::CreateRegTest(); + TestChainstateManager chainstate(*params); + chainstate.Initialize(params->GenesisBlock()); + chainstate.SetBypassPOWValidation(true); + const auto& genesis = params->GenesisBlock(); + + SECTION("Three competing forks - longest wins") { + // Fork 1: height 5 + auto fork1 = BuildHeaderChain(genesis.GetHash(), genesis.nTime, 5, 1000); + REQUIRE(AcceptHeadersToChain(chainstate, fork1)); + REQUIRE(chainstate.ActivateBestChain(nullptr)); + REQUIRE(chainstate.GetChainHeight() == 5); + + // Fork 2: height 7 (should become tip) + auto fork2 = BuildHeaderChain(genesis.GetHash(), genesis.nTime, 7, 2000); + REQUIRE(AcceptHeadersToChain(chainstate, fork2)); + REQUIRE(chainstate.ActivateBestChain(nullptr)); + REQUIRE(chainstate.GetChainHeight() == 7); + REQUIRE(chainstate.GetTip()->GetBlockHash() == fork2.back().GetHash()); + + // Fork 3: height 6 (should NOT become tip) + auto fork3 = BuildHeaderChain(genesis.GetHash(), genesis.nTime, 6, 3000); + REQUIRE(AcceptHeadersToChain(chainstate, fork3)); + REQUIRE(chainstate.ActivateBestChain(nullptr)); + + // E2E verification: fork2 (longest) is still tip + REQUIRE(chainstate.GetChainHeight() == 7); + REQUIRE(chainstate.GetTip()->GetBlockHash() == fork2.back().GetHash()); + } + + SECTION("Fork extension makes shorter fork win") { + // Initial: fork1 at height 5, fork2 at height 3 + auto fork1 = BuildHeaderChain(genesis.GetHash(), genesis.nTime, 5, 1000); + auto fork2 = BuildHeaderChain(genesis.GetHash(), genesis.nTime, 3, 2000); + + REQUIRE(AcceptHeadersToChain(chainstate, fork1)); + REQUIRE(AcceptHeadersToChain(chainstate, fork2)); + REQUIRE(chainstate.ActivateBestChain(nullptr)); + + REQUIRE(chainstate.GetChainHeight() == 5); + REQUIRE(chainstate.GetTip()->GetBlockHash() == fork1.back().GetHash()); + + // Extend fork2 to height 7 + auto fork2_ext = BuildHeaderChain(fork2.back().GetHash(), genesis.nTime + 360, 4, 3000); + REQUIRE(AcceptHeadersToChain(chainstate, fork2_ext)); + REQUIRE(chainstate.ActivateBestChain(nullptr)); + + // E2E verification: extended fork2 is now tip + REQUIRE(chainstate.GetChainHeight() == 7); + REQUIRE(chainstate.GetTip()->GetBlockHash() == fork2_ext.back().GetHash()); + } +} + +// Note: Test "E2E: Headers received out of order still result in correct chain" +// was removed because orphan pool infrastructure was removed. Out-of-order headers +// are now discarded and trigger GETHEADERS requests to fill the gap. diff --git a/test/integration-chain/invalidateblock_chain_tests.cpp b/test/integration-chain/invalidateblock_chain_tests.cpp index 9b0ff92..ad1ab41 100644 --- a/test/integration-chain/invalidateblock_chain_tests.cpp +++ b/test/integration-chain/invalidateblock_chain_tests.cpp @@ -21,7 +21,7 @@ class InvalidateBlockChainFixture2 { } uint256 MineBlock() { auto* tip = chainstate.GetTip(); REQUIRE(tip != nullptr); - CBlockHeader header; header.nVersion=1; header.hashPrevBlock=tip->GetBlockHash(); header.minerAddress=uint160(); header.nTime=tip->nTime+120; header.nBits=0x207fffff; header.nNonce=tip->nHeight+1; + CBlockHeader header; header.nVersion=1; header.hashPrevBlock=tip->GetBlockHash(); header.payloadRoot=uint256(); header.nTime=tip->nTime+120; header.nBits=0x207fffff; header.nNonce=tip->nHeight+1; header.hashRandomX.SetNull(); ValidationState st; REQUIRE(chainstate.ProcessNewBlockHeader(header, st)); return header.GetHash(); } const CBlockIndex* Get(const uint256& h){ return chainstate.LookupBlockIndex(h); } @@ -54,7 +54,7 @@ TEST_CASE("InvalidateBlock (chain) - Fork switching after invalidation", "[inval CBlockHeader b1; b1.nVersion = 1; b1.hashPrevBlock = fx.genesis_hash; - b1.minerAddress = uint160(); + b1.payloadRoot = uint256(); b1.nTime = fx.chainstate.GetTip()->nTime + 1000; b1.nBits = 0x207fffff; b1.nNonce = 9999; @@ -65,7 +65,7 @@ TEST_CASE("InvalidateBlock (chain) - Fork switching after invalidation", "[inval CBlockHeader b2; b2.nVersion = 1; b2.hashPrevBlock = b1.GetHash(); - b2.minerAddress = uint160(); + b2.payloadRoot = uint256(); b2.nTime = b1.nTime + 120; b2.nBits = 0x207fffff; b2.nNonce = 10000; @@ -75,7 +75,7 @@ TEST_CASE("InvalidateBlock (chain) - Fork switching after invalidation", "[inval CBlockHeader b3; b3.nVersion = 1; b3.hashPrevBlock = b2.GetHash(); - b3.minerAddress = uint160(); + b3.payloadRoot = uint256(); b3.nTime = b2.nTime + 120; b3.nBits = 0x207fffff; b3.nNonce = 10001; @@ -129,7 +129,7 @@ TEST_CASE("InvalidateBlock (chain) - Invalidate non-active chain block", "[inval CBlockHeader b1; b1.nVersion = 1; b1.hashPrevBlock = fx.genesis_hash; - b1.minerAddress = uint160(); + b1.payloadRoot = uint256(); b1.nTime = fx.chainstate.GetTip()->nTime + 500; b1.nBits = 0x207fffff; b1.nNonce = 8888; @@ -224,7 +224,7 @@ TEST_CASE("InvalidateBlock (chain) - Multiple forks, best fork wins", "[invalida CBlockHeader b1; b1.nVersion = 1; b1.hashPrevBlock = fx.genesis_hash; - b1.minerAddress = uint160(); + b1.payloadRoot = uint256(); b1.nTime = fx.chainstate.GetTip()->nTime + 500; b1.nBits = 0x207fffff; b1.nNonce = 7777; @@ -236,7 +236,7 @@ TEST_CASE("InvalidateBlock (chain) - Multiple forks, best fork wins", "[invalida CBlockHeader c1; c1.nVersion = 1; c1.hashPrevBlock = fx.genesis_hash; - c1.minerAddress = uint160(); + c1.payloadRoot = uint256(); c1.nTime = fx.chainstate.GetTip()->nTime + 1000; c1.nBits = 0x207fffff; c1.nNonce = 6666; @@ -246,7 +246,7 @@ TEST_CASE("InvalidateBlock (chain) - Multiple forks, best fork wins", "[invalida CBlockHeader c2; c2.nVersion = 1; c2.hashPrevBlock = c1.GetHash(); - c2.minerAddress = uint160(); + c2.payloadRoot = uint256(); c2.nTime = c1.nTime + 120; c2.nBits = 0x207fffff; c2.nNonce = 6667; diff --git a/test/integration-chain/reorg_multi_node_tests.cpp b/test/integration-chain/reorg_multi_node_tests.cpp index 219c26b..95e7b21 100644 --- a/test/integration-chain/reorg_multi_node_tests.cpp +++ b/test/integration-chain/reorg_multi_node_tests.cpp @@ -51,7 +51,7 @@ class TestNode { CBlockHeader header; header.nVersion = 1; header.hashPrevBlock = prev_hash; - header.minerAddress.SetNull(); + header.payloadRoot.SetNull(); header.nTime = util::GetTime() + (node_id * 1000); // Offset by node_id to ensure unique hashes header.nBits = 0x207fffff; header.nNonce = node_id + (tip ? tip->nHeight : 0); diff --git a/test/integration-chain/stress_threading_tests.cpp b/test/integration-chain/stress_threading_tests.cpp index 90dd069..6c09afa 100644 --- a/test/integration-chain/stress_threading_tests.cpp +++ b/test/integration-chain/stress_threading_tests.cpp @@ -10,10 +10,12 @@ #include using namespace unicity; +#include "../common/mock_trust_base_manager.hpp" TEST_CASE("Stress test: High concurrency validation", "[stress][threading]") { auto params = chain::ChainParams::CreateRegTest(); - validation::ChainstateManager chainstate(*params); + test::MockTrustBaseManager mock_tbm; + validation::ChainstateManager chainstate(*params, mock_tbm); CBlockHeader genesis = params->GenesisBlock(); REQUIRE(chainstate.Initialize(genesis)); diff --git a/test/integration-network/infra/node_simulator.cpp b/test/integration-network/infra/node_simulator.cpp index 7a99dff..a547cc7 100644 --- a/test/integration-network/infra/node_simulator.cpp +++ b/test/integration-network/infra/node_simulator.cpp @@ -1,395 +1,395 @@ -// Copyright (c) 2025 The Unicity Foundation -// NodeSimulator implementation - Sends malicious P2P messages for testing - -#include "node_simulator.hpp" -#include "network/protocol.hpp" -#include "network/message.hpp" -#include "util/hash.hpp" -#include "chain/validation.hpp" -#include "chain/pow.hpp" -#include "chain/randomx_pow.hpp" -#include - -namespace unicity { -namespace test { - -CBlockHeader NodeSimulator::CreateDummyHeader(const uint256& prev_hash, uint32_t nBits) { - CBlockHeader header; - header.nVersion = 1; - header.hashPrevBlock = prev_hash; - header.nTime = static_cast(sim_network_->GetCurrentTime() / 1000); - header.nBits = nBits; - - // Random nonce and miner address - std::random_device rd; - std::mt19937_64 gen(rd()); - std::uniform_int_distribution dis_nonce; - header.nNonce = dis_nonce(gen); - - std::uniform_int_distribution dis_byte(0, 255); - for (int i = 0; i < 20; i++) { - header.minerAddress.data()[i] = dis_byte(gen); - } - - // Set dummy RandomX hash that is NOT null (passes IsNull() check) - // This allows headers to pass PoW commitment check when bypass is disabled, - // enabling tests to exercise continuity checks rather than failing at PoW. - // Note: SendInvalidPoWHeaders explicitly sets hashRandomX.SetNull() for PoW testing. - header.hashRandomX.SetHex("0000000000000000000000000000000000000000000000000000000000000001"); - - return header; -} - -void NodeSimulator::SendUnconnectingHeaders(int peer_node_id, size_t count) { - // Send headers with random (unknown) parents. These will be discarded by the - // recipient and trigger GETHEADERS requests to fill the gap. - // Previously these filled an "orphan pool" but that infrastructure was removed. - // The first header connects to victim's tip to pass initial checks. - - std::vector headers; - std::random_device rd; - std::mt19937_64 gen(rd()); - - // First header connects to victim's tip - CBlockHeader first_header = CreateDummyHeader(GetTipHash(), params_->GenesisBlock().nBits); - headers.push_back(first_header); - - // Remaining headers have random parents (unconnecting) - for (size_t i = 1; i < count; i++) { - // Random prev_hash - won't exist in victim's chain - uint256 random_prev_hash; - std::uniform_int_distribution dis_byte(0, 255); - for (int j = 0; j < 32; j++) { - random_prev_hash.data()[j] = dis_byte(gen); - } - - CBlockHeader header = CreateDummyHeader(random_prev_hash, params_->GenesisBlock().nBits); - headers.push_back(header); - } - - // Serialize HEADERS message - message::HeadersMessage msg; - msg.headers = headers; - auto payload = msg.serialize(); - - // Create message header - protocol::MessageHeader header(protocol::magic::REGTEST, protocol::commands::HEADERS, static_cast(payload.size())); - uint256 hash = Hash(payload); - std::memcpy(header.checksum.data(), hash.begin(), 4); - auto header_bytes = message::serialize_header(header); - - // Combine header + payload - std::vector full_message; - full_message.insert(full_message.end(), header_bytes.begin(), header_bytes.end()); - full_message.insert(full_message.end(), payload.begin(), payload.end()); - - // Inject directly into SimulatedNetwork - sim_network_->SendMessage(GetId(), peer_node_id, full_message); -} - -void NodeSimulator::SendInvalidPoWHeaders(int peer_node_id, const uint256& prev_hash, size_t count) { - std::vector headers; - for (size_t i = 0; i < count; i++) { - CBlockHeader header = CreateDummyHeader(prev_hash, 0x00000001); // Impossible difficulty - header.hashRandomX.SetNull(); // Invalid: NULL RandomX hash - headers.push_back(header); - } - - // Serialize and inject - message::HeadersMessage msg; - msg.headers = headers; - auto payload = msg.serialize(); - - protocol::MessageHeader header(protocol::magic::REGTEST, protocol::commands::HEADERS, static_cast(payload.size())); - uint256 hash = Hash(payload); - std::memcpy(header.checksum.data(), hash.begin(), 4); - auto header_bytes = message::serialize_header(header); - - std::vector full_message; - full_message.insert(full_message.end(), header_bytes.begin(), header_bytes.end()); - full_message.insert(full_message.end(), payload.begin(), payload.end()); - - sim_network_->SendMessage(GetId(), peer_node_id, full_message); -} - -void NodeSimulator::SendNonContinuousHeaders(int peer_node_id, const uint256& prev_hash) { - // Create two headers that don't connect - CBlockHeader header1 = CreateDummyHeader(prev_hash, params_->GenesisBlock().nBits); - CBlockHeader header2 = CreateDummyHeader(uint256(), params_->GenesisBlock().nBits); // Wrong prev_hash! - - std::vector headers = {header1, header2}; - - // Serialize and inject - message::HeadersMessage msg; - msg.headers = headers; - auto payload = msg.serialize(); - - protocol::MessageHeader header(protocol::magic::REGTEST, protocol::commands::HEADERS, static_cast(payload.size())); - uint256 hash = Hash(payload); - std::memcpy(header.checksum.data(), hash.begin(), 4); - auto header_bytes = message::serialize_header(header); - - std::vector full_message; - full_message.insert(full_message.end(), header_bytes.begin(), header_bytes.end()); - full_message.insert(full_message.end(), payload.begin(), payload.end()); - - sim_network_->SendMessage(GetId(), peer_node_id, full_message); -} - -void NodeSimulator::SendOversizedHeaders(int peer_node_id, size_t count) { - if (count <= protocol::MAX_HEADERS_SIZE) { - return; - } - - std::vector headers; - uint256 prev_hash = GetTipHash(); - - for (size_t i = 0; i < count; i++) { - CBlockHeader header = CreateDummyHeader(prev_hash, params_->GenesisBlock().nBits); - headers.push_back(header); - prev_hash = header.GetHash(); - } - - // Serialize and inject oversized message - message::HeadersMessage msg; - msg.headers = headers; - auto payload = msg.serialize(); - - protocol::MessageHeader header(protocol::magic::REGTEST, protocol::commands::HEADERS, static_cast(payload.size())); - uint256 hash = Hash(payload); - std::memcpy(header.checksum.data(), hash.begin(), 4); - auto header_bytes = message::serialize_header(header); - - std::vector full_message; - full_message.insert(full_message.end(), header_bytes.begin(), header_bytes.end()); - full_message.insert(full_message.end(), payload.begin(), payload.end()); - - sim_network_->SendMessage(GetId(), peer_node_id, full_message); -} - -uint256 NodeSimulator::MineBlockPrivate(const std::string& miner_address) { - // Create block header (same as normal MineBlock) - CBlockHeader header; - header.nVersion = 1; - header.hashPrevBlock = GetTipHash(); - header.nTime = static_cast(sim_network_->GetCurrentTime() / 1000); - header.nBits = params_->GenesisBlock().nBits; - - // Random nonce - std::random_device rd; - std::mt19937_64 gen(rd()); - std::uniform_int_distribution dis_nonce; - header.nNonce = dis_nonce(gen); - - // Random miner address - std::uniform_int_distribution dis_byte(0, 255); - for (int i = 0; i < 20; i++) { - header.minerAddress.data()[i] = dis_byte(gen); - } - - // Bypass PoW (bypass enabled by default) - header.hashRandomX.SetNull(); - - // Add to chainstate - validation::ValidationState state; - auto& chainstate = GetChainstate(); - auto* pindex = chainstate.AcceptBlockHeader(header, state); - - if (pindex) { - chainstate.TryAddBlockIndexCandidate(pindex); - chainstate.ActivateBestChain(); - - uint256 block_hash = header.GetHash(); - - // Block relay handled via ChainTipEvent - no need for manual relay - return block_hash; - } - - return uint256(); // Failed -} - -void NodeSimulator::BroadcastBlock(const uint256& block_hash, int peer_node_id) { - // Look up the block header from our chainstate - auto& chainstate = GetChainstate(); - const chain::CBlockIndex* pindex = chainstate.LookupBlockIndex(block_hash); - - if (!pindex) { - return; - } - - // Get the block header - CBlockHeader header = pindex->GetBlockHeader(); - - // Send as HEADERS message directly to peer - std::vector headers = {header}; - message::HeadersMessage msg; - msg.headers = headers; - auto payload = msg.serialize(); - - protocol::MessageHeader msg_header(protocol::magic::REGTEST, protocol::commands::HEADERS, static_cast(payload.size())); - uint256 hash = Hash(payload); - std::memcpy(msg_header.checksum.data(), hash.begin(), 4); - auto header_bytes = message::serialize_header(msg_header); - - std::vector full_message; - full_message.insert(full_message.end(), header_bytes.begin(), header_bytes.end()); - full_message.insert(full_message.end(), payload.begin(), payload.end()); - - sim_network_->SendMessage(GetId(), peer_node_id, full_message); -} - -void NodeSimulator::SendLowWorkHeaders(int peer_node_id, const std::vector& block_hashes) { - // Look up all the block headers from our chainstate - auto& chainstate = GetChainstate(); - std::vector headers; - - for (const auto& block_hash : block_hashes) { - const chain::CBlockIndex* pindex = chainstate.LookupBlockIndex(block_hash); - - if (!pindex) { - continue; - } - - headers.push_back(pindex->GetBlockHeader()); - } - - if (headers.empty()) { - return; - } - - // Serialize HEADERS message - message::HeadersMessage msg; - msg.headers = headers; - auto payload = msg.serialize(); - - protocol::MessageHeader msg_header(protocol::magic::REGTEST, protocol::commands::HEADERS, static_cast(payload.size())); - uint256 hash = Hash(payload); - std::memcpy(msg_header.checksum.data(), hash.begin(), 4); - auto header_bytes = message::serialize_header(msg_header); - - std::vector full_message; - full_message.insert(full_message.end(), header_bytes.begin(), header_bytes.end()); - full_message.insert(full_message.end(), payload.begin(), payload.end()); - - // Inject message directly into network - sim_network_->SendMessage(GetId(), peer_node_id, full_message); -} - -std::pair NodeSimulator::SendOutOfOrderHeaders(int peer_node_id, const uint256& prev_hash) { - // Create parent header (builds on prev_hash) - CBlockHeader parent_header = CreateDummyHeader(prev_hash, params_->GenesisBlock().nBits); - parent_header.hashRandomX.SetNull(); // Bypass PoW validation - uint256 parent_hash = parent_header.GetHash(); - - // Create child header (builds on parent - will be unconnecting if sent first) - CBlockHeader child_header = CreateDummyHeader(parent_hash, params_->GenesisBlock().nBits); - child_header.nTime = parent_header.nTime + 1; // Ensure different hash - child_header.hashRandomX.SetNull(); // Bypass PoW validation - uint256 child_hash = child_header.GetHash(); - - // Helper lambda to send a single header - auto send_header = [this, peer_node_id](const CBlockHeader& hdr) { - std::vector headers = {hdr}; - message::HeadersMessage msg; - msg.headers = headers; - auto payload = msg.serialize(); - - protocol::MessageHeader msg_header(protocol::magic::REGTEST, protocol::commands::HEADERS, static_cast(payload.size())); - uint256 hash = Hash(payload); - std::memcpy(msg_header.checksum.data(), hash.begin(), 4); - auto header_bytes = message::serialize_header(msg_header); - - std::vector full_message; - full_message.insert(full_message.end(), header_bytes.begin(), header_bytes.end()); - full_message.insert(full_message.end(), payload.begin(), payload.end()); - - sim_network_->SendMessage(GetId(), peer_node_id, full_message); - }; - - // Send CHILD FIRST (unconnecting - parent unknown, will trigger GETHEADERS) - send_header(child_header); - - // Process the message - sim_network_->ProcessMessages(sim_network_->GetCurrentTime()); - - // Send PARENT SECOND - send_header(parent_header); - - return {parent_hash, child_hash}; -} - -void NodeSimulator::SendValidSideChainHeaders(int peer_node_id, const uint256& fork_point, size_t count) { - // Create a chain of valid headers building on fork_point - // Each header has bypass PoW enabled so it will be accepted - std::vector headers; - uint256 prev_hash = fork_point; - - // Generate random bytes for uniqueness - std::random_device rd; - std::mt19937_64 gen(rd()); - std::uniform_int_distribution dis_nonce; - std::uniform_int_distribution dis_byte(0, 255); - - for (size_t i = 0; i < count; i++) { - CBlockHeader header; - header.nVersion = 1; - header.hashPrevBlock = prev_hash; - header.nTime = static_cast(sim_network_->GetCurrentTime() / 1000) + static_cast(i); - header.nBits = params_->GenesisBlock().nBits; - header.nNonce = dis_nonce(gen); - - // Random miner address for uniqueness - for (int j = 0; j < 20; j++) { - header.minerAddress.data()[j] = dis_byte(gen); - } - - // Set null RandomX hash - PoW bypass mode will accept this - header.hashRandomX.SetNull(); - - headers.push_back(header); - prev_hash = header.GetHash(); - } - - if (headers.empty()) { - return; - } - - // Serialize HEADERS message - message::HeadersMessage msg; - msg.headers = headers; - auto payload = msg.serialize(); - - protocol::MessageHeader msg_header(protocol::magic::REGTEST, protocol::commands::HEADERS, static_cast(payload.size())); - uint256 hash = Hash(payload); - std::memcpy(msg_header.checksum.data(), hash.begin(), 4); - auto header_bytes = message::serialize_header(msg_header); - - std::vector full_message; - full_message.insert(full_message.end(), header_bytes.begin(), header_bytes.end()); - full_message.insert(full_message.end(), payload.begin(), payload.end()); - - // Inject message directly into network - sim_network_->SendMessage(GetId(), peer_node_id, full_message); -} - -void NodeSimulator::SendValidHeaders(int peer_node_id, const std::vector& headers) { - // Send valid headers from our chain through P2P layer - // This goes through HeaderSyncManager::HandleHeadersMessage() on the receiver - message::HeadersMessage msg; - msg.headers = headers; - auto payload = msg.serialize(); - - protocol::MessageHeader msg_header(protocol::magic::REGTEST, protocol::commands::HEADERS, static_cast(payload.size())); - uint256 hash = Hash(payload); - std::memcpy(msg_header.checksum.data(), hash.begin(), 4); - auto header_bytes = message::serialize_header(msg_header); - - std::vector full_message; - full_message.insert(full_message.end(), header_bytes.begin(), header_bytes.end()); - full_message.insert(full_message.end(), payload.begin(), payload.end()); - - sim_network_->SendMessage(GetId(), peer_node_id, full_message); -} - -} // namespace test -} // namespace unicity +// Copyright (c) 2025 The Unicity Foundation +// NodeSimulator implementation - Sends malicious P2P messages for testing + +#include "node_simulator.hpp" +#include "network/protocol.hpp" +#include "network/message.hpp" +#include "util/hash.hpp" +#include "chain/validation.hpp" +#include "chain/pow.hpp" +#include "chain/randomx_pow.hpp" +#include + +namespace unicity { +namespace test { + +CBlockHeader NodeSimulator::CreateDummyHeader(const uint256& prev_hash, uint32_t nBits) { + CBlockHeader header; + header.nVersion = 1; + header.hashPrevBlock = prev_hash; + header.nTime = static_cast(sim_network_->GetCurrentTime() / 1000); + header.nBits = nBits; + + // Random nonce and miner address + std::random_device rd; + std::mt19937_64 gen(rd()); + std::uniform_int_distribution dis_nonce; + header.nNonce = dis_nonce(gen); + + std::uniform_int_distribution dis_byte(0, 255); + for (int i = 0; i < 20; i++) { + header.payloadRoot.data()[i] = dis_byte(gen); + } + + // Set dummy RandomX hash that is NOT null (passes IsNull() check) + // This allows headers to pass PoW commitment check when bypass is disabled, + // enabling tests to exercise continuity checks rather than failing at PoW. + // Note: SendInvalidPoWHeaders explicitly sets hashRandomX.SetNull() for PoW testing. + header.hashRandomX.SetHex("0000000000000000000000000000000000000000000000000000000000000001"); + + return header; +} + +void NodeSimulator::SendUnconnectingHeaders(int peer_node_id, size_t count) { + // Send headers with random (unknown) parents. These will be discarded by the + // recipient and trigger GETHEADERS requests to fill the gap. + // Previously these filled an "orphan pool" but that infrastructure was removed. + // The first header connects to victim's tip to pass initial checks. + + std::vector headers; + std::random_device rd; + std::mt19937_64 gen(rd()); + + // First header connects to victim's tip + CBlockHeader first_header = CreateDummyHeader(GetTipHash(), params_->GenesisBlock().nBits); + headers.push_back(first_header); + + // Remaining headers have random parents (unconnecting) + for (size_t i = 1; i < count; i++) { + // Random prev_hash - won't exist in victim's chain + uint256 random_prev_hash; + std::uniform_int_distribution dis_byte(0, 255); + for (int j = 0; j < 32; j++) { + random_prev_hash.data()[j] = dis_byte(gen); + } + + CBlockHeader header = CreateDummyHeader(random_prev_hash, params_->GenesisBlock().nBits); + headers.push_back(header); + } + + // Serialize HEADERS message + message::HeadersMessage msg; + msg.headers = headers; + auto payload = msg.serialize(); + + // Create message header + protocol::MessageHeader header(protocol::magic::REGTEST, protocol::commands::HEADERS, static_cast(payload.size())); + uint256 hash = Hash(payload); + std::memcpy(header.checksum.data(), hash.begin(), 4); + auto header_bytes = message::serialize_header(header); + + // Combine header + payload + std::vector full_message; + full_message.insert(full_message.end(), header_bytes.begin(), header_bytes.end()); + full_message.insert(full_message.end(), payload.begin(), payload.end()); + + // Inject directly into SimulatedNetwork + sim_network_->SendMessage(GetId(), peer_node_id, full_message); +} + +void NodeSimulator::SendInvalidPoWHeaders(int peer_node_id, const uint256& prev_hash, size_t count) { + std::vector headers; + for (size_t i = 0; i < count; i++) { + CBlockHeader header = CreateDummyHeader(prev_hash, 0x00000001); // Impossible difficulty + header.hashRandomX.SetNull(); // Invalid: NULL RandomX hash + headers.push_back(header); + } + + // Serialize and inject + message::HeadersMessage msg; + msg.headers = headers; + auto payload = msg.serialize(); + + protocol::MessageHeader header(protocol::magic::REGTEST, protocol::commands::HEADERS, static_cast(payload.size())); + uint256 hash = Hash(payload); + std::memcpy(header.checksum.data(), hash.begin(), 4); + auto header_bytes = message::serialize_header(header); + + std::vector full_message; + full_message.insert(full_message.end(), header_bytes.begin(), header_bytes.end()); + full_message.insert(full_message.end(), payload.begin(), payload.end()); + + sim_network_->SendMessage(GetId(), peer_node_id, full_message); +} + +void NodeSimulator::SendNonContinuousHeaders(int peer_node_id, const uint256& prev_hash) { + // Create two headers that don't connect + CBlockHeader header1 = CreateDummyHeader(prev_hash, params_->GenesisBlock().nBits); + CBlockHeader header2 = CreateDummyHeader(uint256(), params_->GenesisBlock().nBits); // Wrong prev_hash! + + std::vector headers = {header1, header2}; + + // Serialize and inject + message::HeadersMessage msg; + msg.headers = headers; + auto payload = msg.serialize(); + + protocol::MessageHeader header(protocol::magic::REGTEST, protocol::commands::HEADERS, static_cast(payload.size())); + uint256 hash = Hash(payload); + std::memcpy(header.checksum.data(), hash.begin(), 4); + auto header_bytes = message::serialize_header(header); + + std::vector full_message; + full_message.insert(full_message.end(), header_bytes.begin(), header_bytes.end()); + full_message.insert(full_message.end(), payload.begin(), payload.end()); + + sim_network_->SendMessage(GetId(), peer_node_id, full_message); +} + +void NodeSimulator::SendOversizedHeaders(int peer_node_id, size_t count) { + if (count <= protocol::MAX_HEADERS_SIZE) { + return; + } + + std::vector headers; + uint256 prev_hash = GetTipHash(); + + for (size_t i = 0; i < count; i++) { + CBlockHeader header = CreateDummyHeader(prev_hash, params_->GenesisBlock().nBits); + headers.push_back(header); + prev_hash = header.GetHash(); + } + + // Serialize and inject oversized message + message::HeadersMessage msg; + msg.headers = headers; + auto payload = msg.serialize(); + + protocol::MessageHeader header(protocol::magic::REGTEST, protocol::commands::HEADERS, static_cast(payload.size())); + uint256 hash = Hash(payload); + std::memcpy(header.checksum.data(), hash.begin(), 4); + auto header_bytes = message::serialize_header(header); + + std::vector full_message; + full_message.insert(full_message.end(), header_bytes.begin(), header_bytes.end()); + full_message.insert(full_message.end(), payload.begin(), payload.end()); + + sim_network_->SendMessage(GetId(), peer_node_id, full_message); +} + +uint256 NodeSimulator::MineBlockPrivate(const std::string& payload_root) { + // Create block header (same as normal MineBlock) + CBlockHeader header; + header.nVersion = 1; + header.hashPrevBlock = GetTipHash(); + header.nTime = static_cast(sim_network_->GetCurrentTime() / 1000); + header.nBits = params_->GenesisBlock().nBits; + + // Random nonce + std::random_device rd; + std::mt19937_64 gen(rd()); + std::uniform_int_distribution dis_nonce; + header.nNonce = dis_nonce(gen); + + // Random miner address + std::uniform_int_distribution dis_byte(0, 255); + for (int i = 0; i < 20; i++) { + header.payloadRoot.data()[i] = dis_byte(gen); + } + + // Bypass PoW (bypass enabled by default) + header.hashRandomX.SetNull(); + + // Add to chainstate + validation::ValidationState state; + auto& chainstate = GetChainstate(); + auto* pindex = chainstate.AcceptBlockHeader(header, state); + + if (pindex) { + chainstate.TryAddBlockIndexCandidate(pindex); + chainstate.ActivateBestChain(); + + uint256 block_hash = header.GetHash(); + + // Block relay handled via ChainTipEvent - no need for manual relay + return block_hash; + } + + return uint256(); // Failed +} + +void NodeSimulator::BroadcastBlock(const uint256& block_hash, int peer_node_id) { + // Look up the block header from our chainstate + auto& chainstate = GetChainstate(); + const chain::CBlockIndex* pindex = chainstate.LookupBlockIndex(block_hash); + + if (!pindex) { + return; + } + + // Get the block header + CBlockHeader header = pindex->GetBlockHeader(); + + // Send as HEADERS message directly to peer + std::vector headers = {header}; + message::HeadersMessage msg; + msg.headers = headers; + auto payload = msg.serialize(); + + protocol::MessageHeader msg_header(protocol::magic::REGTEST, protocol::commands::HEADERS, static_cast(payload.size())); + uint256 hash = Hash(payload); + std::memcpy(msg_header.checksum.data(), hash.begin(), 4); + auto header_bytes = message::serialize_header(msg_header); + + std::vector full_message; + full_message.insert(full_message.end(), header_bytes.begin(), header_bytes.end()); + full_message.insert(full_message.end(), payload.begin(), payload.end()); + + sim_network_->SendMessage(GetId(), peer_node_id, full_message); +} + +void NodeSimulator::SendLowWorkHeaders(int peer_node_id, const std::vector& block_hashes) { + // Look up all the block headers from our chainstate + auto& chainstate = GetChainstate(); + std::vector headers; + + for (const auto& block_hash : block_hashes) { + const chain::CBlockIndex* pindex = chainstate.LookupBlockIndex(block_hash); + + if (!pindex) { + continue; + } + + headers.push_back(pindex->GetBlockHeader()); + } + + if (headers.empty()) { + return; + } + + // Serialize HEADERS message + message::HeadersMessage msg; + msg.headers = headers; + auto payload = msg.serialize(); + + protocol::MessageHeader msg_header(protocol::magic::REGTEST, protocol::commands::HEADERS, static_cast(payload.size())); + uint256 hash = Hash(payload); + std::memcpy(msg_header.checksum.data(), hash.begin(), 4); + auto header_bytes = message::serialize_header(msg_header); + + std::vector full_message; + full_message.insert(full_message.end(), header_bytes.begin(), header_bytes.end()); + full_message.insert(full_message.end(), payload.begin(), payload.end()); + + // Inject message directly into network + sim_network_->SendMessage(GetId(), peer_node_id, full_message); +} + +std::pair NodeSimulator::SendOutOfOrderHeaders(int peer_node_id, const uint256& prev_hash) { + // Create parent header (builds on prev_hash) + CBlockHeader parent_header = CreateDummyHeader(prev_hash, params_->GenesisBlock().nBits); + parent_header.hashRandomX.SetNull(); // Bypass PoW validation + uint256 parent_hash = parent_header.GetHash(); + + // Create child header (builds on parent - will be unconnecting if sent first) + CBlockHeader child_header = CreateDummyHeader(parent_hash, params_->GenesisBlock().nBits); + child_header.nTime = parent_header.nTime + 1; // Ensure different hash + child_header.hashRandomX.SetNull(); // Bypass PoW validation + uint256 child_hash = child_header.GetHash(); + + // Helper lambda to send a single header + auto send_header = [this, peer_node_id](const CBlockHeader& hdr) { + std::vector headers = {hdr}; + message::HeadersMessage msg; + msg.headers = headers; + auto payload = msg.serialize(); + + protocol::MessageHeader msg_header(protocol::magic::REGTEST, protocol::commands::HEADERS, static_cast(payload.size())); + uint256 hash = Hash(payload); + std::memcpy(msg_header.checksum.data(), hash.begin(), 4); + auto header_bytes = message::serialize_header(msg_header); + + std::vector full_message; + full_message.insert(full_message.end(), header_bytes.begin(), header_bytes.end()); + full_message.insert(full_message.end(), payload.begin(), payload.end()); + + sim_network_->SendMessage(GetId(), peer_node_id, full_message); + }; + + // Send CHILD FIRST (unconnecting - parent unknown, will trigger GETHEADERS) + send_header(child_header); + + // Process the message + sim_network_->ProcessMessages(sim_network_->GetCurrentTime()); + + // Send PARENT SECOND + send_header(parent_header); + + return {parent_hash, child_hash}; +} + +void NodeSimulator::SendValidSideChainHeaders(int peer_node_id, const uint256& fork_point, size_t count) { + // Create a chain of valid headers building on fork_point + // Each header has bypass PoW enabled so it will be accepted + std::vector headers; + uint256 prev_hash = fork_point; + + // Generate random bytes for uniqueness + std::random_device rd; + std::mt19937_64 gen(rd()); + std::uniform_int_distribution dis_nonce; + std::uniform_int_distribution dis_byte(0, 255); + + for (size_t i = 0; i < count; i++) { + CBlockHeader header; + header.nVersion = 1; + header.hashPrevBlock = prev_hash; + header.nTime = static_cast(sim_network_->GetCurrentTime() / 1000) + static_cast(i); + header.nBits = params_->GenesisBlock().nBits; + header.nNonce = dis_nonce(gen); + + // Random miner address for uniqueness + for (int j = 0; j < 20; j++) { + header.payloadRoot.data()[j] = dis_byte(gen); + } + + // Set null RandomX hash - PoW bypass mode will accept this + header.hashRandomX.SetNull(); + + headers.push_back(header); + prev_hash = header.GetHash(); + } + + if (headers.empty()) { + return; + } + + // Serialize HEADERS message + message::HeadersMessage msg; + msg.headers = headers; + auto payload = msg.serialize(); + + protocol::MessageHeader msg_header(protocol::magic::REGTEST, protocol::commands::HEADERS, static_cast(payload.size())); + uint256 hash = Hash(payload); + std::memcpy(msg_header.checksum.data(), hash.begin(), 4); + auto header_bytes = message::serialize_header(msg_header); + + std::vector full_message; + full_message.insert(full_message.end(), header_bytes.begin(), header_bytes.end()); + full_message.insert(full_message.end(), payload.begin(), payload.end()); + + // Inject message directly into network + sim_network_->SendMessage(GetId(), peer_node_id, full_message); +} + +void NodeSimulator::SendValidHeaders(int peer_node_id, const std::vector& headers) { + // Send valid headers from our chain through P2P layer + // This goes through HeaderSyncManager::HandleHeadersMessage() on the receiver + message::HeadersMessage msg; + msg.headers = headers; + auto payload = msg.serialize(); + + protocol::MessageHeader msg_header(protocol::magic::REGTEST, protocol::commands::HEADERS, static_cast(payload.size())); + uint256 hash = Hash(payload); + std::memcpy(msg_header.checksum.data(), hash.begin(), 4); + auto header_bytes = message::serialize_header(msg_header); + + std::vector full_message; + full_message.insert(full_message.end(), header_bytes.begin(), header_bytes.end()); + full_message.insert(full_message.end(), payload.begin(), payload.end()); + + sim_network_->SendMessage(GetId(), peer_node_id, full_message); +} + +} // namespace test +} // namespace unicity diff --git a/test/integration-network/infra/node_simulator.hpp b/test/integration-network/infra/node_simulator.hpp index 78353af..e6014cb 100644 --- a/test/integration-network/infra/node_simulator.hpp +++ b/test/integration-network/infra/node_simulator.hpp @@ -55,7 +55,7 @@ class NodeSimulator : public SimulatedNode { void EnableStalling(bool enabled) { stalling_enabled_ = enabled; } // Mine a block privately (don't broadcast) - for selfish mining attacks - uint256 MineBlockPrivate(const std::string& miner_address = "selfish_miner"); + uint256 MineBlockPrivate(const std::string& payload_root = "selfish_miner"); // Broadcast a previously mined private block to a specific peer void BroadcastBlock(const uint256& block_hash, int peer_node_id); diff --git a/test/integration-network/infra/simulated_node.cpp b/test/integration-network/infra/simulated_node.cpp index 53c4c85..192e8c1 100644 --- a/test/integration-network/infra/simulated_node.cpp +++ b/test/integration-network/infra/simulated_node.cpp @@ -1,638 +1,638 @@ -// Copyright (c) 2025 The Unicity Foundation -// SimulatedNode implementation - Uses REAL P2P components with simulated transport - -#include "simulated_node.hpp" -#include "test_access.hpp" -#include -#include -#include "chain/block.hpp" -#include "chain/chainstate_manager.hpp" -#include "util/logging.hpp" -#include "chain/pow.hpp" -#include "chain/randomx_pow.hpp" -#include "util/sha256.hpp" -#include "chain/validation.hpp" -#include "network/connection_types.hpp" -#include "network/addr_relay_manager.hpp" - -namespace unicity { -namespace test { - -using unicity::validation::ValidationState; - -// Helper to generate default address from node_id -static std::string GenerateDefaultAddress(int node_id) { - std::ostringstream oss; - oss << "127.0.0." << (node_id % 255); - return oss.str(); -} - -// Common initialization logic used by all constructors -void SimulatedNode::InitializeNode(const chain::ChainParams* params) { - // Setup chain params - if (params) { - params_ = params; - } else { - params_owned_ = chain::ChainParams::CreateRegTest(); - params_ = params_owned_.get(); - } - - // Initialize chainstate with genesis - chainstate_ = std::make_unique(*params_); - chainstate_->Initialize(params_->GenesisBlock()); - - // Create work guard to keep io_context alive - work_guard_ = std::make_unique>( - asio::make_work_guard(io_context_) - ); - - // Initialize networking - InitializeNetworking(); - - // Re-register with SimulatedNetwork to include node pointer for event processing and transport - if (sim_network_ && transport_) { - sim_network_->RegisterNode(node_id_, [this](int from_node_id, const std::vector& data) { - if (transport_) { - transport_->deliver_message(from_node_id, data); - } - }, this, transport_.get()); - } -} - -// Standard constructor - auto-generates address -SimulatedNode::SimulatedNode(int node_id, - SimulatedNetwork* network, - const chain::ChainParams* params) - : node_id_(node_id) - , port_(protocol::ports::REGTEST + node_id) - , sim_network_(network) - , address_(GenerateDefaultAddress(node_id)) -{ - InitializeNode(params); -} - -// Constructor with custom address - for testing netgroup diversity -SimulatedNode::SimulatedNode(int node_id, - SimulatedNetwork* network, - const std::string& custom_address, - const chain::ChainParams* params) - : node_id_(node_id) - , port_(protocol::ports::REGTEST + node_id) - , sim_network_(network) - , address_(custom_address) -{ - InitializeNode(params); -} - -// Constructor with IO threads override -SimulatedNode::SimulatedNode(int node_id, - SimulatedNetwork* network, - const chain::ChainParams* params, - size_t io_threads_override) - : node_id_(node_id) - , port_(protocol::ports::REGTEST + node_id) - , sim_network_(network) - , address_(GenerateDefaultAddress(node_id)) - , io_threads_override_(io_threads_override) -{ - InitializeNode(params); -} - -// Full constructor with all options -SimulatedNode::SimulatedNode(int node_id, - SimulatedNetwork* network, - const std::string& custom_address, - const chain::ChainParams* params, - size_t io_threads_override) - : node_id_(node_id) - , port_(protocol::ports::REGTEST + node_id) - , sim_network_(network) - , address_(custom_address) - , io_threads_override_(io_threads_override) -{ - InitializeNode(params); -} - -SimulatedNode::~SimulatedNode() { - // Unsubscribe from notifications before stopping network - tip_sub_.Unsubscribe(); - - // Stop networking - if (network_manager_) { - network_manager_->stop(); - } - - // Release work guard to allow io_context to finish - work_guard_.reset(); - - // Process remaining events - io_context_.run(); -} - -void SimulatedNode::InitializeNetworking() { - // Create bridged transport that routes through SimulatedNetwork - transport_ = std::make_shared(node_id_, sim_network_); - - // Create NetworkManager with our transport - network::NetworkManager::Config config; - config.network_magic = params_->GetNetworkMagic(); - config.listen_enabled = true; - config.listen_port = port_; - config.io_threads = io_threads_override_; // 0 by default (deterministic); tests may override - config.enable_nat = false; // Disable NAT/UPnP in tests (would block trying to discover devices) - - // CRITICAL: Set unique test_nonce for each node to prevent self-connection rejection - // In multi-node tests, each SimulatedNode needs a unique nonce (node_id + offset) - // Setting test_nonce disables process-wide nonce, and each peer gets this value - // via set_local_nonce() in ConnectionManager, ensuring different nodes can connect - config.test_nonce = static_cast(node_id_) + 1000000; - - // Create shared_ptr wrapper for io_context (NetworkManager requires shared ownership) - // Use aliasing constructor with no-op deleter since SimulatedNode owns the io_context - auto io_context_ptr = std::shared_ptr( - std::shared_ptr{}, &io_context_ - ); - - network_manager_ = std::make_unique( - *chainstate_, // Pass TestChainstateManager (inherits from ChainstateManager) - config, - transport_, - io_context_ptr // Pass shared_ptr to our io_context - ); - - // Start networking - if (!network_manager_->start()) { - throw std::runtime_error("Failed to start NetworkManager"); - } - - // Subscribe to chain tip changes to relay new blocks (mirrors Application behavior) - // IMPORTANT: Notifications() is a global singleton shared by all SimulatedNodes. - // We must verify the event matches OUR chainstate before relaying. - tip_sub_ = Notifications().SubscribeChainTip( - [this](const ChainTipEvent& event) { - // Verify this event is for OUR chainstate (not another node's) - const auto* our_tip = chainstate_ ? chainstate_->GetTip() : nullptr; - if (!our_tip || our_tip->GetBlockHash() != event.hash) { - return; // Event is from another node's chainstate - } - // Skip tip announcement during IBD (matches Bitcoin Core's UpdatedBlockTip behavior) - if (event.is_initial_download) { - return; - } - // Announce new tip to all connected peers via direct HEADERS - if (network_manager_) { - network_manager_->announce_block(event.hash); - } - }); -} - -std::string SimulatedNode::GetAddress() const { - return address_; -} - -bool SimulatedNode::ConnectTo(int peer_node_id, const std::string& address, uint16_t port) { - // Prevent self-connection - if (peer_node_id == node_id_) { - return false; - } - - // Generate peer address - std::string peer_addr = address; - if (peer_addr.empty()) { - std::ostringstream oss; - oss << "127.0.0." << (peer_node_id % 255); - peer_addr = oss.str(); - } - -// Construct NetworkAddress for new API - protocol::NetworkAddress net_addr; - net_addr.services = protocol::ServiceFlags::NODE_NETWORK; - // Default to the peer's simulated listen port if caller passed the base regtest port - uint16_t connect_port = port; - if (connect_port == protocol::ports::REGTEST) { - // In simulation, each node listens on REGTEST + node_id - connect_port = static_cast(protocol::ports::REGTEST + peer_node_id); - } - net_addr.port = connect_port; - - // Convert IP string to bytes (IPv4-mapped IPv6) - asio::error_code ec; - auto ip_addr = asio::ip::make_address(peer_addr, ec); - - if (ec) { - LOG_ERROR("SimulatedNode::ConnectToPeer: Invalid IP address: {}", peer_addr); - return false; - } - - if (ip_addr.is_v4()) { - auto v6_mapped = asio::ip::make_address_v6( - asio::ip::v4_mapped, ip_addr.to_v4()); - auto bytes = v6_mapped.to_bytes(); - std::copy(bytes.begin(), bytes.end(), net_addr.ip.begin()); - } else { - auto bytes = ip_addr.to_v6().to_bytes(); - std::copy(bytes.begin(), bytes.end(), net_addr.ip.begin()); - } - - // Use real NetworkManager to connect - auto result = network_manager_->connect_to(net_addr); - bool success = (result == network::ConnectionResult::Success); - if (success) { - stats_.connections_made++; - } - - // Process events to ensure connection is initiated - // This is important in fast (non-TSAN) builds where async operations complete quickly - ProcessEvents(); - - return success; -} - -bool SimulatedNode::ConnectToFullRelay(int peer_node_id, const std::string& address, uint16_t port) { - // Prevent self-connection - if (peer_node_id == node_id_) { - return false; - } - - // Generate peer address - std::string peer_addr = address; - if (peer_addr.empty()) { - std::ostringstream oss; - oss << "127.0.0." << (peer_node_id % 255); - peer_addr = oss.str(); - } - - // Construct NetworkAddress for new API - protocol::NetworkAddress net_addr; - net_addr.services = protocol::ServiceFlags::NODE_NETWORK; - uint16_t connect_port = port; - if (connect_port == protocol::ports::REGTEST) { - connect_port = static_cast(protocol::ports::REGTEST + peer_node_id); - } - net_addr.port = connect_port; - - // Convert IP string to bytes (IPv4-mapped IPv6) - asio::error_code ec; - auto ip_addr = asio::ip::make_address(peer_addr, ec); - - if (ec) { - LOG_ERROR("SimulatedNode::ConnectToFullRelay: Invalid IP address: {}", peer_addr); - return false; - } - - if (ip_addr.is_v4()) { - auto v6_mapped = asio::ip::make_address_v6( - asio::ip::v4_mapped, ip_addr.to_v4()); - auto bytes = v6_mapped.to_bytes(); - std::copy(bytes.begin(), bytes.end(), net_addr.ip.begin()); - } else { - auto bytes = ip_addr.to_v6().to_bytes(); - std::copy(bytes.begin(), bytes.end(), net_addr.ip.begin()); - } - - // Use connect_to with OUTBOUND_FULL_RELAY connection type (participates in ADDR relay) - auto result = network_manager_->connect_to(net_addr, network::NetPermissionFlags::None, network::ConnectionType::OUTBOUND_FULL_RELAY); - bool success = (result == network::ConnectionResult::Success); - if (success) { - stats_.connections_made++; - } - - ProcessEvents(); - return success; -} - -bool SimulatedNode::ConnectToBlockRelayOnly(int peer_node_id, const std::string& address, uint16_t port) { - // Prevent self-connection - if (peer_node_id == node_id_) { - return false; - } - - // Generate peer address - std::string peer_addr = address; - if (peer_addr.empty()) { - std::ostringstream oss; - oss << "127.0.0." << (peer_node_id % 255); - peer_addr = oss.str(); - } - - // Construct NetworkAddress for new API - protocol::NetworkAddress net_addr; - net_addr.services = protocol::ServiceFlags::NODE_NETWORK; - uint16_t connect_port = port; - if (connect_port == protocol::ports::REGTEST) { - connect_port = static_cast(protocol::ports::REGTEST + peer_node_id); - } - net_addr.port = connect_port; - - // Convert IP string to bytes (IPv4-mapped IPv6) - asio::error_code ec; - auto ip_addr = asio::ip::make_address(peer_addr, ec); - - if (ec) { - LOG_ERROR("SimulatedNode::ConnectToBlockRelayOnly: Invalid IP address: {}", peer_addr); - return false; - } - - if (ip_addr.is_v4()) { - auto v6_mapped = asio::ip::make_address_v6( - asio::ip::v4_mapped, ip_addr.to_v4()); - auto bytes = v6_mapped.to_bytes(); - std::copy(bytes.begin(), bytes.end(), net_addr.ip.begin()); - } else { - auto bytes = ip_addr.to_v6().to_bytes(); - std::copy(bytes.begin(), bytes.end(), net_addr.ip.begin()); - } - - // Use connect_to with BLOCK_RELAY connection type - auto result = network_manager_->connect_to(net_addr, network::NetPermissionFlags::None, network::ConnectionType::BLOCK_RELAY); - bool success = (result == network::ConnectionResult::Success); - if (success) { - stats_.connections_made++; - } - - ProcessEvents(); - return success; -} - -void SimulatedNode::DisconnectFrom(int peer_node_id) { - // Convert node_id to IP address, then find and disconnect the peer - if (!network_manager_) { - LOG_NET_TRACE("DisconnectFrom({}): no network_manager", peer_node_id); - return; - } - - // Get the peer's actual address from the simulated network - // This is critical for nodes with custom addresses (e.g., "192.168.1.100") - std::string peer_addr; - if (sim_network_) { - peer_addr = sim_network_->GetNodeAddress(peer_node_id); - } - // Fallback to default if not found in network - if (peer_addr.empty()) { - std::ostringstream oss; - oss << "127.0.0." << (peer_node_id % 255); - peer_addr = oss.str(); - } - LOG_NET_DEBUG("DisconnectFrom({}): looking for peer with address '{}'", peer_node_id, peer_addr); - - // Get peer ID by address (this returns the ConnectionManager map key) - auto& peer_mgr = network_manager_->peer_manager(); - - // Search all peers to find one matching this address - // We can't use find_peer_by_address() because: - // - For outbound peers: target_port = protocol::ports::REGTEST - // - For inbound peers: target_port = ephemeral source port (unknown) - // Since each node has a unique IP (127.0.0.X), search by address only - int peer_manager_id = -1; - auto all_peers = peer_mgr.get_all_peers(); - for (const auto& peer : all_peers) { - if (peer && peer->target_address() == peer_addr) { - peer_manager_id = peer->id(); - break; - } - } - - if (peer_manager_id >= 0) { - LOG_NET_DEBUG("DisconnectFrom({}): found peer_manager_id={}, disconnecting", peer_node_id, peer_manager_id); - network_manager_->disconnect_from(peer_manager_id); - stats_.disconnections++; - - // Process events to ensure disconnect is processed locally - ProcessEvents(); - - // NOTE: The remote node won't know about the disconnect until it processes - // the connection close event. The test should call AdvanceTime() and ProcessEvents() - // on the remote node after calling DisconnectFrom(). - } else { - LOG_NET_DEBUG("DisconnectFrom({}): peer NOT FOUND (addr='{}')", peer_node_id, peer_addr); - // Debug: list all peers and their addresses - for (const auto& peer : all_peers) { - if (peer) { - LOG_NET_DEBUG(" peer={} target_address='{}' target_port={} is_inbound={}", - peer->id(), peer->target_address(), peer->target_port(), peer->is_inbound()); - } - } - } -} - -uint256 SimulatedNode::MineBlock(const std::string& miner_address) { - // Create block header - CBlockHeader header; - header.nVersion = 1; - header.hashPrevBlock = GetTipHash(); - header.nTime = static_cast(sim_network_->GetCurrentTime() / 1000); - header.nBits = params_->GenesisBlock().nBits; - - // Random nonce - std::random_device rd; - std::mt19937_64 gen(rd()); - std::uniform_int_distribution dis_nonce; - header.nNonce = dis_nonce(gen); - - // Random miner address - std::uniform_int_distribution dis_byte(0, 255); - for (int i = 0; i < 20; i++) { - header.minerAddress.data()[i] = dis_byte(gen); - } - - // Set dummy RandomX hash (PoW bypass enabled by default) - header.hashRandomX.SetHex("0000000000000000000000000000000000000000000000000000000000000000"); - - // Add to chainstate - validation::ValidationState state; -auto* pindex = chainstate_->AcceptBlockHeader(header, state); - - if (pindex) { - chainstate_->TryAddBlockIndexCandidate(pindex); - chainstate_->ActivateBestChain(); - stats_.blocks_mined++; - - // Block relay is handled automatically by ChainTipEvent callback - // which announces blocks via announce_block(). No need to call it here directly. - - // Process events to ensure the block relay messages are sent - // This is important in fast (non-TSAN) builds where async operations complete quickly - ProcessEvents(); - - return header.GetHash(); - } - - return uint256(); // Failed -} - -bool SimulatedNode::MineBlocks(int count, const std::string& miner_address) { - for (int i = 0; i < count; i++) { - uint256 hash = MineBlock(miner_address); - if (hash.IsNull()) { - return false; - } - } - return true; -} - -int SimulatedNode::GetTipHeight() const { - const auto* tip = chainstate_->GetTip(); - return tip ? tip->nHeight : 0; -} - -uint256 SimulatedNode::GetTipHash() const { - const auto* tip = chainstate_->GetTip(); - return tip ? tip->GetBlockHash() : params_->GenesisBlock().GetHash(); -} - -const chain::CBlockIndex* SimulatedNode::GetTip() const { - return chainstate_->GetTip(); -} - -bool SimulatedNode::GetIsIBD() const { - return chainstate_->IsInitialBlockDownload(); -} - -uint256 SimulatedNode::GetBlockHash(int height) const { - // Need to search through the chain to find block at given height - const auto* tip = chainstate_->GetTip(); - if (!tip || height > tip->nHeight) { - return uint256(); - } - - // Walk back from tip to find block at height - const auto* pindex = tip; - while (pindex && pindex->nHeight > height) { - pindex = pindex->pprev; - } - - return (pindex && pindex->nHeight == height) ? pindex->GetBlockHash() : uint256(); -} - -CBlockHeader SimulatedNode::GetBlockHeader(const uint256& hash) const { - const auto* pindex = chainstate_->LookupBlockIndex(hash); - return pindex ? pindex->GetBlockHeader() : CBlockHeader(); -} - -void SimulatedNode::ReceiveHeaders(int from_peer_id, const std::vector& headers) { - // This simulates receiving headers from a peer - // Process headers directly through chainstate - // Headers with missing parents are skipped (no orphan pool) - (void)from_peer_id; // Unused now that orphan pool is removed - for (const auto& header : headers) { - validation::ValidationState state; - auto* pindex = chainstate_->AcceptBlockHeader(header, state); - if (pindex) { - chainstate_->TryAddBlockIndexCandidate(pindex); - } - // Headers with missing parents are simply skipped - } -} - -size_t SimulatedNode::GetPeerCount() const { - if (network_manager_) { - return network_manager_->active_peer_count(); - } - return 0; -} - -size_t SimulatedNode::GetOutboundPeerCount() const { - if (network_manager_) { - return network_manager_->outbound_peer_count(); - } - return 0; -} - -size_t SimulatedNode::GetInboundPeerCount() const { - if (network_manager_) { - return network_manager_->inbound_peer_count(); - } - return 0; -} - -bool SimulatedNode::IsBanned(const std::string& address) const { - if (network_manager_) { - return const_cast(network_manager_.get())->peer_manager().IsBanned(address); - } - return false; -} - -void SimulatedNode::Ban(const std::string& address, int64_t ban_time_seconds) { - if (network_manager_) { - network_manager_->peer_manager().Ban(address, ban_time_seconds); - } -} - -void SimulatedNode::Unban(const std::string& address) { - if (network_manager_) { - network_manager_->peer_manager().Unban(address); - } -} - -void SimulatedNode::ProcessEvents() { - // Process pending async operations - // poll() runs all ready handlers, which may post new work - // Keep polling until no more work is immediately ready - while (true) { - size_t executed = io_context_.poll(); - if (executed == 0) { - break; - } - } - -} - -void SimulatedNode::ProcessPeriodic() { - // Run periodic maintenance tasks - // In a real node, these run on timers, but in simulation they're triggered by AdvanceTime() - if (network_manager_) { - // Trigger initial sync selection deterministically (no timers) - NetworkManagerTestAccess::CheckInitialSync(*network_manager_); - - network_manager_->peer_manager().process_periodic(); - - // Process pending ADDR relays (trickle delay for privacy) - network_manager_->discovery_manager().ProcessPendingAddrRelays(); - - // NOTE: We intentionally do NOT call announce_tip_to_peers() here. - // In production, tip announcements only happen via ChainTipEvent (gated by is_initial_download). - // This matches Bitcoin Core's event-driven relay model. - } -} - -// Test hook wrappers using NetworkManagerTestAccess -void SimulatedNode::CheckInitialSync() { - if (network_manager_) { - NetworkManagerTestAccess::CheckInitialSync(*network_manager_); - } -} - -void SimulatedNode::ProcessHeaderSyncTimers() { - if (network_manager_) { - NetworkManagerTestAccess::ProcessHeaderSyncTimers(*network_manager_); - } -} - -void SimulatedNode::TriggerSelfAdvertisement() { - if (network_manager_) { - NetworkManagerTestAccess::TriggerSelfAdvertisement(*network_manager_); - } -} - -void SimulatedNode::AttemptFeelerConnection() { - if (network_manager_) { - NetworkManagerTestAccess::AttemptFeelerConnection(*network_manager_); - } -} - -network::AddrRelayManager& SimulatedNode::GetDiscoveryManager() { - return NetworkManagerTestAccess::GetDiscoveryManager(*network_manager_); -} - -network::HeaderSyncManager& SimulatedNode::GetHeaderSync() { - return NetworkManagerTestAccess::GetHeaderSync(*network_manager_); -} - -void SimulatedNode::SetInboundPermissions(network::NetPermissionFlags flags) { - if (network_manager_) { - NetworkManagerTestAccess::SetDefaultInboundPermissions(*network_manager_, flags); - } -} - -} // namespace test -} // namespace unicity +// Copyright (c) 2025 The Unicity Foundation +// SimulatedNode implementation - Uses REAL P2P components with simulated transport + +#include "simulated_node.hpp" +#include "test_access.hpp" +#include +#include +#include "chain/block.hpp" +#include "chain/chainstate_manager.hpp" +#include "util/logging.hpp" +#include "chain/pow.hpp" +#include "chain/randomx_pow.hpp" +#include "util/sha256.hpp" +#include "chain/validation.hpp" +#include "network/connection_types.hpp" +#include "network/addr_relay_manager.hpp" + +namespace unicity { +namespace test { + +using unicity::validation::ValidationState; + +// Helper to generate default address from node_id +static std::string GenerateDefaultAddress(int node_id) { + std::ostringstream oss; + oss << "127.0.0." << (node_id % 255); + return oss.str(); +} + +// Common initialization logic used by all constructors +void SimulatedNode::InitializeNode(const chain::ChainParams* params) { + // Setup chain params + if (params) { + params_ = params; + } else { + params_owned_ = chain::ChainParams::CreateRegTest(); + params_ = params_owned_.get(); + } + + // Initialize chainstate with genesis + chainstate_ = std::make_unique(*params_); + chainstate_->Initialize(params_->GenesisBlock()); + + // Create work guard to keep io_context alive + work_guard_ = std::make_unique>( + asio::make_work_guard(io_context_) + ); + + // Initialize networking + InitializeNetworking(); + + // Re-register with SimulatedNetwork to include node pointer for event processing and transport + if (sim_network_ && transport_) { + sim_network_->RegisterNode(node_id_, [this](int from_node_id, const std::vector& data) { + if (transport_) { + transport_->deliver_message(from_node_id, data); + } + }, this, transport_.get()); + } +} + +// Standard constructor - auto-generates address +SimulatedNode::SimulatedNode(int node_id, + SimulatedNetwork* network, + const chain::ChainParams* params) + : node_id_(node_id) + , port_(protocol::ports::REGTEST + node_id) + , sim_network_(network) + , address_(GenerateDefaultAddress(node_id)) +{ + InitializeNode(params); +} + +// Constructor with custom address - for testing netgroup diversity +SimulatedNode::SimulatedNode(int node_id, + SimulatedNetwork* network, + const std::string& custom_address, + const chain::ChainParams* params) + : node_id_(node_id) + , port_(protocol::ports::REGTEST + node_id) + , sim_network_(network) + , address_(custom_address) +{ + InitializeNode(params); +} + +// Constructor with IO threads override +SimulatedNode::SimulatedNode(int node_id, + SimulatedNetwork* network, + const chain::ChainParams* params, + size_t io_threads_override) + : node_id_(node_id) + , port_(protocol::ports::REGTEST + node_id) + , sim_network_(network) + , address_(GenerateDefaultAddress(node_id)) + , io_threads_override_(io_threads_override) +{ + InitializeNode(params); +} + +// Full constructor with all options +SimulatedNode::SimulatedNode(int node_id, + SimulatedNetwork* network, + const std::string& custom_address, + const chain::ChainParams* params, + size_t io_threads_override) + : node_id_(node_id) + , port_(protocol::ports::REGTEST + node_id) + , sim_network_(network) + , address_(custom_address) + , io_threads_override_(io_threads_override) +{ + InitializeNode(params); +} + +SimulatedNode::~SimulatedNode() { + // Unsubscribe from notifications before stopping network + tip_sub_.Unsubscribe(); + + // Stop networking + if (network_manager_) { + network_manager_->stop(); + } + + // Release work guard to allow io_context to finish + work_guard_.reset(); + + // Process remaining events + io_context_.run(); +} + +void SimulatedNode::InitializeNetworking() { + // Create bridged transport that routes through SimulatedNetwork + transport_ = std::make_shared(node_id_, sim_network_); + + // Create NetworkManager with our transport + network::NetworkManager::Config config; + config.network_magic = params_->GetNetworkMagic(); + config.listen_enabled = true; + config.listen_port = port_; + config.io_threads = io_threads_override_; // 0 by default (deterministic); tests may override + config.enable_nat = false; // Disable NAT/UPnP in tests (would block trying to discover devices) + + // CRITICAL: Set unique test_nonce for each node to prevent self-connection rejection + // In multi-node tests, each SimulatedNode needs a unique nonce (node_id + offset) + // Setting test_nonce disables process-wide nonce, and each peer gets this value + // via set_local_nonce() in ConnectionManager, ensuring different nodes can connect + config.test_nonce = static_cast(node_id_) + 1000000; + + // Create shared_ptr wrapper for io_context (NetworkManager requires shared ownership) + // Use aliasing constructor with no-op deleter since SimulatedNode owns the io_context + auto io_context_ptr = std::shared_ptr( + std::shared_ptr{}, &io_context_ + ); + + network_manager_ = std::make_unique( + *chainstate_, // Pass TestChainstateManager (inherits from ChainstateManager) + config, + transport_, + io_context_ptr // Pass shared_ptr to our io_context + ); + + // Start networking + if (!network_manager_->start()) { + throw std::runtime_error("Failed to start NetworkManager"); + } + + // Subscribe to chain tip changes to relay new blocks (mirrors Application behavior) + // IMPORTANT: Notifications() is a global singleton shared by all SimulatedNodes. + // We must verify the event matches OUR chainstate before relaying. + tip_sub_ = Notifications().SubscribeChainTip( + [this](const ChainTipEvent& event) { + // Verify this event is for OUR chainstate (not another node's) + const auto* our_tip = chainstate_ ? chainstate_->GetTip() : nullptr; + if (!our_tip || our_tip->GetBlockHash() != event.hash) { + return; // Event is from another node's chainstate + } + // Skip tip announcement during IBD (matches Bitcoin Core's UpdatedBlockTip behavior) + if (event.is_initial_download) { + return; + } + // Announce new tip to all connected peers via direct HEADERS + if (network_manager_) { + network_manager_->announce_block(event.hash); + } + }); +} + +std::string SimulatedNode::GetAddress() const { + return address_; +} + +bool SimulatedNode::ConnectTo(int peer_node_id, const std::string& address, uint16_t port) { + // Prevent self-connection + if (peer_node_id == node_id_) { + return false; + } + + // Generate peer address + std::string peer_addr = address; + if (peer_addr.empty()) { + std::ostringstream oss; + oss << "127.0.0." << (peer_node_id % 255); + peer_addr = oss.str(); + } + +// Construct NetworkAddress for new API + protocol::NetworkAddress net_addr; + net_addr.services = protocol::ServiceFlags::NODE_NETWORK; + // Default to the peer's simulated listen port if caller passed the base regtest port + uint16_t connect_port = port; + if (connect_port == protocol::ports::REGTEST) { + // In simulation, each node listens on REGTEST + node_id + connect_port = static_cast(protocol::ports::REGTEST + peer_node_id); + } + net_addr.port = connect_port; + + // Convert IP string to bytes (IPv4-mapped IPv6) + asio::error_code ec; + auto ip_addr = asio::ip::make_address(peer_addr, ec); + + if (ec) { + LOG_ERROR("SimulatedNode::ConnectToPeer: Invalid IP address: {}", peer_addr); + return false; + } + + if (ip_addr.is_v4()) { + auto v6_mapped = asio::ip::make_address_v6( + asio::ip::v4_mapped, ip_addr.to_v4()); + auto bytes = v6_mapped.to_bytes(); + std::copy(bytes.begin(), bytes.end(), net_addr.ip.begin()); + } else { + auto bytes = ip_addr.to_v6().to_bytes(); + std::copy(bytes.begin(), bytes.end(), net_addr.ip.begin()); + } + + // Use real NetworkManager to connect + auto result = network_manager_->connect_to(net_addr); + bool success = (result == network::ConnectionResult::Success); + if (success) { + stats_.connections_made++; + } + + // Process events to ensure connection is initiated + // This is important in fast (non-TSAN) builds where async operations complete quickly + ProcessEvents(); + + return success; +} + +bool SimulatedNode::ConnectToFullRelay(int peer_node_id, const std::string& address, uint16_t port) { + // Prevent self-connection + if (peer_node_id == node_id_) { + return false; + } + + // Generate peer address + std::string peer_addr = address; + if (peer_addr.empty()) { + std::ostringstream oss; + oss << "127.0.0." << (peer_node_id % 255); + peer_addr = oss.str(); + } + + // Construct NetworkAddress for new API + protocol::NetworkAddress net_addr; + net_addr.services = protocol::ServiceFlags::NODE_NETWORK; + uint16_t connect_port = port; + if (connect_port == protocol::ports::REGTEST) { + connect_port = static_cast(protocol::ports::REGTEST + peer_node_id); + } + net_addr.port = connect_port; + + // Convert IP string to bytes (IPv4-mapped IPv6) + asio::error_code ec; + auto ip_addr = asio::ip::make_address(peer_addr, ec); + + if (ec) { + LOG_ERROR("SimulatedNode::ConnectToFullRelay: Invalid IP address: {}", peer_addr); + return false; + } + + if (ip_addr.is_v4()) { + auto v6_mapped = asio::ip::make_address_v6( + asio::ip::v4_mapped, ip_addr.to_v4()); + auto bytes = v6_mapped.to_bytes(); + std::copy(bytes.begin(), bytes.end(), net_addr.ip.begin()); + } else { + auto bytes = ip_addr.to_v6().to_bytes(); + std::copy(bytes.begin(), bytes.end(), net_addr.ip.begin()); + } + + // Use connect_to with OUTBOUND_FULL_RELAY connection type (participates in ADDR relay) + auto result = network_manager_->connect_to(net_addr, network::NetPermissionFlags::None, network::ConnectionType::OUTBOUND_FULL_RELAY); + bool success = (result == network::ConnectionResult::Success); + if (success) { + stats_.connections_made++; + } + + ProcessEvents(); + return success; +} + +bool SimulatedNode::ConnectToBlockRelayOnly(int peer_node_id, const std::string& address, uint16_t port) { + // Prevent self-connection + if (peer_node_id == node_id_) { + return false; + } + + // Generate peer address + std::string peer_addr = address; + if (peer_addr.empty()) { + std::ostringstream oss; + oss << "127.0.0." << (peer_node_id % 255); + peer_addr = oss.str(); + } + + // Construct NetworkAddress for new API + protocol::NetworkAddress net_addr; + net_addr.services = protocol::ServiceFlags::NODE_NETWORK; + uint16_t connect_port = port; + if (connect_port == protocol::ports::REGTEST) { + connect_port = static_cast(protocol::ports::REGTEST + peer_node_id); + } + net_addr.port = connect_port; + + // Convert IP string to bytes (IPv4-mapped IPv6) + asio::error_code ec; + auto ip_addr = asio::ip::make_address(peer_addr, ec); + + if (ec) { + LOG_ERROR("SimulatedNode::ConnectToBlockRelayOnly: Invalid IP address: {}", peer_addr); + return false; + } + + if (ip_addr.is_v4()) { + auto v6_mapped = asio::ip::make_address_v6( + asio::ip::v4_mapped, ip_addr.to_v4()); + auto bytes = v6_mapped.to_bytes(); + std::copy(bytes.begin(), bytes.end(), net_addr.ip.begin()); + } else { + auto bytes = ip_addr.to_v6().to_bytes(); + std::copy(bytes.begin(), bytes.end(), net_addr.ip.begin()); + } + + // Use connect_to with BLOCK_RELAY connection type + auto result = network_manager_->connect_to(net_addr, network::NetPermissionFlags::None, network::ConnectionType::BLOCK_RELAY); + bool success = (result == network::ConnectionResult::Success); + if (success) { + stats_.connections_made++; + } + + ProcessEvents(); + return success; +} + +void SimulatedNode::DisconnectFrom(int peer_node_id) { + // Convert node_id to IP address, then find and disconnect the peer + if (!network_manager_) { + LOG_NET_TRACE("DisconnectFrom({}): no network_manager", peer_node_id); + return; + } + + // Get the peer's actual address from the simulated network + // This is critical for nodes with custom addresses (e.g., "192.168.1.100") + std::string peer_addr; + if (sim_network_) { + peer_addr = sim_network_->GetNodeAddress(peer_node_id); + } + // Fallback to default if not found in network + if (peer_addr.empty()) { + std::ostringstream oss; + oss << "127.0.0." << (peer_node_id % 255); + peer_addr = oss.str(); + } + LOG_NET_DEBUG("DisconnectFrom({}): looking for peer with address '{}'", peer_node_id, peer_addr); + + // Get peer ID by address (this returns the ConnectionManager map key) + auto& peer_mgr = network_manager_->peer_manager(); + + // Search all peers to find one matching this address + // We can't use find_peer_by_address() because: + // - For outbound peers: target_port = protocol::ports::REGTEST + // - For inbound peers: target_port = ephemeral source port (unknown) + // Since each node has a unique IP (127.0.0.X), search by address only + int peer_manager_id = -1; + auto all_peers = peer_mgr.get_all_peers(); + for (const auto& peer : all_peers) { + if (peer && peer->target_address() == peer_addr) { + peer_manager_id = peer->id(); + break; + } + } + + if (peer_manager_id >= 0) { + LOG_NET_DEBUG("DisconnectFrom({}): found peer_manager_id={}, disconnecting", peer_node_id, peer_manager_id); + network_manager_->disconnect_from(peer_manager_id); + stats_.disconnections++; + + // Process events to ensure disconnect is processed locally + ProcessEvents(); + + // NOTE: The remote node won't know about the disconnect until it processes + // the connection close event. The test should call AdvanceTime() and ProcessEvents() + // on the remote node after calling DisconnectFrom(). + } else { + LOG_NET_DEBUG("DisconnectFrom({}): peer NOT FOUND (addr='{}')", peer_node_id, peer_addr); + // Debug: list all peers and their addresses + for (const auto& peer : all_peers) { + if (peer) { + LOG_NET_DEBUG(" peer={} target_address='{}' target_port={} is_inbound={}", + peer->id(), peer->target_address(), peer->target_port(), peer->is_inbound()); + } + } + } +} + +uint256 SimulatedNode::MineBlock(const std::string& payload_root) { + // Create block header + CBlockHeader header; + header.nVersion = 1; + header.hashPrevBlock = GetTipHash(); + header.nTime = static_cast(sim_network_->GetCurrentTime() / 1000); + header.nBits = params_->GenesisBlock().nBits; + + // Random nonce + std::random_device rd; + std::mt19937_64 gen(rd()); + std::uniform_int_distribution dis_nonce; + header.nNonce = dis_nonce(gen); + + // Random miner address + std::uniform_int_distribution dis_byte(0, 255); + for (int i = 0; i < 20; i++) { + header.payloadRoot.data()[i] = dis_byte(gen); + } + + // Set dummy RandomX hash (PoW bypass enabled by default) + header.hashRandomX.SetHex("0000000000000000000000000000000000000000000000000000000000000000"); + + // Add to chainstate + validation::ValidationState state; +auto* pindex = chainstate_->AcceptBlockHeader(header, state); + + if (pindex) { + chainstate_->TryAddBlockIndexCandidate(pindex); + chainstate_->ActivateBestChain(); + stats_.blocks_mined++; + + // Block relay is handled automatically by ChainTipEvent callback + // which announces blocks via announce_block(). No need to call it here directly. + + // Process events to ensure the block relay messages are sent + // This is important in fast (non-TSAN) builds where async operations complete quickly + ProcessEvents(); + + return header.GetHash(); + } + + return uint256(); // Failed +} + +bool SimulatedNode::MineBlocks(int count, const std::string& payload_root) { + for (int i = 0; i < count; i++) { + uint256 hash = MineBlock(payload_root); + if (hash.IsNull()) { + return false; + } + } + return true; +} + +int SimulatedNode::GetTipHeight() const { + const auto* tip = chainstate_->GetTip(); + return tip ? tip->nHeight : 0; +} + +uint256 SimulatedNode::GetTipHash() const { + const auto* tip = chainstate_->GetTip(); + return tip ? tip->GetBlockHash() : params_->GenesisBlock().GetHash(); +} + +const chain::CBlockIndex* SimulatedNode::GetTip() const { + return chainstate_->GetTip(); +} + +bool SimulatedNode::GetIsIBD() const { + return chainstate_->IsInitialBlockDownload(); +} + +uint256 SimulatedNode::GetBlockHash(int height) const { + // Need to search through the chain to find block at given height + const auto* tip = chainstate_->GetTip(); + if (!tip || height > tip->nHeight) { + return uint256(); + } + + // Walk back from tip to find block at height + const auto* pindex = tip; + while (pindex && pindex->nHeight > height) { + pindex = pindex->pprev; + } + + return (pindex && pindex->nHeight == height) ? pindex->GetBlockHash() : uint256(); +} + +CBlockHeader SimulatedNode::GetBlockHeader(const uint256& hash) const { + const auto* pindex = chainstate_->LookupBlockIndex(hash); + return pindex ? pindex->GetBlockHeader() : CBlockHeader(); +} + +void SimulatedNode::ReceiveHeaders(int from_peer_id, const std::vector& headers) { + // This simulates receiving headers from a peer + // Process headers directly through chainstate + // Headers with missing parents are skipped (no orphan pool) + (void)from_peer_id; // Unused now that orphan pool is removed + for (const auto& header : headers) { + validation::ValidationState state; + auto* pindex = chainstate_->AcceptBlockHeader(header, state); + if (pindex) { + chainstate_->TryAddBlockIndexCandidate(pindex); + } + // Headers with missing parents are simply skipped + } +} + +size_t SimulatedNode::GetPeerCount() const { + if (network_manager_) { + return network_manager_->active_peer_count(); + } + return 0; +} + +size_t SimulatedNode::GetOutboundPeerCount() const { + if (network_manager_) { + return network_manager_->outbound_peer_count(); + } + return 0; +} + +size_t SimulatedNode::GetInboundPeerCount() const { + if (network_manager_) { + return network_manager_->inbound_peer_count(); + } + return 0; +} + +bool SimulatedNode::IsBanned(const std::string& address) const { + if (network_manager_) { + return const_cast(network_manager_.get())->peer_manager().IsBanned(address); + } + return false; +} + +void SimulatedNode::Ban(const std::string& address, int64_t ban_time_seconds) { + if (network_manager_) { + network_manager_->peer_manager().Ban(address, ban_time_seconds); + } +} + +void SimulatedNode::Unban(const std::string& address) { + if (network_manager_) { + network_manager_->peer_manager().Unban(address); + } +} + +void SimulatedNode::ProcessEvents() { + // Process pending async operations + // poll() runs all ready handlers, which may post new work + // Keep polling until no more work is immediately ready + while (true) { + size_t executed = io_context_.poll(); + if (executed == 0) { + break; + } + } + +} + +void SimulatedNode::ProcessPeriodic() { + // Run periodic maintenance tasks + // In a real node, these run on timers, but in simulation they're triggered by AdvanceTime() + if (network_manager_) { + // Trigger initial sync selection deterministically (no timers) + NetworkManagerTestAccess::CheckInitialSync(*network_manager_); + + network_manager_->peer_manager().process_periodic(); + + // Process pending ADDR relays (trickle delay for privacy) + network_manager_->discovery_manager().ProcessPendingAddrRelays(); + + // NOTE: We intentionally do NOT call announce_tip_to_peers() here. + // In production, tip announcements only happen via ChainTipEvent (gated by is_initial_download). + // This matches Bitcoin Core's event-driven relay model. + } +} + +// Test hook wrappers using NetworkManagerTestAccess +void SimulatedNode::CheckInitialSync() { + if (network_manager_) { + NetworkManagerTestAccess::CheckInitialSync(*network_manager_); + } +} + +void SimulatedNode::ProcessHeaderSyncTimers() { + if (network_manager_) { + NetworkManagerTestAccess::ProcessHeaderSyncTimers(*network_manager_); + } +} + +void SimulatedNode::TriggerSelfAdvertisement() { + if (network_manager_) { + NetworkManagerTestAccess::TriggerSelfAdvertisement(*network_manager_); + } +} + +void SimulatedNode::AttemptFeelerConnection() { + if (network_manager_) { + NetworkManagerTestAccess::AttemptFeelerConnection(*network_manager_); + } +} + +network::AddrRelayManager& SimulatedNode::GetDiscoveryManager() { + return NetworkManagerTestAccess::GetDiscoveryManager(*network_manager_); +} + +network::HeaderSyncManager& SimulatedNode::GetHeaderSync() { + return NetworkManagerTestAccess::GetHeaderSync(*network_manager_); +} + +void SimulatedNode::SetInboundPermissions(network::NetPermissionFlags flags) { + if (network_manager_) { + NetworkManagerTestAccess::SetDefaultInboundPermissions(*network_manager_, flags); + } +} + +} // namespace test +} // namespace unicity diff --git a/test/integration-network/infra/simulated_node.hpp b/test/integration-network/infra/simulated_node.hpp index 9e604f4..b56ec1b 100644 --- a/test/integration-network/infra/simulated_node.hpp +++ b/test/integration-network/infra/simulated_node.hpp @@ -67,8 +67,8 @@ class SimulatedNode : public SimulatedNetwork::ISimulatedNode { void Disconnect(int peer_id) { DisconnectFrom(peer_id); } // Alias // Mining (instant, no PoW) - uint256 MineBlock(const std::string& miner_address = "test_miner"); - bool MineBlocks(int count, const std::string& miner_address = "test_miner"); + uint256 MineBlock(const std::string& payload_root = "test_miner"); + bool MineBlocks(int count, const std::string& payload_root = "test_miner"); // Blockchain state int GetTipHeight() const; diff --git a/test/integration-network/manager/header_sync_adversarial_tests.cpp b/test/integration-network/manager/header_sync_adversarial_tests.cpp index 8219df7..a9a98f3 100644 --- a/test/integration-network/manager/header_sync_adversarial_tests.cpp +++ b/test/integration-network/manager/header_sync_adversarial_tests.cpp @@ -1055,7 +1055,7 @@ TEST_CASE("HeaderSync: Full batch of duplicate active-chain headers must NOT tri // Fix: Only request continuation when pindexLast->nHeight > tip_before->nHeight. // Duplicate headers from earlier in the chain will have pindexLast below tip, blocking continuation. // - // Cost to attacker: ~8 MB per iteration (80,000 headers x 100 bytes) + // Cost to attacker: ~9 MB per iteration (80,000 headers x 112 bytes) // Cost to victim: 80,000 hash lookups + CPU per iteration // Use a smaller batch for test speed. We override continuation_threshold_ @@ -1318,7 +1318,7 @@ class MinWorkParams : public chain::ChainParams { consensus.nNetworkExpirationGracePeriod = 0; consensus.nSuspiciousReorgDepth = 100; nDefaultPort = 29590; - genesis = chain::CreateGenesisBlock(1296688602, 2, 0x207fffff, 1); + genesis = chain::CreateGenesisBlock(1296688602, 2, 0x207fffff, chain::GlobalChainParams::Get().GenesisBlock().GetUTB(), 1); consensus.hashGenesisBlock = genesis.GetHash(); } }; diff --git a/test/integration-network/peer/adversarial_tests.cpp b/test/integration-network/peer/adversarial_tests.cpp index dc31b2a..a5d8cd1 100644 --- a/test/integration-network/peer/adversarial_tests.cpp +++ b/test/integration-network/peer/adversarial_tests.cpp @@ -1,2749 +1,2749 @@ -// Adversarial tests for network/peer.cpp - Attack scenarios and edge cases (ported to test2) - -#include "catch_amalgamated.hpp" -#include "network/peer.hpp" -#include "network/transport.hpp" -#include "network/real_transport.hpp" -#include "network/protocol.hpp" -#include "network/message.hpp" -#include "util/hash.hpp" -#include -#include -#include -#include -#include -#include -#include -#include -#include - -using namespace unicity; -using namespace unicity::network; - -// ============================================================================= -// MOCK TRANSPORT (from legacy tests) -// ============================================================================= - -#include "infra/mock_transport.hpp" - -// ============================================================================= -// HELPERS -// ============================================================================= - -static std::vector create_test_message( - uint32_t magic, - const std::string& command, - const std::vector& payload) -{ - protocol::MessageHeader header(magic, command, static_cast(payload.size())); - uint256 hash = Hash(payload); - std::memcpy(header.checksum.data(), hash.begin(), 4); - auto header_bytes = message::serialize_header(header); - - std::vector full_message; - full_message.insert(full_message.end(), header_bytes.begin(), header_bytes.end()); - full_message.insert(full_message.end(), payload.begin(), payload.end()); - return full_message; -} - -static std::vector create_version_message(uint32_t magic, uint64_t nonce) { - message::VersionMessage msg; - msg.version = protocol::PROTOCOL_VERSION; - msg.services = protocol::NODE_NETWORK; - msg.timestamp = 1234567890; - msg.nonce = nonce; - msg.user_agent = "/Test:1.0.0/"; - msg.start_height = 0; - - auto payload = msg.serialize(); - return create_test_message(magic, protocol::commands::VERSION, payload); -} - -static std::vector create_verack_message(uint32_t magic) { - message::VerackMessage msg; - auto payload = msg.serialize(); - return create_test_message(magic, protocol::commands::VERACK, payload); -} - -static std::vector create_ping_message(uint32_t magic, uint64_t nonce) { - message::PingMessage msg(nonce); - auto payload = msg.serialize(); - return create_test_message(magic, protocol::commands::PING, payload); -} - -static std::vector create_pong_message(uint32_t magic, uint64_t nonce) { - message::PongMessage msg(nonce); - auto payload = msg.serialize(); - return create_test_message(magic, protocol::commands::PONG, payload); -} - -// ============================================================================= -// MALFORMED MESSAGE ATTACKS -// ============================================================================= - -TEST_CASE("Adversarial - PartialHeaderAttack", "[adversarial][malformed]") { - asio::io_context io_context; - auto mock_conn = std::make_shared(); - const uint32_t magic = protocol::magic::REGTEST; - - auto peer = Peer::create_inbound(io_context, mock_conn, magic, 0); - peer->start(); - io_context.poll(); - - SECTION("Partial header (only magic bytes)") { - std::vector partial_header(4); - std::memcpy(partial_header.data(), &magic, 4); - - mock_conn->simulate_receive(partial_header); - io_context.poll(); - - CHECK(peer->is_connected()); - CHECK(peer->version() == 0); - } - - SECTION("Partial header then timeout") { - std::vector partial_header(12); // Only 12 of 24 header bytes - mock_conn->simulate_receive(partial_header); - io_context.poll(); - CHECK(peer->is_connected()); - } -} - -TEST_CASE("Adversarial - HeaderLengthMismatch", "[adversarial][malformed]") { - asio::io_context io_context; - auto mock_conn = std::make_shared(); - const uint32_t magic = protocol::magic::REGTEST; - - auto peer = Peer::create_inbound(io_context, mock_conn, magic, 0); - peer->start(); - io_context.poll(); - - SECTION("Header claims 100 bytes, send 50 bytes") { - protocol::MessageHeader header(magic, protocol::commands::VERSION, 100); - header.checksum = [&](const auto& data) { uint256 hash = Hash(data); std::array checksum; std::memcpy(checksum.data(), hash.begin(), 4); return checksum; }(std::vector(100, 0)); - auto header_bytes = message::serialize_header(header); - std::vector partial_payload(50, 0xAA); - std::vector malicious_msg; - malicious_msg.insert(malicious_msg.end(), header_bytes.begin(), header_bytes.end()); - malicious_msg.insert(malicious_msg.end(), partial_payload.begin(), partial_payload.end()); - mock_conn->simulate_receive(malicious_msg); - io_context.poll(); - CHECK(peer->is_connected()); - CHECK(peer->version() == 0); - } - - SECTION("Header claims 0 bytes, send 100 bytes") { - protocol::MessageHeader header(magic, protocol::commands::VERSION, 0); - header.checksum.fill(0); - auto header_bytes = message::serialize_header(header); - std::vector unexpected_payload(100, 0xBB); - std::vector malicious_msg; - malicious_msg.insert(malicious_msg.end(), header_bytes.begin(), header_bytes.end()); - malicious_msg.insert(malicious_msg.end(), unexpected_payload.begin(), unexpected_payload.end()); - mock_conn->simulate_receive(malicious_msg); - io_context.poll(); - CHECK(peer->state() == PeerConnectionState::DISCONNECTED); - } -} - -TEST_CASE("Adversarial - EmptyCommandField", "[adversarial][malformed]") { - asio::io_context io_context; - auto mock_conn = std::make_shared(); - const uint32_t magic = protocol::magic::REGTEST; - - auto peer = Peer::create_inbound(io_context, mock_conn, magic, 0); - peer->start(); - io_context.poll(); - - protocol::MessageHeader header; - header.magic = magic; - header.command.fill(0); - header.length = 0; - header.checksum.fill(0); - - auto header_bytes = message::serialize_header(header); - mock_conn->simulate_receive(header_bytes); - io_context.poll(); - - CHECK(peer->state() == PeerConnectionState::DISCONNECTED); -} - -TEST_CASE("Adversarial - NonPrintableCommandCharacters", "[adversarial][malformed]") { - asio::io_context io_context; - auto mock_conn = std::make_shared(); - const uint32_t magic = protocol::magic::REGTEST; - - auto peer = Peer::create_inbound(io_context, mock_conn, magic, 0); - peer->start(); - io_context.poll(); - - protocol::MessageHeader header; - header.magic = magic; - header.command = { static_cast(0xFF), static_cast(0xFE), static_cast(0xFD), static_cast(0xFC), - static_cast(0xFB), static_cast(0xFA), static_cast(0xF9), static_cast(0xF8), - static_cast(0xF7), static_cast(0xF6), static_cast(0xF5), static_cast(0xF4) }; - header.length = 0; - header.checksum.fill(0); - - auto header_bytes = message::serialize_header(header); - mock_conn->simulate_receive(header_bytes); - io_context.poll(); - - CHECK(peer->state() == PeerConnectionState::DISCONNECTED); -} - -// ============================================================================= -// PROTOCOL STATE MACHINE ATTACKS -// ============================================================================= - -TEST_CASE("Adversarial - RapidVersionFlood", "[adversarial][flood]") { - asio::io_context io_context; - auto mock_conn = std::make_shared(); - const uint32_t magic = protocol::magic::REGTEST; - - auto peer = Peer::create_inbound(io_context, mock_conn, magic, 0); - peer->start(); - io_context.poll(); - - auto version1 = create_version_message(magic, 54321); - mock_conn->simulate_receive(version1); - io_context.poll(); - - CHECK(peer->version() == protocol::PROTOCOL_VERSION); - CHECK(peer->peer_nonce() == 54321); - - for (int i = 0; i < 99; i++) { - auto version_dup = create_version_message(magic, 99999 + i); - mock_conn->simulate_receive(version_dup); - io_context.poll(); - } - - CHECK(peer->version() == protocol::PROTOCOL_VERSION); - CHECK(peer->peer_nonce() == 54321); - CHECK(peer->is_connected()); -} - -TEST_CASE("Adversarial - RapidVerackFlood", "[adversarial][flood]") { - asio::io_context io_context; - auto mock_conn = std::make_shared(); - const uint32_t magic = protocol::magic::REGTEST; - - auto peer = Peer::create_outbound(io_context, mock_conn, magic, 0); - peer->start(); - io_context.poll(); - - auto version = create_version_message(magic, 54321); - mock_conn->simulate_receive(version); - io_context.poll(); - - auto verack1 = create_verack_message(magic); - mock_conn->simulate_receive(verack1); - io_context.poll(); - - CHECK(peer->state() == PeerConnectionState::READY); - - for (int i = 0; i < 99; i++) { - auto verack_dup = create_verack_message(magic); - mock_conn->simulate_receive(verack_dup); - io_context.poll(); - } - - CHECK(peer->state() == PeerConnectionState::READY); - CHECK(peer->is_connected()); -} - -TEST_CASE("Adversarial - AlternatingVersionVerack", "[adversarial][protocol]") { - asio::io_context io_context; - auto mock_conn = std::make_shared(); - const uint32_t magic = protocol::magic::REGTEST; - - auto peer = Peer::create_inbound(io_context, mock_conn, magic, 0); - peer->start(); - io_context.poll(); - - for (int i = 0; i < 10; i++) { - auto version = create_version_message(magic, 50000 + i); - mock_conn->simulate_receive(version); - io_context.poll(); - if (!peer->is_connected()) break; - auto verack = create_verack_message(magic); - mock_conn->simulate_receive(verack); - io_context.poll(); - if (!peer->is_connected()) break; - } - - CHECK(peer->state() == PeerConnectionState::READY); - CHECK(peer->peer_nonce() == 50000); -} - -// ============================================================================= -// RESOURCE EXHAUSTION ATTACKS -// ============================================================================= - -TEST_CASE("Adversarial - SlowDataDrip", "[adversarial][resource]") { - asio::io_context io_context; - auto mock_conn = std::make_shared(); - const uint32_t magic = protocol::magic::REGTEST; - - auto peer = Peer::create_inbound(io_context, mock_conn, magic, 0); - peer->start(); - io_context.poll(); - - auto version = create_version_message(magic, 54321); - for (size_t i = 0; i < version.size(); i++) { - std::vector single_byte = {version[i]}; - mock_conn->simulate_receive(single_byte); - io_context.poll(); - } - - CHECK(peer->version() == protocol::PROTOCOL_VERSION); - CHECK(peer->is_connected()); -} - -TEST_CASE("Adversarial - MultiplePartialMessages", "[adversarial][resource]") { - asio::io_context io_context; - auto mock_conn = std::make_shared(); - const uint32_t magic = protocol::magic::REGTEST; - - auto peer = Peer::create_inbound(io_context, mock_conn, magic, 0); - peer->start(); - io_context.poll(); - - // Send partial headers (12 bytes each containing 0xCC) - // After 2 iterations, buffer has 24 bytes → triggers header parse - // Magic bytes 0xCCCCCCCC don't match REGTEST → disconnect - for (int i = 0; i < 10; i++) { - std::vector partial_header(12, 0xCC); - mock_conn->simulate_receive(partial_header); - io_context.poll(); - if (!peer->is_connected()) { - break; - } - } - - // NOTE: This test validates wrong magic detection, not partial message handling. - // Partial headers < 24 bytes are tolerated (buffered until complete). - // At 24 bytes, header is parsed and bad magic (0xCCCCCCCC) triggers disconnect. - CHECK(peer->state() == PeerConnectionState::DISCONNECTED); -} - -TEST_CASE("Adversarial - BufferFragmentation", "[adversarial][resource]") { - asio::io_context io_context; - auto mock_conn = std::make_shared(); - const uint32_t magic = protocol::magic::REGTEST; - - auto peer = Peer::create_inbound(io_context, mock_conn, magic, 0); - peer->start(); - io_context.poll(); - - auto version = create_version_message(magic, 54321); - mock_conn->simulate_receive(version); - io_context.poll(); - CHECK(peer->version() == protocol::PROTOCOL_VERSION); - - auto verack = create_verack_message(magic); - mock_conn->simulate_receive(verack); - io_context.poll(); - REQUIRE(peer->state() == PeerConnectionState::READY); - - auto bad_ping = create_ping_message(0xBADBAD, 99999); - mock_conn->simulate_receive(bad_ping); - io_context.poll(); - - CHECK(peer->state() == PeerConnectionState::DISCONNECTED); -} - -// ============================================================================= -// TIMING ATTACKS -// ============================================================================= - -TEST_CASE("Adversarial - ExtremeTimestamps", "[adversarial][timing]") { - asio::io_context io_context; - auto mock_conn = std::make_shared(); - const uint32_t magic = protocol::magic::REGTEST; - - auto peer = Peer::create_inbound(io_context, mock_conn, magic, 0); - peer->start(); - io_context.poll(); - - SECTION("Timestamp = 0 (January 1970)") { - message::VersionMessage msg; - msg.version = protocol::PROTOCOL_VERSION; - msg.services = protocol::NODE_NETWORK; - msg.timestamp = 0; - msg.nonce = 54321; - msg.user_agent = "/Test:1.0.0/"; - msg.start_height = 0; - auto payload = msg.serialize(); - auto full_msg = create_test_message(magic, protocol::commands::VERSION, payload); - mock_conn->simulate_receive(full_msg); - io_context.poll(); - CHECK(peer->version() == protocol::PROTOCOL_VERSION); - CHECK(peer->is_connected()); - } - - SECTION("Timestamp = MAX_INT64 (far future)") { - message::VersionMessage msg; - msg.version = protocol::PROTOCOL_VERSION; - msg.services = protocol::NODE_NETWORK; - msg.timestamp = std::numeric_limits::max(); - msg.nonce = 54321; - msg.user_agent = "/Test:1.0.0/"; - msg.start_height = 0; - auto payload = msg.serialize(); - auto full_msg = create_test_message(magic, protocol::commands::VERSION, payload); - mock_conn->simulate_receive(full_msg); - io_context.poll(); - CHECK(peer->version() == protocol::PROTOCOL_VERSION); - CHECK(peer->is_connected()); - } -} - -// ============================================================================= -// MESSAGE SEQUENCE ATTACKS -// ============================================================================= - -TEST_CASE("Adversarial - OutOfOrderHandshake", "[adversarial][protocol]") { - asio::io_context io_context; - auto mock_conn = std::make_shared(); - const uint32_t magic = protocol::magic::REGTEST; - - SECTION("VERACK then VERSION then VERACK (outbound)") { - // Bitcoin Core behavior: ignore non-version messages before handshake (no disconnect) - // Core: net_processing.cpp:3657-3660 - logs and returns without disconnecting - auto peer = Peer::create_outbound(io_context, mock_conn, magic, 0); - peer->start(); - io_context.poll(); - - // Track message count after VERSION sent during start() - size_t count_after_version = mock_conn->sent_message_count(); - - auto verack1 = create_verack_message(magic); - mock_conn->simulate_receive(verack1); - io_context.poll(); - - // SECURITY: Peer must ignore premature VERACK (match Bitcoin Core) - // Premature VERACK is silently ignored, peer stays connected waiting for VERSION - CHECK(peer->is_connected()); - CHECK(peer->version() == 0); // Still waiting for VERSION - - // Assert no egress: peer must not send any messages in response to premature VERACK - CHECK(mock_conn->sent_message_count() == count_after_version); - } - - SECTION("Double VERSION with VERACK in between") { - auto peer = Peer::create_inbound(io_context, mock_conn, magic, 0); - peer->start(); - io_context.poll(); - auto version1 = create_version_message(magic, 11111); - mock_conn->simulate_receive(version1); - io_context.poll(); - CHECK(peer->peer_nonce() == 11111); - auto verack = create_verack_message(magic); - mock_conn->simulate_receive(verack); - io_context.poll(); - CHECK(peer->state() == PeerConnectionState::READY); - auto version2 = create_version_message(magic, 22222); - mock_conn->simulate_receive(version2); - io_context.poll(); - CHECK(peer->peer_nonce() == 11111); - CHECK(peer->state() == PeerConnectionState::READY); - } -} - -TEST_CASE("Adversarial - PingFloodBeforeHandshake", "[adversarial][flood]") { - asio::io_context io_context; - auto mock_conn = std::make_shared(); - const uint32_t magic = protocol::magic::REGTEST; - - auto peer = Peer::create_inbound(io_context, mock_conn, magic, 0); - peer->start(); - io_context.poll(); - - // Send VERSION to start handshake (but don't send VERACK to complete it) - auto version = create_version_message(magic, 12345); - mock_conn->simulate_receive(version); - io_context.poll(); - - REQUIRE(peer->state() == PeerConnectionState::VERSION_SENT); - - // Clear messages (VERSION, VERACK sent by peer) - mock_conn->clear_sent_messages(); - - // Flood with PING messages before handshake completes - for (int i = 0; i < 10; i++) { - auto ping = create_ping_message(magic, 1000 + i); - mock_conn->simulate_receive(ping); - io_context.poll(); - } - - // CRITICAL: Peer must IGNORE all PINGs (Bitcoin Core policy) - // - Stay connected (not disconnect) - // - Send no PONG responses - // - Remain in VERSION_SENT state (waiting for our VERACK) - CHECK(peer->state() == PeerConnectionState::VERSION_SENT); - CHECK(peer->is_connected()); - - // Verify no PONG messages were sent - auto sent_messages = mock_conn->get_sent_messages(); - for (const auto& msg : sent_messages) { - if (msg.size() >= 24) { - std::string command(msg.begin() + 4, msg.begin() + 16); - CHECK(command.find("pong") == std::string::npos); - } - } -} - -// ============================================================================= -// QUICK WIN TESTS -// ============================================================================= - -TEST_CASE("Adversarial - PongNonceMismatch", "[adversarial][protocol][quickwin]") { - asio::io_context io_context; - auto mock_conn = std::make_shared(); - const uint32_t magic = protocol::magic::REGTEST; - - auto peer = Peer::create_outbound(io_context, mock_conn, magic, 0); - peer->start(); - io_context.poll(); - - auto version = create_version_message(magic, 54321); - mock_conn->simulate_receive(version); - io_context.poll(); - - auto verack = create_verack_message(magic); - mock_conn->simulate_receive(verack); - io_context.poll(); - - REQUIRE(peer->state() == PeerConnectionState::READY); - mock_conn->clear_sent_messages(); - - uint64_t peer_ping_nonce = 777777; - auto ping_from_peer = create_ping_message(magic, peer_ping_nonce); - mock_conn->simulate_receive(ping_from_peer); - io_context.poll(); - CHECK(mock_conn->sent_message_count() == 1); - - auto wrong_pong = create_pong_message(magic, 999999); - mock_conn->simulate_receive(wrong_pong); - io_context.poll(); - CHECK(peer->is_connected()); -} - -TEST_CASE("Adversarial - DeserializationFailureFlooding", "[adversarial][malformed][quickwin]") { - asio::io_context io_context; - auto mock_conn = std::make_shared(); - const uint32_t magic = protocol::magic::REGTEST; - - auto peer = Peer::create_inbound(io_context, mock_conn, magic, 0); - peer->start(); - io_context.poll(); - - auto version = create_version_message(magic, 54321); - mock_conn->simulate_receive(version); - io_context.poll(); - - auto verack = create_verack_message(magic); - mock_conn->simulate_receive(verack); - io_context.poll(); - - REQUIRE(peer->state() == PeerConnectionState::READY); - - SECTION("PING with payload too short") { - std::vector short_payload = {0x01, 0x02, 0x03, 0x04}; - auto malformed_ping = create_test_message(magic, protocol::commands::PING, short_payload); - mock_conn->simulate_receive(malformed_ping); - io_context.poll(); - CHECK(peer->state() == PeerConnectionState::DISCONNECTED); - } - - SECTION("PING with payload too long") { - // SECURITY: PING must be exactly 8 bytes (Bitcoin Core pattern) - // Oversized PING messages are a DoS vector (e.g., 4 MB PING flooding) - std::vector long_payload(16, 0xAA); // 16 bytes, should be 8 - auto malformed_ping = create_test_message(magic, protocol::commands::PING, long_payload); - mock_conn->simulate_receive(malformed_ping); - io_context.poll(); - CHECK(peer->state() == PeerConnectionState::DISCONNECTED); - } - - SECTION("VERACK with unexpected payload") { - std::vector garbage_payload = {0xDE, 0xAD, 0xBE, 0xEF}; - auto malformed_verack = create_test_message(magic, protocol::commands::VERACK, garbage_payload); - mock_conn->simulate_receive(malformed_verack); - io_context.poll(); - CHECK(peer->state() == PeerConnectionState::DISCONNECTED); - } - - SECTION("PONG with wrong length") { - // SECURITY: PONG must be exactly 8 bytes (Bitcoin Core pattern) - // Oversized PONG messages are a DoS vector - std::vector long_pong(16, 0xBB); // 16 bytes, should be 8 - auto malformed_pong = create_test_message(magic, protocol::commands::PONG, long_pong); - mock_conn->simulate_receive(malformed_pong); - io_context.poll(); - CHECK(peer->state() == PeerConnectionState::DISCONNECTED); - } - - SECTION("PING with wrong length") { - // SECURITY: PING must be exactly 8 bytes (Bitcoin Core pattern) - // Oversized PING messages are a DoS vector - std::vector short_ping(4, 0xAA); // 4 bytes, should be 8 - auto malformed_ping = create_test_message(magic, protocol::commands::PING, short_ping); - mock_conn->simulate_receive(malformed_ping); - io_context.poll(); - CHECK(peer->state() == PeerConnectionState::DISCONNECTED); - } - - SECTION("GETADDR with wrong length") { - // SECURITY: GETADDR must be exactly 0 bytes (empty payload) - // Prevents abuse of GETADDR flood with extra payload - std::vector payload_getaddr(10, 0xCC); // 10 bytes, should be 0 - auto malformed_getaddr = create_test_message(magic, protocol::commands::GETADDR, payload_getaddr); - mock_conn->simulate_receive(malformed_getaddr); - io_context.poll(); - CHECK(peer->state() == PeerConnectionState::DISCONNECTED); - } -} - -// TEST REMOVED: ReceiveBufferCycling previously tested buffer management with 100 KB PING messages -// This test is no longer valid after adding per-message-type size limits (PING must be exactly 8 bytes) -// Buffer cycling is adequately tested by other tests with properly-sized messages - -TEST_CASE("Adversarial - MessageSizeLimits", "[adversarial][malformed][dos]") { - asio::io_context io_context; - auto mock_conn = std::make_shared(); - const uint32_t magic = protocol::magic::REGTEST; - - auto peer = Peer::create_inbound(io_context, mock_conn, magic, 0); - peer->start(); - io_context.poll(); - - auto version = create_version_message(magic, 54321); - mock_conn->simulate_receive(version); - io_context.poll(); - - auto verack = create_verack_message(magic); - mock_conn->simulate_receive(verack); - io_context.poll(); - - REQUIRE(peer->state() == PeerConnectionState::READY); - - SECTION("ADDR oversized (>1000 addresses)") { - // SECURITY: ADDR messages limited to MAX_ADDR_SIZE (1000) addresses - // Prevents memory exhaustion attacks - message::MessageSerializer s; - s.write_varint(1001); // One more than MAX_ADDR_SIZE - // Write 1001 dummy addresses (each 30 bytes without timestamp, 34 with) - for (int i = 0; i < 1001; i++) { - s.write_uint32(1234567890); // timestamp - s.write_uint64(protocol::NODE_NETWORK); // services - std::array ipv6{}; - ipv6[10] = 0xFF; ipv6[11] = 0xFF; // IPv4-mapped prefix - ipv6[12] = 127; ipv6[15] = 1; // 127.0.0.1 - s.write_bytes(ipv6.data(), 16); - s.write_uint16(9590); // port (network byte order) - } - auto oversized_addr = create_test_message(magic, protocol::commands::ADDR, s.data()); - mock_conn->simulate_receive(oversized_addr); - io_context.poll(); - CHECK(peer->state() == PeerConnectionState::DISCONNECTED); - } - - SECTION("GETHEADERS oversized locator (>101 hashes)") { - // SECURITY: GETHEADERS locator limited to MAX_LOCATOR_SZ (101) hashes - // Prevents CPU exhaustion from expensive FindFork() operations - message::MessageSerializer s; - s.write_uint32(protocol::PROTOCOL_VERSION); - s.write_varint(102); // One more than MAX_LOCATOR_SZ - // Write 102 dummy block hashes (each 32 bytes) - for (int i = 0; i < 102; i++) { - std::array hash{}; - hash[0] = static_cast(i & 0xFF); - s.write_bytes(hash.data(), 32); - } - // Write hash_stop (32 bytes of zeros) - std::array hash_stop{}; - s.write_bytes(hash_stop.data(), 32); - auto oversized_getheaders = create_test_message(magic, protocol::commands::GETHEADERS, s.data()); - mock_conn->simulate_receive(oversized_getheaders); - io_context.poll(); - CHECK(peer->state() == PeerConnectionState::DISCONNECTED); - } - - SECTION("HEADERS oversized (>MAX_HEADERS_SIZE)") { - // SECURITY: HEADERS messages limited to MAX_HEADERS_SIZE (80000) headers - // Prevents memory exhaustion attacks - // Note: We claim to send MAX_HEADERS_SIZE+1 headers but only write a few - // The rejection happens based on the count, not the actual data - message::MessageSerializer s; - s.write_varint(protocol::MAX_HEADERS_SIZE + 1); // One more than MAX_HEADERS_SIZE - // Write just a few dummy headers (100 bytes each for Unicity) - for (int i = 0; i < 10; i++) { - // CBlockHeader: 100 bytes (version, prev_hash, miner_addr, timestamp, bits, nonce, randomx_hash) - s.write_uint32(1); // version (4) - std::array prev_hash{}; - s.write_bytes(prev_hash.data(), 32); // hashPrevBlock (32) - std::array miner_addr{}; - s.write_bytes(miner_addr.data(), 20); // minerAddress (20) - s.write_uint32(1234567890); // timestamp (4) - s.write_uint32(0x1d00ffff); // bits (4) - s.write_uint32(i); // nonce (4) - std::array randomx_hash{}; - s.write_bytes(randomx_hash.data(), 32); // hashRandomX (32) - } - auto oversized_headers = create_test_message(magic, protocol::commands::HEADERS, s.data()); - mock_conn->simulate_receive(oversized_headers); - io_context.poll(); - CHECK(peer->state() == PeerConnectionState::DISCONNECTED); - } -} - -TEST_CASE("Adversarial - ProtocolStateMachine", "[adversarial][protocol][state]") { - asio::io_context io_context; - auto mock_conn = std::make_shared(); - const uint32_t magic = protocol::magic::REGTEST; - - SECTION("Multiple VERSION messages - ignored per Bitcoin Core") { - // Bitcoin Core: Ignores duplicate VERSION (checks if pfrom.nVersion != 0) - // Prevents: time manipulation via multiple AddTimeData() calls - // Pattern: Log and return, don't disconnect - - auto peer = Peer::create_inbound(io_context, mock_conn, magic, 0); - peer->start(); - io_context.poll(); - - // First VERSION: should be accepted - auto version1 = create_version_message(magic, 54321); - mock_conn->simulate_receive(version1); - io_context.poll(); - - // Send VERACK to complete handshake - auto verack = create_verack_message(magic); - mock_conn->simulate_receive(verack); - io_context.poll(); - - REQUIRE(peer->state() == PeerConnectionState::READY); - - // Capture message count before second VERSION - size_t count_before = mock_conn->sent_message_count(); - - // Second VERSION: ignored (Bitcoin Core pattern) - auto version2 = create_version_message(magic, 99999); - mock_conn->simulate_receive(version2); - io_context.poll(); - - // Bitcoin Core: Stays connected, ignores duplicate, sends no response - CHECK(peer->state() == PeerConnectionState::READY); - CHECK(mock_conn->sent_message_count() == count_before); // No egress! - } - - SECTION("Multiple VERACK messages - ignored per Bitcoin Core") { - // Bitcoin Core: Ignores duplicate VERACK (checks if pfrom.fSuccessfullyConnected) - // Prevents: timer churn from repeated schedule_ping() calls - // Pattern: Log warning and return, don't disconnect - - auto peer = Peer::create_inbound(io_context, mock_conn, magic, 0); - peer->start(); - io_context.poll(); - - auto version = create_version_message(magic, 54321); - mock_conn->simulate_receive(version); - io_context.poll(); - - auto verack1 = create_verack_message(magic); - mock_conn->simulate_receive(verack1); - io_context.poll(); - - REQUIRE(peer->state() == PeerConnectionState::READY); - - // Capture message count before second VERACK - size_t count_before = mock_conn->sent_message_count(); - - // Second VERACK: ignored (Bitcoin Core pattern) - auto verack2 = create_verack_message(magic); - mock_conn->simulate_receive(verack2); - io_context.poll(); - - // Bitcoin Core: Stays connected, ignores duplicate, sends no response - CHECK(peer->state() == PeerConnectionState::READY); - CHECK(mock_conn->sent_message_count() == count_before); // No egress! - } - - SECTION("VERSION after READY state - ignored per Bitcoin Core") { - // Bitcoin Core: Duplicate VERSION is ignored (same as multiple VERSION test) - // Even after READY, peer_version_ != 0 so duplicate is ignored - - auto peer = Peer::create_inbound(io_context, mock_conn, magic, 0); - peer->start(); - io_context.poll(); - - auto version = create_version_message(magic, 54321); - mock_conn->simulate_receive(version); - io_context.poll(); - - auto verack = create_verack_message(magic); - mock_conn->simulate_receive(verack); - io_context.poll(); - - REQUIRE(peer->state() == PeerConnectionState::READY); - - // Capture message count before VERSION after READY - size_t count_before = mock_conn->sent_message_count(); - - // Send VERSION after READY - ignored (same logic as duplicate VERSION) - auto version2 = create_version_message(magic, 99999); - mock_conn->simulate_receive(version2); - io_context.poll(); - - // Bitcoin Core: Stays connected, ignores duplicate - CHECK(peer->state() == PeerConnectionState::READY); - CHECK(mock_conn->sent_message_count() == count_before); // No egress! - } - - SECTION("GETHEADERS before handshake complete - ignored") { - // SECURITY: Non-handshake messages ignored before successfully_connected_ - // Prevents: resource exhaustion from unauthenticated peers - // Pattern: peer.cpp:770 checks successfully_connected_, logs and returns - - auto peer = Peer::create_inbound(io_context, mock_conn, magic, 0); - peer->start(); - io_context.poll(); - - auto version = create_version_message(magic, 54321); - mock_conn->simulate_receive(version); - io_context.poll(); - - // Peer has received VERSION but not yet VERACK - not in READY state - // Capture message count before sending GETHEADERS - size_t count_before = mock_conn->sent_message_count(); - - // Send GETHEADERS before VERACK - ignored - message::MessageSerializer s; - s.write_uint32(protocol::PROTOCOL_VERSION); - s.write_varint(1); // 1 locator hash - std::array hash{}; - s.write_bytes(hash.data(), 32); - std::array hash_stop{}; - s.write_bytes(hash_stop.data(), 32); - auto getheaders = create_test_message(magic, protocol::commands::GETHEADERS, s.data()); - mock_conn->simulate_receive(getheaders); - io_context.poll(); - - // Stays connected (still in VERSION_SENT state), ignores message, sends no response - CHECK(peer->state() == PeerConnectionState::VERSION_SENT); // Still in handshake - CHECK(mock_conn->sent_message_count() == count_before); // No egress! - } - - SECTION("HEADERS before handshake complete - ignored") { - // SECURITY: Non-handshake messages ignored before successfully_connected_ - // Prevents: DoS attacks from unauthenticated peers sending expensive HEADERS - // Pattern: peer.cpp:770 checks successfully_connected_, logs and returns - - auto peer = Peer::create_inbound(io_context, mock_conn, magic, 0); - peer->start(); - io_context.poll(); - - auto version = create_version_message(magic, 54321); - mock_conn->simulate_receive(version); - io_context.poll(); - - // Peer has received VERSION but not yet VERACK - not in READY state - // Capture message count before sending HEADERS - size_t count_before = mock_conn->sent_message_count(); - - // Send HEADERS before VERACK - ignored - message::MessageSerializer s; - s.write_varint(0); // 0 headers (empty, but still ignored before READY) - auto headers = create_test_message(magic, protocol::commands::HEADERS, s.data()); - mock_conn->simulate_receive(headers); - io_context.poll(); - - // Stays connected (still in VERSION_SENT state), ignores message, sends no response - CHECK(peer->state() == PeerConnectionState::VERSION_SENT); // Still in handshake - CHECK(mock_conn->sent_message_count() == count_before); // No egress! - } -} - -TEST_CASE("Adversarial - UnknownMessageFlooding", "[adversarial][flood][quickwin]") { - // Bitcoin Core parity: unknown commands are silently ignored - // This provides forward compatibility for protocol upgrades - asio::io_context io_context; - auto mock_conn = std::make_shared(); - const uint32_t magic = protocol::magic::REGTEST; - - auto peer = Peer::create_inbound(io_context, mock_conn, magic, 0); - peer->start(); - io_context.poll(); - - auto version = create_version_message(magic, 54321); - mock_conn->simulate_receive(version); - io_context.poll(); - - auto verack = create_verack_message(magic); - mock_conn->simulate_receive(verack); - io_context.poll(); - - REQUIRE(peer->state() == PeerConnectionState::READY); - - std::vector fake_commands = { - "FAKECMD1", "FAKECMD2", "XYZABC", "UNKNOWN", - "BOGUS", "INVALID", "NOTREAL", "JUNK", - "GARBAGE", "RANDOM" - }; - - // Send many unknown commands - all should be silently ignored - const int messages_to_send = 100; - for (int i = 0; i < messages_to_send; i++) { - std::string fake_cmd = fake_commands[i % fake_commands.size()]; - std::vector dummy_payload = {0x01, 0x02, 0x03, 0x04}; - auto unknown_msg = create_test_message(magic, fake_cmd, dummy_payload); - mock_conn->simulate_receive(unknown_msg); - io_context.poll(); - } - - // Peer should still be connected - unknown commands are ignored - CHECK(peer->is_connected()); - CHECK(peer->state() == PeerConnectionState::READY); -} - -TEST_CASE("Adversarial - StatisticsOverflow", "[adversarial][resource][quickwin]") { - asio::io_context io_context; - auto mock_conn = std::make_shared(); - const uint32_t magic = protocol::magic::REGTEST; - - auto peer = Peer::create_outbound(io_context, mock_conn, magic, 0); - peer->start(); - io_context.poll(); - - auto version = create_version_message(magic, 54321); - mock_conn->simulate_receive(version); - io_context.poll(); - - auto verack = create_verack_message(magic); - mock_conn->simulate_receive(verack); - io_context.poll(); - - REQUIRE(peer->state() == PeerConnectionState::READY); - - // Snapshot stats before injecting PINGs - auto& stats = peer->stats(); - uint64_t msg_before = stats.messages_received.load(); - uint64_t bytes_before = stats.bytes_received.load(); - const int ping_count = 1000; - - for (int i = 0; i < ping_count; i++) { - auto ping = create_ping_message(magic, 5000 + i); - mock_conn->simulate_receive(ping); - io_context.poll(); - } - - // Verify exact stat increments - uint64_t msg_after = stats.messages_received.load(); - uint64_t bytes_after = stats.bytes_received.load(); - CHECK(msg_after == msg_before + ping_count); - CHECK(bytes_after > bytes_before); - CHECK(peer->is_connected()); -} - -TEST_CASE("Adversarial - MessageHandlerBlocking", "[adversarial][threading][p2]") { - // Tests that message handlers can perform work without crashing the peer - // Removed sleep_for() per TESTING.md guidelines (deterministic tests only) - asio::io_context io_context; - auto mock_conn = std::make_shared(); - const uint32_t magic = protocol::magic::REGTEST; - - auto peer = Peer::create_outbound(io_context, mock_conn, magic, 0); - - int handler_call_count = 0; - const auto msgs_before = peer->stats().messages_received.load(); - - peer->set_message_handler([&](PeerPtr p, std::unique_ptr msg) { - handler_call_count++; - // Simulate some work (without sleep - tests should be fast) - // In real usage, handlers might do validation, chainstate queries, etc. - int work = 0; - for (int i = 0; i < 1000; ++i) { - work = work + i; // Avoid volatile compound assignment (deprecated in C++20) - } - // Prevent optimization of the loop - if (work < 0) handler_call_count = 0; // Never true, but compiler can't prove it - }); - - peer->start(); - io_context.poll(); - - auto version = create_version_message(magic, 54321); - mock_conn->simulate_receive(version); - io_context.poll(); - - auto verack = create_verack_message(magic); - mock_conn->simulate_receive(verack); - io_context.poll(); - - REQUIRE(peer->state() == PeerConnectionState::READY); - REQUIRE(handler_call_count > 0); // Handler was called - - const auto msgs_after = peer->stats().messages_received.load(); - CHECK(msgs_after > msgs_before); // Messages were processed - CHECK(peer->is_connected()); // Peer still connected after handler execution -} - -TEST_CASE("Adversarial - ConcurrentDisconnectDuringProcessing", "[adversarial][race][p2]") { - // Tests that disconnect() can be safely called at any time during message processing - // This verifies the shared_ptr-based lifecycle management in peer.cpp:164-168 - // Removed sleep_for() per TESTING.md guidelines (deterministic tests only) - asio::io_context io_context; - auto mock_conn = std::make_shared(); - const uint32_t magic = protocol::magic::REGTEST; - - auto peer = Peer::create_outbound(io_context, mock_conn, magic, 0); - - bool handler_executed = false; - PeerConnectionState state_during_handler = PeerConnectionState::DISCONNECTED; - - peer->set_message_handler([&](PeerPtr p, std::unique_ptr msg) { - handler_executed = true; - // Capture state during handler execution - state_during_handler = p->state(); - // Handler completes successfully even if disconnect is imminent - }); - - peer->start(); - io_context.poll(); - - auto version = create_version_message(magic, 54321); - mock_conn->simulate_receive(version); - io_context.poll(); - - auto verack = create_verack_message(magic); - mock_conn->simulate_receive(verack); - io_context.poll(); - - REQUIRE(peer->state() == PeerConnectionState::READY); - REQUIRE(handler_executed); // Handler ran during handshake - - // The adversarial test: call disconnect() after message processing has occurred - // This verifies shared_ptr lifecycle management prevents use-after-free - // (The handler was called above during VERSION/VERACK, proving message processing works) - - peer->disconnect(); - io_context.poll(); - - // Peer should be cleanly disconnected - CHECK(peer->state() == PeerConnectionState::DISCONNECTED); - - // Success: No crashes or use-after-free during or after disconnect - // The test passes if we reach here without crashing -} - -TEST_CASE("Adversarial - SelfConnectionEdgeCases", "[adversarial][protocol][p2]") { - asio::io_context io_context; - const uint32_t magic = protocol::magic::REGTEST; - - SECTION("Inbound self-connection with matching nonce") { - auto mock_conn = std::make_shared(); - auto peer = Peer::create_inbound(io_context, mock_conn, magic, 0); - peer->start(); - io_context.poll(); - auto version = create_version_message(magic, peer->get_local_nonce()); - mock_conn->simulate_receive(version); - io_context.poll(); - CHECK(peer->state() == PeerConnectionState::DISCONNECTED); - } - - SECTION("Outbound self-connection detection") { - // Both inbound AND outbound should detect self-connection (Bitcoin Core pattern) - auto mock_conn = std::make_shared(); - auto peer = Peer::create_outbound(io_context, mock_conn, magic, 0); - peer->start(); - io_context.poll(); - auto version = create_version_message(magic, peer->get_local_nonce()); - mock_conn->simulate_receive(version); - io_context.poll(); - CHECK(peer->state() == PeerConnectionState::DISCONNECTED); - } -} - -TEST_CASE("Adversarial - MaxMessageSizeEdgeCases", "[adversarial][edge][p2]") { - asio::io_context io_context; - auto mock_conn = std::make_shared(); - const uint32_t magic = protocol::magic::REGTEST; - - auto peer = Peer::create_inbound(io_context, mock_conn, magic, 0); - peer->start(); - io_context.poll(); - - auto version = create_version_message(magic, 54321); - mock_conn->simulate_receive(version); - io_context.poll(); - - auto verack = create_verack_message(magic); - mock_conn->simulate_receive(verack); - io_context.poll(); - - REQUIRE(peer->state() == PeerConnectionState::READY); - - SECTION("Exactly MAX_PROTOCOL_MESSAGE_LENGTH PING rejected") { - // Even though this is within global MAX_PROTOCOL_MESSAGE_LENGTH (4 MB), - // PING has per-message-type limit of exactly 8 bytes - std::vector max_payload(protocol::MAX_PROTOCOL_MESSAGE_LENGTH, 0xAA); - auto max_msg = create_test_message(magic, protocol::commands::PING, max_payload); - mock_conn->simulate_receive(max_msg); - io_context.poll(); - CHECK(peer->state() == PeerConnectionState::DISCONNECTED); - } - - SECTION("Exactly MAX_PROTOCOL_MESSAGE_LENGTH + 1") { - std::vector payload(protocol::MAX_PROTOCOL_MESSAGE_LENGTH + 1, 0xBB); - protocol::MessageHeader header(magic, protocol::commands::PING, - protocol::MAX_PROTOCOL_MESSAGE_LENGTH + 1); - header.checksum = [&](const auto& data) { uint256 hash = Hash(data); std::array checksum; std::memcpy(checksum.data(), hash.begin(), 4); return checksum; }(payload); - auto header_bytes = message::serialize_header(header); - mock_conn->simulate_receive(header_bytes); - io_context.poll(); - CHECK(peer->state() == PeerConnectionState::DISCONNECTED); - } - - SECTION("Oversized PING rejected (DoS protection)") { - // SECURITY: PING must be exactly 8 bytes (Bitcoin Core pattern) - // Accepting 3 MB PING messages is a DoS footgun (bandwidth/memory exhaustion) - // This test validates per-message-type size limits, not just global MAX limit - std::vector large_payload(3 * 1024 * 1024, 0xEE); // 3 MB PING! - auto large_msg = create_test_message(magic, protocol::commands::PING, large_payload); - mock_conn->simulate_receive(large_msg); - io_context.poll(); - CHECK(peer->state() == PeerConnectionState::DISCONNECTED); // Must disconnect! - } -} - -TEST_CASE("Adversarial - MessageRateLimiting", "[adversarial][flood][p3]") { - asio::io_context io_context; - auto mock_conn = std::make_shared(); - const uint32_t magic = protocol::magic::REGTEST; - - auto peer = Peer::create_inbound(io_context, mock_conn, magic, 0); - peer->start(); - io_context.poll(); - - auto version = create_version_message(magic, 54321); - mock_conn->simulate_receive(version); - io_context.poll(); - - auto verack = create_verack_message(magic); - mock_conn->simulate_receive(verack); - io_context.poll(); - - REQUIRE(peer->state() == PeerConnectionState::READY); - - // Snapshot stats before flood - auto& stats = peer->stats(); - uint64_t msg_before = stats.messages_received.load(); - const int ping_count = 1000; - int sent = 0; - - for (int i = 0; i < ping_count; i++) { - auto ping = create_ping_message(magic, 8000 + i); - mock_conn->simulate_receive(ping); - io_context.poll(); - sent++; - if (!peer->is_connected()) { break; } - } - - // Verify exact stat increments - uint64_t msg_after = stats.messages_received.load(); - CHECK(peer->is_connected()); - CHECK(msg_after == msg_before + sent); -} - -TEST_CASE("Adversarial - TransportCallbackOrdering", "[adversarial][race][p3]") { - asio::io_context io_context; - auto mock_conn = std::make_shared(); - const uint32_t magic = protocol::magic::REGTEST; - - auto peer = Peer::create_inbound(io_context, mock_conn, magic, 0); - peer->start(); - io_context.poll(); - - SECTION("Receive callback after disconnect") { - peer->disconnect(); - io_context.poll(); // Process the disconnect operation - CHECK(peer->state() == PeerConnectionState::DISCONNECTED); - auto version = create_version_message(magic, 54321); - mock_conn->simulate_receive(version); - io_context.poll(); - // SECURITY: After disconnect(), callbacks are cleared to prevent use-after-free - // Messages received after disconnect() should NOT be processed - CHECK(peer->state() == PeerConnectionState::DISCONNECTED); - CHECK(peer->version() == 0); // VERSION not processed (callback cleared) - } - - SECTION("Disconnect callback fires twice") { - auto version = create_version_message(magic, 54321); - mock_conn->simulate_receive(version); - io_context.poll(); - auto verack = create_verack_message(magic); - mock_conn->simulate_receive(verack); - io_context.poll(); - REQUIRE(peer->state() == PeerConnectionState::READY); - peer->disconnect(); - io_context.poll(); // Process first disconnect - CHECK(peer->state() == PeerConnectionState::DISCONNECTED); - peer->disconnect(); - io_context.poll(); // Process second disconnect (should be no-op) - CHECK(peer->state() == PeerConnectionState::DISCONNECTED); - } -} - -TEST_CASE("Adversarial - CommandFieldPadding", "[adversarial][malformed][p3]") { - asio::io_context io_context; - auto mock_conn = std::make_shared(); - const uint32_t magic = protocol::magic::REGTEST; - - auto peer = Peer::create_inbound(io_context, mock_conn, magic, 0); - peer->start(); - io_context.poll(); - - SECTION("VERSION with null padding") { - protocol::MessageHeader header; - header.magic = magic; - header.command.fill(0); - std::string cmd = "version"; - std::copy(cmd.begin(), cmd.end(), header.command.begin()); - message::VersionMessage msg; - msg.version = protocol::PROTOCOL_VERSION; - msg.services = protocol::NODE_NETWORK; - msg.timestamp = 1234567890; - msg.nonce = 54321; - msg.user_agent = "/Test:1.0.0/"; - msg.start_height = 0; - auto payload = msg.serialize(); - header.length = payload.size(); - header.checksum = [&](const auto& data) { uint256 hash = Hash(data); std::array checksum; std::memcpy(checksum.data(), hash.begin(), 4); return checksum; }(payload); - auto header_bytes = message::serialize_header(header); - std::vector full_message; - full_message.insert(full_message.end(), header_bytes.begin(), header_bytes.end()); - full_message.insert(full_message.end(), payload.begin(), payload.end()); - mock_conn->simulate_receive(full_message); - io_context.poll(); - CHECK(peer->version() == protocol::PROTOCOL_VERSION); - CHECK(peer->is_connected()); - } - - SECTION("Command with trailing spaces") { - protocol::MessageHeader header; - header.magic = magic; - header.command.fill(' '); - std::string cmd = "version"; - std::copy(cmd.begin(), cmd.end(), header.command.begin()); - message::VersionMessage msg; - msg.version = protocol::PROTOCOL_VERSION; - msg.services = protocol::NODE_NETWORK; - msg.timestamp = 1234567890; - msg.nonce = 54321; - msg.user_agent = "/Test:1.0.0/"; - msg.start_height = 0; - auto payload = msg.serialize(); - header.length = payload.size(); - header.checksum = [&](const auto& data) { uint256 hash = Hash(data); std::array checksum; std::memcpy(checksum.data(), hash.begin(), 4); return checksum; }(payload); - auto header_bytes = message::serialize_header(header); - std::vector full_message; - full_message.insert(full_message.end(), header_bytes.begin(), header_bytes.end()); - full_message.insert(full_message.end(), payload.begin(), payload.end()); - mock_conn->simulate_receive(full_message); - io_context.poll(); - bool connected = peer->is_connected(); - bool version_set = (peer->version() == protocol::PROTOCOL_VERSION); - CHECK((connected == version_set)); - } -} -// ============================================================================= -// HANDSHAKE SECURITY TESTS - Phase 1: Critical Security -// ============================================================================= -// Tests for handshake state machine enforcement - prevents information -// disclosure and DoS attacks from unauthenticated peers. -// -// These tests validate the fix in src/network/peer.cpp:733-765 which adds -// successfully_connected_ checks before processing non-handshake messages. -// ============================================================================= - -// Helper functions for creating protocol messages -static std::vector create_getaddr_message(uint32_t magic) { - // GETADDR has no payload - std::vector empty_payload; - return create_test_message(magic, protocol::commands::GETADDR, empty_payload); -} - -static std::vector create_getheaders_message(uint32_t magic) { - // Minimal GETHEADERS: version (4 bytes) + hash_count (1 byte = 0) + hash_stop (32 bytes = 0) - message::GetHeadersMessage msg; - msg.version = protocol::PROTOCOL_VERSION; - // Empty locator hashes - msg.hash_stop.SetNull(); - auto payload = msg.serialize(); - return create_test_message(magic, protocol::commands::GETHEADERS, payload); -} - -static std::vector create_addr_message(uint32_t magic) { - // ADDR with one address - message::AddrMessage msg; - protocol::TimestampedAddress addr; - addr.timestamp = 1234567890; - addr.address.services = protocol::NODE_NETWORK; - addr.address.ip.fill(0); - addr.address.ip[10] = 0xff; - addr.address.ip[11] = 0xff; - addr.address.ip[12] = 127; - addr.address.ip[13] = 0; - addr.address.ip[14] = 0; - addr.address.ip[15] = 1; - addr.address.port = 9590; - msg.addresses.push_back(addr); - auto payload = msg.serialize(); - return create_test_message(magic, protocol::commands::ADDR, payload); -} - -static std::vector create_headers_message(uint32_t magic) { - // HEADERS with one header - message::HeadersMessage msg; - CBlockHeader header; - header.nVersion = 1; - header.hashPrevBlock.SetNull(); - header.minerAddress.SetNull(); - header.nTime = 1234567890; - header.nBits = 0x1d00ffff; - header.nNonce = 0; - header.hashRandomX.SetNull(); - msg.headers.push_back(header); - auto payload = msg.serialize(); - return create_test_message(magic, protocol::commands::HEADERS, payload); -} - -// Helper to create a message with specific header length field -static std::vector create_message_with_length(uint32_t magic, const std::string& command, uint32_t length_field) { - protocol::MessageHeader header; - header.magic = magic; - header.set_command(command); - header.length = length_field; - header.checksum.fill(0); // Invalid checksum, but we're testing length handling - - auto header_bytes = message::serialize_header(header); - return header_bytes; // Return just the header, no payload -} - -// ============================================================================= -// TEST 1.1: PING before VERACK -// ============================================================================= -TEST_CASE("Handshake Security - PING before VERACK", "[adversarial][handshake][security]") { - asio::io_context io_context; - auto mock_conn = std::make_shared(); - const uint32_t magic = protocol::magic::REGTEST; - - auto peer = Peer::create_inbound(io_context, mock_conn, magic, 0); - peer->start(); - io_context.poll(); - - // Send VERSION - auto version = create_version_message(magic, 12345); - mock_conn->simulate_receive(version); - io_context.poll(); - - // Peer should be in CONNECTED state but not READY - REQUIRE(peer->state() == PeerConnectionState::VERSION_SENT); - - // Clear any outgoing messages (VERSION, VERACK) - mock_conn->clear_sent_messages(); - - // Send PING before completing handshake - auto ping = create_ping_message(magic, 0xDEADBEEF); - mock_conn->simulate_receive(ping); - io_context.poll(); - - // CRITICAL: Peer must NOT respond with PONG - // Verify NO egress traffic (security: no information disclosure) - CHECK(mock_conn->sent_message_count() == 0); - - // Double-check: scan for PONG specifically - auto sent_messages = mock_conn->get_sent_messages(); - bool pong_sent = false; - for (const auto& msg : sent_messages) { - if (msg.size() >= 24) { - std::string command(msg.begin() + 4, msg.begin() + 16); - if (command.find("pong") != std::string::npos) { - pong_sent = true; - break; - } - } - } - CHECK(!pong_sent); - - // Verify peer stays connected and in correct state - CHECK(peer->is_connected()); - CHECK(peer->state() == PeerConnectionState::VERSION_SENT); -} - -// ============================================================================= -// TEST 1.3: GETADDR before VERACK -// ============================================================================= -TEST_CASE("Handshake Security - GETADDR before VERACK", "[adversarial][handshake][security]") { - asio::io_context io_context; - auto mock_conn = std::make_shared(); - const uint32_t magic = protocol::magic::REGTEST; - - auto peer = Peer::create_inbound(io_context, mock_conn, magic, 0); - peer->start(); - io_context.poll(); - - // Send VERSION - auto version = create_version_message(magic, 12345); - mock_conn->simulate_receive(version); - io_context.poll(); - - REQUIRE(peer->state() == PeerConnectionState::VERSION_SENT); - mock_conn->clear_sent_messages(); - - // Attack: Send GETADDR before VERACK to enumerate addresses - auto getaddr = create_getaddr_message(magic); - mock_conn->simulate_receive(getaddr); - io_context.poll(); - - // CRITICAL: Peer must NOT respond with ADDR - // Verify NO egress traffic (security: no network topology disclosure) - CHECK(mock_conn->sent_message_count() == 0); - - // Double-check: scan for ADDR specifically - auto sent_messages = mock_conn->get_sent_messages(); - bool addr_sent = false; - for (const auto& msg : sent_messages) { - if (msg.size() >= 24) { - std::string command(msg.begin() + 4, msg.begin() + 16); - if (command.find("addr") != std::string::npos) { - addr_sent = true; - break; - } - } - } - CHECK(!addr_sent); - - // Verify peer stays connected and in correct state - CHECK(peer->is_connected()); - CHECK(peer->state() == PeerConnectionState::VERSION_SENT); -} - -// ============================================================================= -// TEST 1.4: GETHEADERS before VERACK -// ============================================================================= -TEST_CASE("Handshake Security - GETHEADERS before VERACK", "[adversarial][handshake][security]") { - asio::io_context io_context; - auto mock_conn = std::make_shared(); - const uint32_t magic = protocol::magic::REGTEST; - - auto peer = Peer::create_inbound(io_context, mock_conn, magic, 0); - peer->start(); - io_context.poll(); - - auto version = create_version_message(magic, 12345); - mock_conn->simulate_receive(version); - io_context.poll(); - - REQUIRE(peer->state() == PeerConnectionState::VERSION_SENT); - mock_conn->clear_sent_messages(); - - // Attack: Send GETHEADERS before VERACK to fingerprint chain state - auto getheaders = create_getheaders_message(magic); - mock_conn->simulate_receive(getheaders); - io_context.poll(); - - // CRITICAL: Peer must NOT respond with HEADERS - // Verify NO egress traffic (security: no chain state disclosure) - CHECK(mock_conn->sent_message_count() == 0); - - // Double-check: scan for HEADERS specifically - auto sent_messages = mock_conn->get_sent_messages(); - bool headers_sent = false; - for (const auto& msg : sent_messages) { - if (msg.size() >= 24) { - std::string command(msg.begin() + 4, msg.begin() + 16); - if (command.find("headers") != std::string::npos) { - headers_sent = true; - break; - } - } - } - - CHECK(!headers_sent); // HEADERS must NOT be sent (chain fingerprinting protection) - - // Verify peer stays connected and in correct state - CHECK(peer->is_connected()); - CHECK(peer->state() == PeerConnectionState::VERSION_SENT); -} - -// ============================================================================= -// TEST 1.6: ADDR before VERACK -// ============================================================================= -TEST_CASE("Handshake Security - ADDR before VERACK", "[adversarial][handshake][security]") { - asio::io_context io_context; - auto mock_conn = std::make_shared(); - const uint32_t magic = protocol::magic::REGTEST; - - auto peer = Peer::create_inbound(io_context, mock_conn, magic, 0); - peer->start(); - io_context.poll(); - - auto version = create_version_message(magic, 12345); - mock_conn->simulate_receive(version); - io_context.poll(); - - REQUIRE(peer->state() == PeerConnectionState::VERSION_SENT); - mock_conn->clear_sent_messages(); - - // Attack: Send malicious ADDR before VERACK to poison address database - auto addr = create_addr_message(magic); - mock_conn->simulate_receive(addr); - io_context.poll(); - - // CRITICAL: Peer should ignore ADDR (address table must not be polluted) - // Verify NO egress traffic (security: no response to pre-handshake ADDR) - CHECK(mock_conn->sent_message_count() == 0); - - // This test validates that ADDR processing is deferred until handshake completes - // Note: We can't directly inspect the address manager, but the message should be ignored - CHECK(peer->is_connected()); - CHECK(peer->state() == PeerConnectionState::VERSION_SENT); - - // Now complete handshake and verify peer reaches READY state - auto verack = create_verack_message(magic); - mock_conn->simulate_receive(verack); - io_context.poll(); - - CHECK(peer->state() == PeerConnectionState::READY); -} - -// ============================================================================= -// TEST 6.5: Header length field overflow -// ============================================================================= -TEST_CASE("Handshake Security - Header length overflow", "[adversarial][handshake][security]") { - asio::io_context io_context; - auto mock_conn = std::make_shared(); - const uint32_t magic = protocol::magic::REGTEST; - - auto peer = Peer::create_inbound(io_context, mock_conn, magic, 0); - peer->start(); - io_context.poll(); - - // Attack: Send VERSION header with length = 0xFFFFFFFF (4GB) - // This could cause integer overflow or massive memory allocation - auto malicious_header = create_message_with_length(magic, protocol::commands::VERSION, 0xFFFFFFFF); - mock_conn->simulate_receive(malicious_header); - io_context.poll(); - - // Peer should disconnect or reject the message (not crash!) - // The exact behavior depends on implementation, but it must not allocate 4GB - // The peer should either be disconnected or still waiting for valid VERSION - bool safe_state = (peer->state() == PeerConnectionState::DISCONNECTED || - peer->state() == PeerConnectionState::CONNECTING || - peer->version() == 0); - - CHECK(safe_state); // Must not process 4GB message -} - -// ============================================================================= -// TEST 6.4: Oversized VERSION -// ============================================================================= -TEST_CASE("Handshake Security - Oversized VERSION", "[adversarial][handshake][security]") { - asio::io_context io_context; - auto mock_conn = std::make_shared(); - const uint32_t magic = protocol::magic::REGTEST; - - auto peer = Peer::create_inbound(io_context, mock_conn, magic, 0); - peer->start(); - io_context.poll(); - - // Attack: Send VERSION with length = MAX_PROTOCOL_MESSAGE_LENGTH - // Peer should reject this before allocating memory - auto malicious_header = create_message_with_length(magic, protocol::commands::VERSION, - protocol::MAX_PROTOCOL_MESSAGE_LENGTH); - mock_conn->simulate_receive(malicious_header); - io_context.poll(); - - // Peer should disconnect (not allocate 4MB for VERSION) - bool safe_state = (peer->state() == PeerConnectionState::DISCONNECTED || - peer->version() == 0); - - CHECK(safe_state); // Must not allocate maximum size for VERSION -} - -// ============================================================================= -// TEST 6.6: VERSION with bad checksum -// ============================================================================= -TEST_CASE("Handshake Security - VERSION with bad checksum", "[adversarial][handshake][security]") { - asio::io_context io_context; - auto mock_conn = std::make_shared(); - const uint32_t magic = protocol::magic::REGTEST; - - auto peer = Peer::create_inbound(io_context, mock_conn, magic, 0); - peer->start(); - io_context.poll(); - - // Create a valid VERSION message - message::VersionMessage msg; - msg.version = protocol::PROTOCOL_VERSION; - msg.services = protocol::NODE_NETWORK; - msg.timestamp = 1234567890; - msg.nonce = 12345; - msg.user_agent = "/Test:1.0.0/"; - msg.start_height = 0; - auto payload = msg.serialize(); - - // Create header with CORRECT length but WRONG checksum - protocol::MessageHeader header; - header.magic = magic; - header.set_command(protocol::commands::VERSION); - header.length = static_cast(payload.size()); - header.checksum.fill(0); // Wrong checksum (should be hash of payload) - - auto header_bytes = message::serialize_header(header); - - // Send complete message: header + payload - std::vector malicious_message; - malicious_message.insert(malicious_message.end(), header_bytes.begin(), header_bytes.end()); - malicious_message.insert(malicious_message.end(), payload.begin(), payload.end()); - - mock_conn->simulate_receive(malicious_message); - io_context.poll(); - - // CRITICAL: Peer must disconnect on checksum mismatch - // Bad checksum indicates corrupted or malicious message - bool safe_state = (peer->state() == PeerConnectionState::DISCONNECTED || - peer->version() == 0); - - CHECK(safe_state); // Must reject message with bad checksum -} - -// ============================================================================= -// TEST 6.7: Wrong network magic during handshake -// ============================================================================= -TEST_CASE("Handshake Security - Wrong network magic during handshake", "[adversarial][handshake][security]") { - asio::io_context io_context; - auto mock_conn = std::make_shared(); - const uint32_t magic = protocol::magic::REGTEST; - - auto peer = Peer::create_inbound(io_context, mock_conn, magic, 0); - peer->start(); - io_context.poll(); - - // Attack: Send VERSION with TESTNET magic to REGTEST node - const uint32_t wrong_magic = protocol::magic::TESTNET; - - message::VersionMessage msg; - msg.version = protocol::PROTOCOL_VERSION; - msg.services = protocol::NODE_NETWORK; - msg.timestamp = 1234567890; - msg.nonce = 12345; - msg.user_agent = "/Test:1.0.0/"; - msg.start_height = 0; - auto payload = msg.serialize(); - - // Create message with WRONG magic - auto malicious_message = create_test_message(wrong_magic, protocol::commands::VERSION, payload); - - mock_conn->simulate_receive(malicious_message); - io_context.poll(); - - // CRITICAL: Peer must disconnect on magic mismatch - // Prevents cross-network pollution and fingerprinting - bool safe_state = (peer->state() == PeerConnectionState::DISCONNECTED || - peer->version() == 0); - - CHECK(safe_state); // Must reject wrong network magic -} - -// ============================================================================= -// TEST 6.8: Checksum for zeros with non-zero payload -// ============================================================================= -TEST_CASE("Handshake Security - Checksum for zeros with non-zero payload", "[adversarial][handshake][security]") { - asio::io_context io_context; - auto mock_conn = std::make_shared(); - const uint32_t magic = protocol::magic::REGTEST; - - auto peer = Peer::create_inbound(io_context, mock_conn, magic, 0); - peer->start(); - io_context.poll(); - - // Create a valid VERSION message payload - message::VersionMessage msg; - msg.version = protocol::PROTOCOL_VERSION; - msg.services = protocol::NODE_NETWORK; - msg.timestamp = 1234567890; - msg.nonce = 12345; - msg.user_agent = "/Test:1.0.0/"; - msg.start_height = 0; - auto payload = msg.serialize(); - - // Calculate checksum of all zeros (different from actual payload) - std::vector zeros(payload.size(), 0); - protocol::MessageHeader wrong_header(magic, protocol::commands::VERSION, static_cast(zeros.size())); - uint256 hash = Hash(zeros); - std::memcpy(wrong_header.checksum.data(), hash.begin(), 4); - auto header_bytes = message::serialize_header(wrong_header); - - // Send header with checksum for zeros, but actual non-zero payload - std::vector malicious_message; - malicious_message.insert(malicious_message.end(), header_bytes.begin(), header_bytes.end()); - malicious_message.insert(malicious_message.end(), payload.begin(), payload.end()); - - mock_conn->simulate_receive(malicious_message); - io_context.poll(); - - // CRITICAL: Peer must disconnect on checksum mismatch - // Header claims checksum for zeros, but payload is non-zero - bool safe_state = (peer->state() == PeerConnectionState::DISCONNECTED || - peer->version() == 0); - - CHECK(safe_state); // Must detect checksum mismatch -} - -// ============================================================================= -// TEST 1.2: PONG before VERACK -// ============================================================================= -TEST_CASE("Handshake Security - PONG before VERACK", "[adversarial][handshake][security]") { - asio::io_context io_context; - auto mock_conn = std::make_shared(); - const uint32_t magic = protocol::magic::REGTEST; - - auto peer = Peer::create_inbound(io_context, mock_conn, magic, 0); - peer->start(); - io_context.poll(); - - // Send VERSION - auto version = create_version_message(magic, 12345); - mock_conn->simulate_receive(version); - io_context.poll(); - - REQUIRE(peer->state() == PeerConnectionState::VERSION_SENT); - mock_conn->clear_sent_messages(); - - // Attack: Send unsolicited PONG before VERACK - auto pong = create_pong_message(magic, 0xDEADBEEF); - mock_conn->simulate_receive(pong); - io_context.poll(); - - // CRITICAL: Peer must ignore unsolicited PONG (prevents state confusion) - CHECK(mock_conn->sent_message_count() == 0); - CHECK(peer->is_connected()); - CHECK(peer->state() == PeerConnectionState::VERSION_SENT); -} - -// ============================================================================= -// TEST 1.7: HEADERS before VERACK -// ============================================================================= -TEST_CASE("Handshake Security - HEADERS before VERACK", "[adversarial][handshake][security]") { - asio::io_context io_context; - auto mock_conn = std::make_shared(); - const uint32_t magic = protocol::magic::REGTEST; - - auto peer = Peer::create_inbound(io_context, mock_conn, magic, 0); - peer->start(); - io_context.poll(); - - auto version = create_version_message(magic, 12345); - mock_conn->simulate_receive(version); - io_context.poll(); - - REQUIRE(peer->state() == PeerConnectionState::VERSION_SENT); - mock_conn->clear_sent_messages(); - - // Attack: Send unsolicited HEADERS before VERACK - auto headers = create_headers_message(magic); - mock_conn->simulate_receive(headers); - io_context.poll(); - - // CRITICAL: Peer must ignore HEADERS (prevents DoS via header processing) - CHECK(mock_conn->sent_message_count() == 0); - CHECK(peer->is_connected()); - CHECK(peer->state() == PeerConnectionState::VERSION_SENT); -} - -// ============================================================================= -// TEST 2.3: Multiple VERACKs -// ============================================================================= -TEST_CASE("Handshake Security - Multiple VERACKs", "[adversarial][handshake][security]") { - asio::io_context io_context; - auto mock_conn = std::make_shared(); - const uint32_t magic = protocol::magic::REGTEST; - - auto peer = Peer::create_inbound(io_context, mock_conn, magic, 0); - peer->start(); - io_context.poll(); - - // Complete handshake - auto version = create_version_message(magic, 12345); - mock_conn->simulate_receive(version); - io_context.poll(); - - auto verack = create_verack_message(magic); - mock_conn->simulate_receive(verack); - io_context.poll(); - - REQUIRE(peer->state() == PeerConnectionState::READY); - mock_conn->clear_sent_messages(); - - // Attack: Send duplicate VERACK messages - for (int i = 0; i < 5; ++i) { - auto duplicate_verack = create_verack_message(magic); - mock_conn->simulate_receive(duplicate_verack); - io_context.poll(); - } - - // CRITICAL: Peer should ignore duplicate VERACKs, stay in READY state - CHECK(peer->state() == PeerConnectionState::READY); - CHECK(peer->is_connected()); -} - -// ============================================================================= -// TEST 4.1: GETADDR flood before VERACK -// ============================================================================= -TEST_CASE("Handshake Security - GETADDR flood before VERACK", "[adversarial][handshake][security]") { - asio::io_context io_context; - auto mock_conn = std::make_shared(); - const uint32_t magic = protocol::magic::REGTEST; - - auto peer = Peer::create_inbound(io_context, mock_conn, magic, 0); - peer->start(); - io_context.poll(); - - auto version = create_version_message(magic, 12345); - mock_conn->simulate_receive(version); - io_context.poll(); - - REQUIRE(peer->state() == PeerConnectionState::VERSION_SENT); - mock_conn->clear_sent_messages(); - - // Attack: Flood with 100 GETADDR messages before VERACK - for (int i = 0; i < 100; ++i) { - auto getaddr = create_getaddr_message(magic); - mock_conn->simulate_receive(getaddr); - io_context.poll(); - } - - // CRITICAL: All GETADDR messages ignored, no ADDR responses - CHECK(mock_conn->sent_message_count() == 0); - - // Now complete handshake - auto verack = create_verack_message(magic); - mock_conn->simulate_receive(verack); - io_context.poll(); - - // Peer should reach READY state successfully - CHECK(peer->state() == PeerConnectionState::READY); -} - -// ============================================================================= -// TEST 4.2: Large message before VERACK -// ============================================================================= -TEST_CASE("Handshake Security - Large message before VERACK", "[adversarial][handshake][security]") { - asio::io_context io_context; - auto mock_conn = std::make_shared(); - const uint32_t magic = protocol::magic::REGTEST; - - auto peer = Peer::create_inbound(io_context, mock_conn, magic, 0); - peer->start(); - io_context.poll(); - - auto version = create_version_message(magic, 12345); - mock_conn->simulate_receive(version); - io_context.poll(); - - REQUIRE(peer->state() == PeerConnectionState::VERSION_SENT); - - // Attack: Send large HEADERS message (near max size) before VERACK - message::HeadersMessage msg; - // Add 2000 headers (2000 * 100 bytes = 200KB) - for (int i = 0; i < 2000; ++i) { - CBlockHeader header; - header.nVersion = 1; - header.hashPrevBlock.SetNull(); - header.minerAddress.SetNull(); - header.nTime = 1234567890 + i; - header.nBits = 0x1d00ffff; - header.nNonce = i; - header.hashRandomX.SetNull(); - msg.headers.push_back(header); - } - auto payload = msg.serialize(); - auto large_headers = create_test_message(magic, protocol::commands::HEADERS, payload); - - mock_conn->simulate_receive(large_headers); - io_context.poll(); - - // CRITICAL: Message should be ignored, no memory exhaustion - // Peer should remain connected or disconnect (both acceptable) - bool safe_state = (peer->is_connected() && peer->state() == PeerConnectionState::VERSION_SENT) || - peer->state() == PeerConnectionState::DISCONNECTED; - CHECK(safe_state); -} - -// ============================================================================= -// TEST 4.3: Message storm before VERACK -// ============================================================================= -TEST_CASE("Handshake Security - Message storm before VERACK", "[adversarial][handshake][security]") { - asio::io_context io_context; - auto mock_conn = std::make_shared(); - const uint32_t magic = protocol::magic::REGTEST; - - auto peer = Peer::create_inbound(io_context, mock_conn, magic, 0); - peer->start(); - io_context.poll(); - - auto version = create_version_message(magic, 12345); - mock_conn->simulate_receive(version); - io_context.poll(); - - REQUIRE(peer->state() == PeerConnectionState::VERSION_SENT); - mock_conn->clear_sent_messages(); - - // Attack: Rapidly send multiple message types before VERACK - for (int i = 0; i < 20; ++i) { - mock_conn->simulate_receive(create_ping_message(magic, i)); - mock_conn->simulate_receive(create_getaddr_message(magic)); - mock_conn->simulate_receive(create_getheaders_message(magic)); - io_context.poll(); - } - - // CRITICAL: All non-handshake messages ignored - CHECK(mock_conn->sent_message_count() == 0); - - // Handshake should complete normally - auto verack = create_verack_message(magic); - mock_conn->simulate_receive(verack); - io_context.poll(); - - CHECK(peer->state() == PeerConnectionState::READY); -} - -// ============================================================================= -// TEST 6.1: VERSION with truncated payload -// ============================================================================= -TEST_CASE("Handshake Security - VERSION with truncated payload", "[adversarial][handshake][security]") { - asio::io_context io_context; - auto mock_conn = std::make_shared(); - const uint32_t magic = protocol::magic::REGTEST; - - auto peer = Peer::create_inbound(io_context, mock_conn, magic, 0); - peer->start(); - io_context.poll(); - - // Create valid VERSION message - auto version = create_version_message(magic, 12345); - - // Truncate payload by 10 bytes (but keep header length field unchanged) - if (version.size() > 34) { // 24-byte header + some payload - version.resize(version.size() - 10); - } - - mock_conn->simulate_receive(version); - io_context.poll(); - - // CRITICAL: Peer should disconnect or reject malformed message - bool safe_state = (peer->state() == PeerConnectionState::DISCONNECTED || - peer->version() == 0); - CHECK(safe_state); -} - -// ============================================================================= -// TEST 6.2: VERSION with extra bytes -// ============================================================================= -TEST_CASE("Handshake Security - VERSION with extra bytes", "[adversarial][handshake][security]") { - asio::io_context io_context; - auto mock_conn = std::make_shared(); - const uint32_t magic = protocol::magic::REGTEST; - - auto peer = Peer::create_inbound(io_context, mock_conn, magic, 0); - peer->start(); - io_context.poll(); - - // Create valid VERSION message - auto version = create_version_message(magic, 12345); - - // Append garbage bytes beyond declared length - std::vector garbage = {0xDE, 0xAD, 0xBE, 0xEF, 0xCA, 0xFE, 0xBA, 0xBE}; - version.insert(version.end(), garbage.begin(), garbage.end()); - - mock_conn->simulate_receive(version); - io_context.poll(); - - // CRITICAL: Peer should either: - // 1. Disconnect due to protocol violation, OR - // 2. Ignore extra bytes and process valid portion (implementation-dependent) - // Both behaviors are acceptable; the key is no crash or undefined behavior - bool safe_state = (peer->state() == PeerConnectionState::DISCONNECTED || - peer->state() == PeerConnectionState::VERSION_SENT || - peer->version() > 0); - CHECK(safe_state); -} - -// ============================================================================= -// TEST 7.2: Invalid magic bytes -// ============================================================================= -TEST_CASE("Handshake Security - Invalid magic bytes", "[adversarial][handshake][security]") { - asio::io_context io_context; - auto mock_conn = std::make_shared(); - const uint32_t magic = protocol::magic::REGTEST; - - auto peer = Peer::create_inbound(io_context, mock_conn, magic, 0); - peer->start(); - io_context.poll(); - - // Send VERSION with invalid magic (not MAINNET, TESTNET, or REGTEST) - const uint32_t invalid_magic = 0xDEADBEEF; - auto version = create_version_message(invalid_magic, 12345); - - mock_conn->simulate_receive(version); - io_context.poll(); - - // CRITICAL: Peer must disconnect on invalid magic - CHECK(peer->state() == PeerConnectionState::DISCONNECTED); -} - -// ============================================================================= -// TEST 7.3: Magic bytes change mid-handshake -// ============================================================================= -TEST_CASE("Handshake Security - Magic bytes change mid-handshake", "[adversarial][handshake][security]") { - asio::io_context io_context; - auto mock_conn = std::make_shared(); - const uint32_t magic = protocol::magic::REGTEST; - - auto peer = Peer::create_inbound(io_context, mock_conn, magic, 0); - peer->start(); - io_context.poll(); - - // Send VERSION with correct magic - auto version = create_version_message(magic, 12345); - mock_conn->simulate_receive(version); - io_context.poll(); - - REQUIRE(peer->state() == PeerConnectionState::VERSION_SENT); - - // Attack: Send VERACK with different magic - const uint32_t wrong_magic = protocol::magic::TESTNET; - auto verack = create_verack_message(wrong_magic); - mock_conn->simulate_receive(verack); - io_context.poll(); - - // CRITICAL: Peer must disconnect on magic mismatch - CHECK(peer->state() == PeerConnectionState::DISCONNECTED); -} - -// ============================================================================= -// TEST 3.2: Messages queued during handshake -// ============================================================================= -TEST_CASE("Handshake Security - Messages queued during handshake", "[adversarial][handshake][security]") { - asio::io_context io_context; - auto mock_conn = std::make_shared(); - const uint32_t magic = protocol::magic::REGTEST; - - auto peer = Peer::create_inbound(io_context, mock_conn, magic, 0); - peer->start(); - io_context.poll(); - - // Send VERSION, PING, VERACK in rapid succession (all in one batch) - auto version = create_version_message(magic, 12345); - auto ping = create_ping_message(magic, 0x12345678); - auto verack = create_verack_message(magic); - - mock_conn->simulate_receive(version); - mock_conn->simulate_receive(ping); // PING sent before VERACK - mock_conn->simulate_receive(verack); - io_context.poll(); - - // Peer should reach READY state - REQUIRE(peer->state() == PeerConnectionState::READY); - mock_conn->clear_sent_messages(); - - // Wait briefly to see if PING gets processed later (it shouldn't) - io_context.poll(); - - // CRITICAL: PING sent before VERACK should NOT trigger PONG later - auto sent_messages = mock_conn->get_sent_messages(); - bool pong_sent = false; - for (const auto& msg : sent_messages) { - if (msg.size() >= 24) { - std::string command(msg.begin() + 4, msg.begin() + 16); - if (command.find("pong") != std::string::npos) { - pong_sent = true; - break; - } - } - } - CHECK(!pong_sent); // PING was ignored, no PONG should be sent -} - -// ============================================================================ -// PHASE 3: RESOURCE EXHAUSTION TESTS -// ============================================================================ -// Tests for DoS protection via resource limits: -// - Recv buffer exhaustion (DEFAULT_RECV_FLOOD_SIZE = 10 MB) -// - Send queue exhaustion (DEFAULT_SEND_QUEUE_SIZE = 10 MB) -// - GETADDR rate limiting -// -// Limits must be >= MAX_PROTOCOL_MESSAGE_LENGTH (8 MB) to allow valid messages. -// See protocol.hpp for limit definitions and peer.cpp for enforcement. - -TEST_CASE("Adversarial - RecvBufferExhaustion", "[adversarial][resource][flood]") { - // SECURITY: Enforces DEFAULT_RECV_FLOOD_SIZE (10 MB) limit - // to prevent memory exhaustion via large messages - // - // Attack scenario: Attacker sends message header claiming large payload, - // then sends partial data. If we buffer unbounded, attacker can OOM us. - // - // Defense: peer.cpp checks if buffer exceeds limit and disconnects - // - // This test verifies we disconnect before buffer exceeds 5MB. - - asio::io_context io_context; - auto mock_conn = std::make_shared(); - const uint32_t magic = protocol::magic::REGTEST; - - auto peer = Peer::create_inbound(io_context, mock_conn, magic, 0); - peer->start(); - io_context.poll(); - - // Complete handshake to reach READY state - auto version = create_version_message(magic, 54321); - mock_conn->simulate_receive(version); - io_context.poll(); - - auto verack = create_verack_message(magic); - mock_conn->simulate_receive(verack); - io_context.poll(); - - REQUIRE(peer->state() == PeerConnectionState::READY); - - // Record baseline stats - uint64_t bytes_received_before = peer->stats().bytes_received.load(); - - // Create a message header claiming 12MB payload (exceeds 10MB limit) - // We'll send the header + 1MB of data, triggering flood protection - // when peer tries to buffer it (total would be >10MB). - const uint32_t claimed_payload_size = 12 * 1024 * 1024; // 12MB - protocol::MessageHeader hdr; - hdr.magic = magic; - hdr.set_command("fakecmd"); // Unknown command (non-zero payload allowed) - hdr.length = claimed_payload_size; - hdr.checksum = {0x00, 0x00, 0x00, 0x00}; // Fake checksum (we won't get to validation) - - // Serialize header (24 bytes) - message::MessageSerializer s; - s.write_uint32(hdr.magic); - for (char c : hdr.command) { - s.write_uint8(static_cast(c)); - } - s.write_uint32(hdr.length); - for (uint8_t b : hdr.checksum) { - s.write_uint8(b); - } - - std::vector header_bytes = s.data(); - REQUIRE(header_bytes.size() == protocol::MESSAGE_HEADER_SIZE); - - // Send header - mock_conn->simulate_receive(header_bytes); - io_context.poll(); - - // Check if disconnected already (header parsing might trigger limit check) - if (peer->state() == PeerConnectionState::DISCONNECTED || - peer->state() == PeerConnectionState::DISCONNECTING) { - // Good! Disconnected early (before buffering full payload) - CHECK(peer->state() != PeerConnectionState::READY); - return; - } - - // Still connected after header, now send 1MB of payload data - // This should push buffer over 10MB limit and trigger disconnect - const size_t chunk_size = 1 * 1024 * 1024; // 1MB - std::vector payload_chunk(chunk_size, 0xAA); - - size_t sent_before_disconnect = mock_conn->sent_message_count(); - - mock_conn->simulate_receive(payload_chunk); - io_context.poll(); - - // CRITICAL SECURITY CHECK: Peer must disconnect when recv buffer would exceed 10MB - // peer.cpp:338: if (usable_bytes + data.size() > DEFAULT_RECV_FLOOD_SIZE) disconnect - bool disconnected = (peer->state() == PeerConnectionState::DISCONNECTED || - peer->state() == PeerConnectionState::DISCONNECTING); - - CHECK(disconnected); // Must disconnect on flood - - // Verify no response sent (egress silence on resource exhaustion) - CHECK(mock_conn->sent_message_count() == sent_before_disconnect); - - // Stats verification: bytes_received should reflect what was buffered before disconnect - uint64_t bytes_received_after = peer->stats().bytes_received.load(); - uint64_t delta = bytes_received_after - bytes_received_before; - - // We sent header (24 bytes) + chunk (1MB), but peer may have disconnected - // before processing all of it. Just verify some bytes were received. - CHECK(delta > 0); - CHECK(delta <= header_bytes.size() + chunk_size); -} - -// NOTE: Send queue exhaustion testing is covered in test/network/real_transport_tests.cpp -// ("Send-queue overflow closes connection") because it's a transport-level protection, -// not a Peer-level protocol concern. That test verifies real_transport.cpp:274 enforcement. - -// NOTE: GETADDR "rate limiting" test -// Bitcoin Core (and Unicity) don't rate-limit GETADDR requests per se. -// Instead, they use once-per-connection gating: addr_relay_manager.cpp:312-319 -// Only the FIRST GETADDR on each connection gets a response. -// This is a simpler and more effective DoS protection than rate limiting. -// -// The test below verifies this once-per-connection gating behavior. - -TEST_CASE("Adversarial - GetAddrOncePerConnection", "[adversarial][resource][getaddr]") { - // SECURITY: Bitcoin Core responds to GETADDR only once per connection - // (addr_relay_manager.cpp:312-319) - // - // Attack scenario: Attacker sends many GETADDR messages to exhaust CPU/bandwidth - // - // Defense: Once-per-connection gating - only first GETADDR gets a response - // - // This test verifies the gating is enforced (not a full integration test, - // just verifies the message is accepted without error - full test would - // require NetworkManager/AddrRelayManager integration) - - asio::io_context io_context; - auto mock_conn = std::make_shared(); - const uint32_t magic = protocol::magic::REGTEST; - - auto peer = Peer::create_inbound(io_context, mock_conn, magic, 0); - peer->start(); - io_context.poll(); - - // Complete handshake - auto version = create_version_message(magic, 54321); - mock_conn->simulate_receive(version); - io_context.poll(); - - auto verack = create_verack_message(magic); - mock_conn->simulate_receive(verack); - io_context.poll(); - - REQUIRE(peer->state() == PeerConnectionState::READY); - - // Send first GETADDR (zero-length payload allowed for GETADDR) - auto getaddr1 = create_test_message(magic, protocol::commands::GETADDR, {}); - mock_conn->simulate_receive(getaddr1); - io_context.poll(); - - // Peer should still be connected (GETADDR is valid) - CHECK(peer->is_connected()); - - // Send second GETADDR - auto getaddr2 = create_test_message(magic, protocol::commands::GETADDR, {}); - mock_conn->simulate_receive(getaddr2); - io_context.poll(); - - // Peer should STILL be connected (once-per-connection gating doesn't disconnect, - // it just silently ignores subsequent GETADDR requests) - CHECK(peer->is_connected()); - - // Send third GETADDR - auto getaddr3 = create_test_message(magic, protocol::commands::GETADDR, {}); - mock_conn->simulate_receive(getaddr3); - io_context.poll(); - - // Still connected - gating is passive (no disconnect) - CHECK(peer->is_connected()); - - // NOTE: This test verifies Peer-level handling (message acceptance). - // Full verification of once-per-connection gating happens at AddrRelayManager level - // (addr_relay_manager.cpp:312-319) and is covered by discovery tests. - // The adversarial aspect we're testing here is: spamming GETADDR doesn't crash/disconnect. -} - -// ============================================================================= -// PHASE 4: NETWORK SECURITY TESTS -// ============================================================================= - -TEST_CASE("Adversarial - WrongNetworkMagic", "[adversarial][security][network-magic]") { - // Bitcoin Core: net_processing.cpp rejects messages with wrong magic bytes immediately - // Security: Prevents cross-network pollution (mainnet peer sending testnet messages) - asio::io_context io_context; - auto mock_conn = std::make_shared(); - const uint32_t correct_magic = protocol::magic::REGTEST; - const uint32_t wrong_magic = protocol::magic::MAINNET; // Different network - - auto peer = Peer::create_inbound(io_context, mock_conn, correct_magic, 0); - peer->start(); - io_context.poll(); - - const auto msgs_before = peer->stats().messages_received.load(); - const auto bytes_before = peer->stats().bytes_received.load(); - - SECTION("VERSION with wrong magic → disconnect") { - // Create VERSION message with MAINNET magic instead of REGTEST - message::VersionMessage msg; - msg.version = protocol::PROTOCOL_VERSION; - msg.services = protocol::NODE_NETWORK; - msg.timestamp = 1234567890; - msg.nonce = 999999; - msg.user_agent = "/Attacker:1.0.0/"; - msg.start_height = 0; - - auto payload = msg.serialize(); - auto malicious_msg = create_test_message(wrong_magic, protocol::commands::VERSION, payload); - - mock_conn->simulate_receive(malicious_msg); - io_context.poll(); - - // Peer should disconnect (wrong magic is protocol violation) - CHECK_FALSE(peer->is_connected()); - - // No message should be processed (magic check happens before deserialization) - const auto msgs_after = peer->stats().messages_received.load(); - const auto bytes_after = peer->stats().bytes_received.load(); - CHECK(msgs_after == msgs_before); - // Note: bytes_received is updated at transport layer (before validation) - // so bytes_after > bytes_before is expected. What matters is msgs_received didn't increment. - CHECK(bytes_after > bytes_before); - } - - SECTION("Correct magic followed by wrong magic → disconnect on second") { - // First message: correct magic (REGTEST) - auto version1 = create_version_message(correct_magic, 111111); - mock_conn->simulate_receive(version1); - io_context.poll(); - - CHECK(peer->is_connected()); - const auto msgs_middle = peer->stats().messages_received.load(); - CHECK(msgs_middle == msgs_before + 1); - - // Second message: wrong magic (MAINNET) - auto version2 = create_version_message(wrong_magic, 222222); - mock_conn->simulate_receive(version2); - io_context.poll(); - - // Should disconnect on wrong magic - CHECK_FALSE(peer->is_connected()); - - // Only first message processed - const auto msgs_after = peer->stats().messages_received.load(); - CHECK(msgs_after == msgs_middle); // No increment - } - - SECTION("Magic bytes in payload → correctly framed, not confused") { - // Ensure magic bytes appearing in message payload don't confuse parser - // This tests that framing is robust against payload content - - // Create payload containing wrong magic bytes - std::vector payload; - uint32_t embedded_magic = wrong_magic; - payload.insert(payload.end(), - reinterpret_cast(&embedded_magic), - reinterpret_cast(&embedded_magic) + 4); - payload.insert(payload.end(), 100, 0xAA); // Padding - - // But header has correct magic - auto framed_msg = create_test_message(correct_magic, protocol::commands::VERSION, payload); - - mock_conn->simulate_receive(framed_msg); - io_context.poll(); - - // Should NOT confuse embedded magic with real magic - // Message processed normally (though it will fail deserialization due to invalid payload) - const auto msgs_after = peer->stats().messages_received.load(); - - // Message was received at network layer (magic was correct) - // Deserialization might fail but that's okay - we're testing magic check isolation - CHECK(msgs_after >= msgs_before); - } -} - -TEST_CASE("Adversarial - UnsupportedProtocolVersion", "[adversarial][security][version]") { - // Bitcoin Core: Rejects peers with version < MIN_PROTOCOL_VERSION - // Ref: protocol.hpp MIN_PROTOCOL_VERSION = 1 - asio::io_context io_context; - auto mock_conn = std::make_shared(); - const uint32_t magic = protocol::magic::REGTEST; - - auto peer = Peer::create_inbound(io_context, mock_conn, magic, 0); - peer->start(); - io_context.poll(); - - const auto msgs_before = peer->stats().messages_received.load(); - - SECTION("VERSION with protocol_version = 0 → disconnect") { - message::VersionMessage msg; - msg.version = 0; // Too old (< MIN_PROTOCOL_VERSION = 1) - msg.services = protocol::NODE_NETWORK; - msg.timestamp = 1234567890; - msg.nonce = 123456; - msg.user_agent = "/OldNode:0.1.0/"; - msg.start_height = 0; - - auto payload = msg.serialize(); - auto version_msg = create_test_message(magic, protocol::commands::VERSION, payload); - - mock_conn->simulate_receive(version_msg); - io_context.poll(); - - // Peer should disconnect (unsupported version) - CHECK_FALSE(peer->is_connected()); - - // Message was received but caused disconnect - const auto msgs_after = peer->stats().messages_received.load(); - CHECK(msgs_after == msgs_before + 1); - - // Peer version should not be set (handshake failed) - CHECK(peer->version() == 0); - } - - SECTION("VERSION with future protocol_version → accept") { - // Bitcoin Core accepts peers with newer versions (forward compatibility) - message::VersionMessage msg; - msg.version = 99999; // Far future version - msg.services = protocol::NODE_NETWORK; - msg.timestamp = 1234567890; - msg.nonce = 654321; - msg.user_agent = "/FutureNode:99.0.0/"; - msg.start_height = 100; - - auto payload = msg.serialize(); - auto version_msg = create_test_message(magic, protocol::commands::VERSION, payload); - - mock_conn->simulate_receive(version_msg); - io_context.poll(); - - // Should accept (forward compatibility) - CHECK(peer->is_connected()); - - // Peer version should be set - CHECK(peer->version() == 99999); - - const auto msgs_after = peer->stats().messages_received.load(); - CHECK(msgs_after == msgs_before + 1); - } - - SECTION("VERSION with exactly MIN_PROTOCOL_VERSION → accept") { - message::VersionMessage msg; - msg.version = protocol::MIN_PROTOCOL_VERSION; // Exactly at boundary - msg.services = protocol::NODE_NETWORK; - msg.timestamp = 1234567890; - msg.nonce = 111222; - msg.user_agent = "/MinNode:1.0.0/"; - msg.start_height = 0; - - auto payload = msg.serialize(); - auto version_msg = create_test_message(magic, protocol::commands::VERSION, payload); - - mock_conn->simulate_receive(version_msg); - io_context.poll(); - - // Should accept (at minimum) - CHECK(peer->is_connected()); - CHECK(peer->version() == protocol::MIN_PROTOCOL_VERSION); - - const auto msgs_after = peer->stats().messages_received.load(); - CHECK(msgs_after == msgs_before + 1); - } -} - -TEST_CASE("Adversarial - InvalidChecksum", "[adversarial][security][checksum]") { - // Bitcoin Core: Disconnects peers sending messages with invalid checksums - // Ref: src/net.cpp V1Transport::CompleteMessage() - asio::io_context io_context; - auto mock_conn = std::make_shared(); - const uint32_t magic = protocol::magic::REGTEST; - - auto peer = Peer::create_inbound(io_context, mock_conn, magic, 0); - peer->start(); - io_context.poll(); - - const auto msgs_before = peer->stats().messages_received.load(); - const auto bytes_before = peer->stats().bytes_received.load(); - - SECTION("VERSION with corrupted checksum → disconnect") { - message::VersionMessage msg; - msg.version = protocol::PROTOCOL_VERSION; - msg.services = protocol::NODE_NETWORK; - msg.timestamp = 1234567890; - msg.nonce = 123456; - msg.user_agent = "/Test:1.0.0/"; - msg.start_height = 0; - - auto payload = msg.serialize(); - - // Create message with valid checksum first - protocol::MessageHeader header(magic, protocol::commands::VERSION, static_cast(payload.size())); - uint256 hash = Hash(payload); - std::memcpy(header.checksum.data(), hash.begin(), 4); - auto header_bytes = message::serialize_header(header); - - // Corrupt the checksum (bytes 20-23 of header) - header_bytes[20] ^= 0xFF; - header_bytes[21] ^= 0xFF; - header_bytes[22] ^= 0xFF; - header_bytes[23] ^= 0xFF; - - std::vector corrupted_msg; - corrupted_msg.insert(corrupted_msg.end(), header_bytes.begin(), header_bytes.end()); - corrupted_msg.insert(corrupted_msg.end(), payload.begin(), payload.end()); - - mock_conn->simulate_receive(corrupted_msg); - io_context.poll(); - - // Peer should disconnect (checksum mismatch) - CHECK_FALSE(peer->is_connected()); - - // Message should not be processed (checksum validation before processing) - const auto msgs_after = peer->stats().messages_received.load(); - const auto bytes_after = peer->stats().bytes_received.load(); - CHECK(msgs_after == msgs_before); // No increment (validation failed) - // Note: bytes_received is updated at transport layer (before validation) - CHECK(bytes_after > bytes_before); // Bytes were received, just not accepted - } - - SECTION("Valid checksum → accepted") { - // Verify that valid messages still work (control test) - auto version_msg = create_version_message(magic, 999888); - mock_conn->simulate_receive(version_msg); - io_context.poll(); - - CHECK(peer->is_connected()); - - const auto msgs_after = peer->stats().messages_received.load(); - CHECK(msgs_after == msgs_before + 1); - } - - SECTION("Empty payload with zero checksum → special case") { - // Bitcoin Core: Empty payloads have checksum 0x5df6e0e2 (hash of empty string) - // NOT all-zeros. Test that we handle empty payloads correctly. - - std::vector empty_payload; - protocol::MessageHeader header(magic, protocol::commands::VERACK, static_cast(empty_payload.size())); - uint256 hash = Hash(empty_payload); - std::memcpy(header.checksum.data(), hash.begin(), 4); - - // VERACK has empty payload - checksum should be hash(empty), not 0x00000000 - // If our implementation uses 0x00000000 for empty payloads, this is a bug - auto header_bytes = message::serialize_header(header); - - // Extract checksum from generated header - uint32_t checksum; - std::memcpy(&checksum, &header_bytes[20], 4); - - // Bitcoin Core: SHA256(SHA256(""))[:4] = 0x5df6e0e2 - // Our implementation should match - CHECK(checksum != 0x00000000); // Not all-zeros - - // Send the message - std::vector verack_msg; - verack_msg.insert(verack_msg.end(), header_bytes.begin(), header_bytes.end()); - - // First send VERSION to enable VERACK - auto version = create_version_message(magic, 111222); - mock_conn->simulate_receive(version); - io_context.poll(); - - const auto msgs_middle = peer->stats().messages_received.load(); - - mock_conn->simulate_receive(verack_msg); - io_context.poll(); - - // Should accept (valid checksum for empty payload) - // Note: might disconnect due to protocol reasons (unexpected VERACK from inbound) - // but NOT due to checksum - const auto msgs_after = peer->stats().messages_received.load(); - CHECK(msgs_after == msgs_middle + 1); // Message was processed - } -} - -// ============================================================================= -// MESSAGE LIBRARY EDGE CASE TESTS -// ============================================================================= - - -TEST_CASE("Adversarial - PONG with short payload", "[adversarial][pong][security]") { - // SECURITY: PONG must be exactly 8 bytes (nonce) - // Short PONG should be rejected - asio::io_context io_context; - auto mock_conn = std::make_shared(); - const uint32_t magic = protocol::magic::REGTEST; - - auto peer = Peer::create_inbound(io_context, mock_conn, magic, 0); - peer->start(); - io_context.poll(); - - // Complete handshake - auto version = create_version_message(magic, 54321); - mock_conn->simulate_receive(version); - io_context.poll(); - - auto verack = create_verack_message(magic); - mock_conn->simulate_receive(verack); - io_context.poll(); - REQUIRE(peer->state() == PeerConnectionState::READY); - - SECTION("PONG with 0 bytes (empty)") { - std::vector empty_payload; - auto malformed_pong = create_test_message(magic, protocol::commands::PONG, empty_payload); - mock_conn->simulate_receive(malformed_pong); - io_context.poll(); - CHECK(peer->state() == PeerConnectionState::DISCONNECTED); - } - - SECTION("PONG with 4 bytes (half nonce)") { - std::vector short_payload = {0x01, 0x02, 0x03, 0x04}; - auto malformed_pong = create_test_message(magic, protocol::commands::PONG, short_payload); - mock_conn->simulate_receive(malformed_pong); - io_context.poll(); - CHECK(peer->state() == PeerConnectionState::DISCONNECTED); - } - - SECTION("PONG with 7 bytes (one byte short)") { - std::vector almost_payload = {0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07}; - auto malformed_pong = create_test_message(magic, protocol::commands::PONG, almost_payload); - mock_conn->simulate_receive(malformed_pong); - io_context.poll(); - CHECK(peer->state() == PeerConnectionState::DISCONNECTED); - } -} - -TEST_CASE("Adversarial - ADDR edge cases", "[adversarial][addr][security]") { - asio::io_context io_context; - auto mock_conn = std::make_shared(); - const uint32_t magic = protocol::magic::REGTEST; - - auto peer = Peer::create_inbound(io_context, mock_conn, magic, 0); - peer->start(); - io_context.poll(); - - // Complete handshake - auto version = create_version_message(magic, 54321); - mock_conn->simulate_receive(version); - io_context.poll(); - - auto verack = create_verack_message(magic); - mock_conn->simulate_receive(verack); - io_context.poll(); - REQUIRE(peer->state() == PeerConnectionState::READY); - - SECTION("ADDR with truncated address entry") { - // Each ADDR entry is 30 bytes: timestamp(4) + services(8) + ip(16) + port(2) - message::MessageSerializer s; - s.write_varint(1); // 1 address - s.write_uint32(0); // timestamp - s.write_uint64(1); // services - // Missing IP and port (only 12 bytes instead of 30) - - auto payload = s.data(); - auto addr_msg = create_test_message(magic, protocol::commands::ADDR, payload); - mock_conn->simulate_receive(addr_msg); - io_context.poll(); - - // Should disconnect due to incomplete message - CHECK(peer->state() == PeerConnectionState::DISCONNECTED); - } - - SECTION("ADDR with zero addresses") { - message::MessageSerializer s; - s.write_varint(0); // 0 addresses - - auto payload = s.data(); - auto addr_msg = create_test_message(magic, protocol::commands::ADDR, payload); - mock_conn->simulate_receive(addr_msg); - io_context.poll(); - - // Empty ADDR should be accepted (valid but useless) - CHECK(peer->is_connected()); - } - - SECTION("ADDR with all-zero IP address") { - message::MessageSerializer s; - s.write_varint(1); - s.write_uint32(static_cast(std::time(nullptr))); // timestamp - s.write_uint64(1); // services - // IPv4-mapped 0.0.0.0 - for (int i = 0; i < 10; ++i) s.write_uint8(0); - s.write_uint8(0xFF); s.write_uint8(0xFF); - s.write_uint8(0); s.write_uint8(0); s.write_uint8(0); s.write_uint8(0); - s.write_uint16(9590); // port - - auto payload = s.data(); - auto addr_msg = create_test_message(magic, protocol::commands::ADDR, payload); - mock_conn->simulate_receive(addr_msg); - io_context.poll(); - - // Should remain connected (invalid IPs are filtered at application layer) - CHECK(peer->is_connected()); - } - - SECTION("ADDR with loopback address") { - message::MessageSerializer s; - s.write_varint(1); - s.write_uint32(static_cast(std::time(nullptr))); - s.write_uint64(1); - // IPv4-mapped 127.0.0.1 - for (int i = 0; i < 10; ++i) s.write_uint8(0); - s.write_uint8(0xFF); s.write_uint8(0xFF); - s.write_uint8(127); s.write_uint8(0); s.write_uint8(0); s.write_uint8(1); - s.write_uint16(9590); - - auto payload = s.data(); - auto addr_msg = create_test_message(magic, protocol::commands::ADDR, payload); - mock_conn->simulate_receive(addr_msg); - io_context.poll(); - - // Loopback addresses may be filtered but shouldn't crash - CHECK(peer->is_connected()); - } -} - -TEST_CASE("Adversarial - GETHEADERS edge cases", "[adversarial][getheaders][security]") { - asio::io_context io_context; - auto mock_conn = std::make_shared(); - const uint32_t magic = protocol::magic::REGTEST; - - auto peer = Peer::create_inbound(io_context, mock_conn, magic, 0); - peer->start(); - io_context.poll(); - - // Complete handshake - auto version = create_version_message(magic, 54321); - mock_conn->simulate_receive(version); - io_context.poll(); - - auto verack = create_verack_message(magic); - mock_conn->simulate_receive(verack); - io_context.poll(); - REQUIRE(peer->state() == PeerConnectionState::READY); - - SECTION("GETHEADERS with empty locator (just stop hash)") { - message::MessageSerializer s; - s.write_uint32(protocol::PROTOCOL_VERSION); // version - s.write_varint(0); // 0 locator hashes - for (int i = 0; i < 32; ++i) s.write_uint8(0); // stop hash (all zeros) - - auto payload = s.data(); - auto msg = create_test_message(magic, protocol::commands::GETHEADERS, payload); - mock_conn->simulate_receive(msg); - io_context.poll(); - - // Empty locator is valid (means "give me headers from genesis") - CHECK(peer->is_connected()); - } - - SECTION("GETHEADERS with truncated payload") { - // Only version field, no locator count - message::MessageSerializer s; - s.write_uint32(protocol::PROTOCOL_VERSION); - - auto payload = s.data(); - auto msg = create_test_message(magic, protocol::commands::GETHEADERS, payload); - mock_conn->simulate_receive(msg); - io_context.poll(); - - CHECK(peer->state() == PeerConnectionState::DISCONNECTED); - } -} +// Adversarial tests for network/peer.cpp - Attack scenarios and edge cases (ported to test2) + +#include "catch_amalgamated.hpp" +#include "network/peer.hpp" +#include "network/transport.hpp" +#include "network/real_transport.hpp" +#include "network/protocol.hpp" +#include "network/message.hpp" +#include "util/hash.hpp" +#include +#include +#include +#include +#include +#include +#include +#include +#include + +using namespace unicity; +using namespace unicity::network; + +// ============================================================================= +// MOCK TRANSPORT (from legacy tests) +// ============================================================================= + +#include "infra/mock_transport.hpp" + +// ============================================================================= +// HELPERS +// ============================================================================= + +static std::vector create_test_message( + uint32_t magic, + const std::string& command, + const std::vector& payload) +{ + protocol::MessageHeader header(magic, command, static_cast(payload.size())); + uint256 hash = Hash(payload); + std::memcpy(header.checksum.data(), hash.begin(), 4); + auto header_bytes = message::serialize_header(header); + + std::vector full_message; + full_message.insert(full_message.end(), header_bytes.begin(), header_bytes.end()); + full_message.insert(full_message.end(), payload.begin(), payload.end()); + return full_message; +} + +static std::vector create_version_message(uint32_t magic, uint64_t nonce) { + message::VersionMessage msg; + msg.version = protocol::PROTOCOL_VERSION; + msg.services = protocol::NODE_NETWORK; + msg.timestamp = 1234567890; + msg.nonce = nonce; + msg.user_agent = "/Test:1.0.0/"; + msg.start_height = 0; + + auto payload = msg.serialize(); + return create_test_message(magic, protocol::commands::VERSION, payload); +} + +static std::vector create_verack_message(uint32_t magic) { + message::VerackMessage msg; + auto payload = msg.serialize(); + return create_test_message(magic, protocol::commands::VERACK, payload); +} + +static std::vector create_ping_message(uint32_t magic, uint64_t nonce) { + message::PingMessage msg(nonce); + auto payload = msg.serialize(); + return create_test_message(magic, protocol::commands::PING, payload); +} + +static std::vector create_pong_message(uint32_t magic, uint64_t nonce) { + message::PongMessage msg(nonce); + auto payload = msg.serialize(); + return create_test_message(magic, protocol::commands::PONG, payload); +} + +// ============================================================================= +// MALFORMED MESSAGE ATTACKS +// ============================================================================= + +TEST_CASE("Adversarial - PartialHeaderAttack", "[adversarial][malformed]") { + asio::io_context io_context; + auto mock_conn = std::make_shared(); + const uint32_t magic = protocol::magic::REGTEST; + + auto peer = Peer::create_inbound(io_context, mock_conn, magic, 0); + peer->start(); + io_context.poll(); + + SECTION("Partial header (only magic bytes)") { + std::vector partial_header(4); + std::memcpy(partial_header.data(), &magic, 4); + + mock_conn->simulate_receive(partial_header); + io_context.poll(); + + CHECK(peer->is_connected()); + CHECK(peer->version() == 0); + } + + SECTION("Partial header then timeout") { + std::vector partial_header(12); // Only 12 of 24 header bytes + mock_conn->simulate_receive(partial_header); + io_context.poll(); + CHECK(peer->is_connected()); + } +} + +TEST_CASE("Adversarial - HeaderLengthMismatch", "[adversarial][malformed]") { + asio::io_context io_context; + auto mock_conn = std::make_shared(); + const uint32_t magic = protocol::magic::REGTEST; + + auto peer = Peer::create_inbound(io_context, mock_conn, magic, 0); + peer->start(); + io_context.poll(); + + SECTION("Header claims 112 bytes, send 50 bytes") { + protocol::MessageHeader header(magic, protocol::commands::VERSION, 112); + header.checksum = [&](const auto& data) { uint256 hash = Hash(data); std::array checksum; std::memcpy(checksum.data(), hash.begin(), 4); return checksum; }(std::vector(112, 0)); + auto header_bytes = message::serialize_header(header); + std::vector partial_payload(50, 0xAA); + std::vector malicious_msg; + malicious_msg.insert(malicious_msg.end(), header_bytes.begin(), header_bytes.end()); + malicious_msg.insert(malicious_msg.end(), partial_payload.begin(), partial_payload.end()); + mock_conn->simulate_receive(malicious_msg); + io_context.poll(); + CHECK(peer->is_connected()); + CHECK(peer->version() == 0); + } + + SECTION("Header claims 0 bytes, send 112 bytes") { + protocol::MessageHeader header(magic, protocol::commands::VERSION, 0); + header.checksum.fill(0); + auto header_bytes = message::serialize_header(header); + std::vector unexpected_payload(112, 0xBB); + std::vector malicious_msg; + malicious_msg.insert(malicious_msg.end(), header_bytes.begin(), header_bytes.end()); + malicious_msg.insert(malicious_msg.end(), unexpected_payload.begin(), unexpected_payload.end()); + mock_conn->simulate_receive(malicious_msg); + io_context.poll(); + CHECK(peer->state() == PeerConnectionState::DISCONNECTED); + } +} + +TEST_CASE("Adversarial - EmptyCommandField", "[adversarial][malformed]") { + asio::io_context io_context; + auto mock_conn = std::make_shared(); + const uint32_t magic = protocol::magic::REGTEST; + + auto peer = Peer::create_inbound(io_context, mock_conn, magic, 0); + peer->start(); + io_context.poll(); + + protocol::MessageHeader header; + header.magic = magic; + header.command.fill(0); + header.length = 0; + header.checksum.fill(0); + + auto header_bytes = message::serialize_header(header); + mock_conn->simulate_receive(header_bytes); + io_context.poll(); + + CHECK(peer->state() == PeerConnectionState::DISCONNECTED); +} + +TEST_CASE("Adversarial - NonPrintableCommandCharacters", "[adversarial][malformed]") { + asio::io_context io_context; + auto mock_conn = std::make_shared(); + const uint32_t magic = protocol::magic::REGTEST; + + auto peer = Peer::create_inbound(io_context, mock_conn, magic, 0); + peer->start(); + io_context.poll(); + + protocol::MessageHeader header; + header.magic = magic; + header.command = { static_cast(0xFF), static_cast(0xFE), static_cast(0xFD), static_cast(0xFC), + static_cast(0xFB), static_cast(0xFA), static_cast(0xF9), static_cast(0xF8), + static_cast(0xF7), static_cast(0xF6), static_cast(0xF5), static_cast(0xF4) }; + header.length = 0; + header.checksum.fill(0); + + auto header_bytes = message::serialize_header(header); + mock_conn->simulate_receive(header_bytes); + io_context.poll(); + + CHECK(peer->state() == PeerConnectionState::DISCONNECTED); +} + +// ============================================================================= +// PROTOCOL STATE MACHINE ATTACKS +// ============================================================================= + +TEST_CASE("Adversarial - RapidVersionFlood", "[adversarial][flood]") { + asio::io_context io_context; + auto mock_conn = std::make_shared(); + const uint32_t magic = protocol::magic::REGTEST; + + auto peer = Peer::create_inbound(io_context, mock_conn, magic, 0); + peer->start(); + io_context.poll(); + + auto version1 = create_version_message(magic, 54321); + mock_conn->simulate_receive(version1); + io_context.poll(); + + CHECK(peer->version() == protocol::PROTOCOL_VERSION); + CHECK(peer->peer_nonce() == 54321); + + for (int i = 0; i < 99; i++) { + auto version_dup = create_version_message(magic, 99999 + i); + mock_conn->simulate_receive(version_dup); + io_context.poll(); + } + + CHECK(peer->version() == protocol::PROTOCOL_VERSION); + CHECK(peer->peer_nonce() == 54321); + CHECK(peer->is_connected()); +} + +TEST_CASE("Adversarial - RapidVerackFlood", "[adversarial][flood]") { + asio::io_context io_context; + auto mock_conn = std::make_shared(); + const uint32_t magic = protocol::magic::REGTEST; + + auto peer = Peer::create_outbound(io_context, mock_conn, magic, 0); + peer->start(); + io_context.poll(); + + auto version = create_version_message(magic, 54321); + mock_conn->simulate_receive(version); + io_context.poll(); + + auto verack1 = create_verack_message(magic); + mock_conn->simulate_receive(verack1); + io_context.poll(); + + CHECK(peer->state() == PeerConnectionState::READY); + + for (int i = 0; i < 99; i++) { + auto verack_dup = create_verack_message(magic); + mock_conn->simulate_receive(verack_dup); + io_context.poll(); + } + + CHECK(peer->state() == PeerConnectionState::READY); + CHECK(peer->is_connected()); +} + +TEST_CASE("Adversarial - AlternatingVersionVerack", "[adversarial][protocol]") { + asio::io_context io_context; + auto mock_conn = std::make_shared(); + const uint32_t magic = protocol::magic::REGTEST; + + auto peer = Peer::create_inbound(io_context, mock_conn, magic, 0); + peer->start(); + io_context.poll(); + + for (int i = 0; i < 10; i++) { + auto version = create_version_message(magic, 50000 + i); + mock_conn->simulate_receive(version); + io_context.poll(); + if (!peer->is_connected()) break; + auto verack = create_verack_message(magic); + mock_conn->simulate_receive(verack); + io_context.poll(); + if (!peer->is_connected()) break; + } + + CHECK(peer->state() == PeerConnectionState::READY); + CHECK(peer->peer_nonce() == 50000); +} + +// ============================================================================= +// RESOURCE EXHAUSTION ATTACKS +// ============================================================================= + +TEST_CASE("Adversarial - SlowDataDrip", "[adversarial][resource]") { + asio::io_context io_context; + auto mock_conn = std::make_shared(); + const uint32_t magic = protocol::magic::REGTEST; + + auto peer = Peer::create_inbound(io_context, mock_conn, magic, 0); + peer->start(); + io_context.poll(); + + auto version = create_version_message(magic, 54321); + for (size_t i = 0; i < version.size(); i++) { + std::vector single_byte = {version[i]}; + mock_conn->simulate_receive(single_byte); + io_context.poll(); + } + + CHECK(peer->version() == protocol::PROTOCOL_VERSION); + CHECK(peer->is_connected()); +} + +TEST_CASE("Adversarial - MultiplePartialMessages", "[adversarial][resource]") { + asio::io_context io_context; + auto mock_conn = std::make_shared(); + const uint32_t magic = protocol::magic::REGTEST; + + auto peer = Peer::create_inbound(io_context, mock_conn, magic, 0); + peer->start(); + io_context.poll(); + + // Send partial headers (12 bytes each containing 0xCC) + // After 2 iterations, buffer has 24 bytes → triggers header parse + // Magic bytes 0xCCCCCCCC don't match REGTEST → disconnect + for (int i = 0; i < 10; i++) { + std::vector partial_header(12, 0xCC); + mock_conn->simulate_receive(partial_header); + io_context.poll(); + if (!peer->is_connected()) { + break; + } + } + + // NOTE: This test validates wrong magic detection, not partial message handling. + // Partial headers < 24 bytes are tolerated (buffered until complete). + // At 24 bytes, header is parsed and bad magic (0xCCCCCCCC) triggers disconnect. + CHECK(peer->state() == PeerConnectionState::DISCONNECTED); +} + +TEST_CASE("Adversarial - BufferFragmentation", "[adversarial][resource]") { + asio::io_context io_context; + auto mock_conn = std::make_shared(); + const uint32_t magic = protocol::magic::REGTEST; + + auto peer = Peer::create_inbound(io_context, mock_conn, magic, 0); + peer->start(); + io_context.poll(); + + auto version = create_version_message(magic, 54321); + mock_conn->simulate_receive(version); + io_context.poll(); + CHECK(peer->version() == protocol::PROTOCOL_VERSION); + + auto verack = create_verack_message(magic); + mock_conn->simulate_receive(verack); + io_context.poll(); + REQUIRE(peer->state() == PeerConnectionState::READY); + + auto bad_ping = create_ping_message(0xBADBAD, 99999); + mock_conn->simulate_receive(bad_ping); + io_context.poll(); + + CHECK(peer->state() == PeerConnectionState::DISCONNECTED); +} + +// ============================================================================= +// TIMING ATTACKS +// ============================================================================= + +TEST_CASE("Adversarial - ExtremeTimestamps", "[adversarial][timing]") { + asio::io_context io_context; + auto mock_conn = std::make_shared(); + const uint32_t magic = protocol::magic::REGTEST; + + auto peer = Peer::create_inbound(io_context, mock_conn, magic, 0); + peer->start(); + io_context.poll(); + + SECTION("Timestamp = 0 (January 1970)") { + message::VersionMessage msg; + msg.version = protocol::PROTOCOL_VERSION; + msg.services = protocol::NODE_NETWORK; + msg.timestamp = 0; + msg.nonce = 54321; + msg.user_agent = "/Test:1.0.0/"; + msg.start_height = 0; + auto payload = msg.serialize(); + auto full_msg = create_test_message(magic, protocol::commands::VERSION, payload); + mock_conn->simulate_receive(full_msg); + io_context.poll(); + CHECK(peer->version() == protocol::PROTOCOL_VERSION); + CHECK(peer->is_connected()); + } + + SECTION("Timestamp = MAX_INT64 (far future)") { + message::VersionMessage msg; + msg.version = protocol::PROTOCOL_VERSION; + msg.services = protocol::NODE_NETWORK; + msg.timestamp = std::numeric_limits::max(); + msg.nonce = 54321; + msg.user_agent = "/Test:1.0.0/"; + msg.start_height = 0; + auto payload = msg.serialize(); + auto full_msg = create_test_message(magic, protocol::commands::VERSION, payload); + mock_conn->simulate_receive(full_msg); + io_context.poll(); + CHECK(peer->version() == protocol::PROTOCOL_VERSION); + CHECK(peer->is_connected()); + } +} + +// ============================================================================= +// MESSAGE SEQUENCE ATTACKS +// ============================================================================= + +TEST_CASE("Adversarial - OutOfOrderHandshake", "[adversarial][protocol]") { + asio::io_context io_context; + auto mock_conn = std::make_shared(); + const uint32_t magic = protocol::magic::REGTEST; + + SECTION("VERACK then VERSION then VERACK (outbound)") { + // Bitcoin Core behavior: ignore non-version messages before handshake (no disconnect) + // Core: net_processing.cpp:3657-3660 - logs and returns without disconnecting + auto peer = Peer::create_outbound(io_context, mock_conn, magic, 0); + peer->start(); + io_context.poll(); + + // Track message count after VERSION sent during start() + size_t count_after_version = mock_conn->sent_message_count(); + + auto verack1 = create_verack_message(magic); + mock_conn->simulate_receive(verack1); + io_context.poll(); + + // SECURITY: Peer must ignore premature VERACK (match Bitcoin Core) + // Premature VERACK is silently ignored, peer stays connected waiting for VERSION + CHECK(peer->is_connected()); + CHECK(peer->version() == 0); // Still waiting for VERSION + + // Assert no egress: peer must not send any messages in response to premature VERACK + CHECK(mock_conn->sent_message_count() == count_after_version); + } + + SECTION("Double VERSION with VERACK in between") { + auto peer = Peer::create_inbound(io_context, mock_conn, magic, 0); + peer->start(); + io_context.poll(); + auto version1 = create_version_message(magic, 11111); + mock_conn->simulate_receive(version1); + io_context.poll(); + CHECK(peer->peer_nonce() == 11111); + auto verack = create_verack_message(magic); + mock_conn->simulate_receive(verack); + io_context.poll(); + CHECK(peer->state() == PeerConnectionState::READY); + auto version2 = create_version_message(magic, 22222); + mock_conn->simulate_receive(version2); + io_context.poll(); + CHECK(peer->peer_nonce() == 11111); + CHECK(peer->state() == PeerConnectionState::READY); + } +} + +TEST_CASE("Adversarial - PingFloodBeforeHandshake", "[adversarial][flood]") { + asio::io_context io_context; + auto mock_conn = std::make_shared(); + const uint32_t magic = protocol::magic::REGTEST; + + auto peer = Peer::create_inbound(io_context, mock_conn, magic, 0); + peer->start(); + io_context.poll(); + + // Send VERSION to start handshake (but don't send VERACK to complete it) + auto version = create_version_message(magic, 12345); + mock_conn->simulate_receive(version); + io_context.poll(); + + REQUIRE(peer->state() == PeerConnectionState::VERSION_SENT); + + // Clear messages (VERSION, VERACK sent by peer) + mock_conn->clear_sent_messages(); + + // Flood with PING messages before handshake completes + for (int i = 0; i < 10; i++) { + auto ping = create_ping_message(magic, 1000 + i); + mock_conn->simulate_receive(ping); + io_context.poll(); + } + + // CRITICAL: Peer must IGNORE all PINGs (Bitcoin Core policy) + // - Stay connected (not disconnect) + // - Send no PONG responses + // - Remain in VERSION_SENT state (waiting for our VERACK) + CHECK(peer->state() == PeerConnectionState::VERSION_SENT); + CHECK(peer->is_connected()); + + // Verify no PONG messages were sent + auto sent_messages = mock_conn->get_sent_messages(); + for (const auto& msg : sent_messages) { + if (msg.size() >= 24) { + std::string command(msg.begin() + 4, msg.begin() + 16); + CHECK(command.find("pong") == std::string::npos); + } + } +} + +// ============================================================================= +// QUICK WIN TESTS +// ============================================================================= + +TEST_CASE("Adversarial - PongNonceMismatch", "[adversarial][protocol][quickwin]") { + asio::io_context io_context; + auto mock_conn = std::make_shared(); + const uint32_t magic = protocol::magic::REGTEST; + + auto peer = Peer::create_outbound(io_context, mock_conn, magic, 0); + peer->start(); + io_context.poll(); + + auto version = create_version_message(magic, 54321); + mock_conn->simulate_receive(version); + io_context.poll(); + + auto verack = create_verack_message(magic); + mock_conn->simulate_receive(verack); + io_context.poll(); + + REQUIRE(peer->state() == PeerConnectionState::READY); + mock_conn->clear_sent_messages(); + + uint64_t peer_ping_nonce = 777777; + auto ping_from_peer = create_ping_message(magic, peer_ping_nonce); + mock_conn->simulate_receive(ping_from_peer); + io_context.poll(); + CHECK(mock_conn->sent_message_count() == 1); + + auto wrong_pong = create_pong_message(magic, 999999); + mock_conn->simulate_receive(wrong_pong); + io_context.poll(); + CHECK(peer->is_connected()); +} + +TEST_CASE("Adversarial - DeserializationFailureFlooding", "[adversarial][malformed][quickwin]") { + asio::io_context io_context; + auto mock_conn = std::make_shared(); + const uint32_t magic = protocol::magic::REGTEST; + + auto peer = Peer::create_inbound(io_context, mock_conn, magic, 0); + peer->start(); + io_context.poll(); + + auto version = create_version_message(magic, 54321); + mock_conn->simulate_receive(version); + io_context.poll(); + + auto verack = create_verack_message(magic); + mock_conn->simulate_receive(verack); + io_context.poll(); + + REQUIRE(peer->state() == PeerConnectionState::READY); + + SECTION("PING with payload too short") { + std::vector short_payload = {0x01, 0x02, 0x03, 0x04}; + auto malformed_ping = create_test_message(magic, protocol::commands::PING, short_payload); + mock_conn->simulate_receive(malformed_ping); + io_context.poll(); + CHECK(peer->state() == PeerConnectionState::DISCONNECTED); + } + + SECTION("PING with payload too long") { + // SECURITY: PING must be exactly 8 bytes (Bitcoin Core pattern) + // Oversized PING messages are a DoS vector (e.g., 4 MB PING flooding) + std::vector long_payload(16, 0xAA); // 16 bytes, should be 8 + auto malformed_ping = create_test_message(magic, protocol::commands::PING, long_payload); + mock_conn->simulate_receive(malformed_ping); + io_context.poll(); + CHECK(peer->state() == PeerConnectionState::DISCONNECTED); + } + + SECTION("VERACK with unexpected payload") { + std::vector garbage_payload = {0xDE, 0xAD, 0xBE, 0xEF}; + auto malformed_verack = create_test_message(magic, protocol::commands::VERACK, garbage_payload); + mock_conn->simulate_receive(malformed_verack); + io_context.poll(); + CHECK(peer->state() == PeerConnectionState::DISCONNECTED); + } + + SECTION("PONG with wrong length") { + // SECURITY: PONG must be exactly 8 bytes (Bitcoin Core pattern) + // Oversized PONG messages are a DoS vector + std::vector long_pong(16, 0xBB); // 16 bytes, should be 8 + auto malformed_pong = create_test_message(magic, protocol::commands::PONG, long_pong); + mock_conn->simulate_receive(malformed_pong); + io_context.poll(); + CHECK(peer->state() == PeerConnectionState::DISCONNECTED); + } + + SECTION("PING with wrong length") { + // SECURITY: PING must be exactly 8 bytes (Bitcoin Core pattern) + // Oversized PING messages are a DoS vector + std::vector short_ping(4, 0xAA); // 4 bytes, should be 8 + auto malformed_ping = create_test_message(magic, protocol::commands::PING, short_ping); + mock_conn->simulate_receive(malformed_ping); + io_context.poll(); + CHECK(peer->state() == PeerConnectionState::DISCONNECTED); + } + + SECTION("GETADDR with wrong length") { + // SECURITY: GETADDR must be exactly 0 bytes (empty payload) + // Prevents abuse of GETADDR flood with extra payload + std::vector payload_getaddr(10, 0xCC); // 10 bytes, should be 0 + auto malformed_getaddr = create_test_message(magic, protocol::commands::GETADDR, payload_getaddr); + mock_conn->simulate_receive(malformed_getaddr); + io_context.poll(); + CHECK(peer->state() == PeerConnectionState::DISCONNECTED); + } +} + +// TEST REMOVED: ReceiveBufferCycling previously tested buffer management with 100 KB PING messages +// This test is no longer valid after adding per-message-type size limits (PING must be exactly 8 bytes) +// Buffer cycling is adequately tested by other tests with properly-sized messages + +TEST_CASE("Adversarial - MessageSizeLimits", "[adversarial][malformed][dos]") { + asio::io_context io_context; + auto mock_conn = std::make_shared(); + const uint32_t magic = protocol::magic::REGTEST; + + auto peer = Peer::create_inbound(io_context, mock_conn, magic, 0); + peer->start(); + io_context.poll(); + + auto version = create_version_message(magic, 54321); + mock_conn->simulate_receive(version); + io_context.poll(); + + auto verack = create_verack_message(magic); + mock_conn->simulate_receive(verack); + io_context.poll(); + + REQUIRE(peer->state() == PeerConnectionState::READY); + + SECTION("ADDR oversized (>1000 addresses)") { + // SECURITY: ADDR messages limited to MAX_ADDR_SIZE (1000) addresses + // Prevents memory exhaustion attacks + message::MessageSerializer s; + s.write_varint(1001); // One more than MAX_ADDR_SIZE + // Write 1001 dummy addresses (each 30 bytes without timestamp, 34 with) + for (int i = 0; i < 1001; i++) { + s.write_uint32(1234567890); // timestamp + s.write_uint64(protocol::NODE_NETWORK); // services + std::array ipv6{}; + ipv6[10] = 0xFF; ipv6[11] = 0xFF; // IPv4-mapped prefix + ipv6[12] = 127; ipv6[15] = 1; // 127.0.0.1 + s.write_bytes(ipv6.data(), 16); + s.write_uint16(9590); // port (network byte order) + } + auto oversized_addr = create_test_message(magic, protocol::commands::ADDR, s.data()); + mock_conn->simulate_receive(oversized_addr); + io_context.poll(); + CHECK(peer->state() == PeerConnectionState::DISCONNECTED); + } + + SECTION("GETHEADERS oversized locator (>101 hashes)") { + // SECURITY: GETHEADERS locator limited to MAX_LOCATOR_SZ (101) hashes + // Prevents CPU exhaustion from expensive FindFork() operations + message::MessageSerializer s; + s.write_uint32(protocol::PROTOCOL_VERSION); + s.write_varint(102); // One more than MAX_LOCATOR_SZ + // Write 102 dummy block hashes (each 32 bytes) + for (int i = 0; i < 102; i++) { + std::array hash{}; + hash[0] = static_cast(i & 0xFF); + s.write_bytes(hash.data(), 32); + } + // Write hash_stop (32 bytes of zeros) + std::array hash_stop{}; + s.write_bytes(hash_stop.data(), 32); + auto oversized_getheaders = create_test_message(magic, protocol::commands::GETHEADERS, s.data()); + mock_conn->simulate_receive(oversized_getheaders); + io_context.poll(); + CHECK(peer->state() == PeerConnectionState::DISCONNECTED); + } + + SECTION("HEADERS oversized (>MAX_HEADERS_SIZE)") { + // SECURITY: HEADERS messages limited to MAX_HEADERS_SIZE (80000) headers + // Prevents memory exhaustion attacks + // Note: We claim to send MAX_HEADERS_SIZE+1 headers but only write a few + // The rejection happens based on the count, not the actual data + message::MessageSerializer s; + s.write_varint(protocol::MAX_HEADERS_SIZE + 1); // One more than MAX_HEADERS_SIZE + // Write just a few dummy headers (112 bytes each for Unicity) + for (int i = 0; i < 10; i++) { + // CBlockHeader: 112 bytes (version, prev_hash, miner_addr, timestamp, bits, nonce, randomx_hash) + s.write_uint32(1); // version (4) + std::array prev_hash{}; + s.write_bytes(prev_hash.data(), 32); // hashPrevBlock (32) + std::array miner_addr{}; + s.write_bytes(miner_addr.data(), 32); // payloadRoot (32) + s.write_uint32(1234567890); // timestamp (4) + s.write_uint32(0x1d00ffff); // bits (4) + s.write_uint32(i); // nonce (4) + std::array randomx_hash{}; + s.write_bytes(randomx_hash.data(), 32); // hashRandomX (32) + } + auto oversized_headers = create_test_message(magic, protocol::commands::HEADERS, s.data()); + mock_conn->simulate_receive(oversized_headers); + io_context.poll(); + CHECK(peer->state() == PeerConnectionState::DISCONNECTED); + } +} + +TEST_CASE("Adversarial - ProtocolStateMachine", "[adversarial][protocol][state]") { + asio::io_context io_context; + auto mock_conn = std::make_shared(); + const uint32_t magic = protocol::magic::REGTEST; + + SECTION("Multiple VERSION messages - ignored per Bitcoin Core") { + // Bitcoin Core: Ignores duplicate VERSION (checks if pfrom.nVersion != 0) + // Prevents: time manipulation via multiple AddTimeData() calls + // Pattern: Log and return, don't disconnect + + auto peer = Peer::create_inbound(io_context, mock_conn, magic, 0); + peer->start(); + io_context.poll(); + + // First VERSION: should be accepted + auto version1 = create_version_message(magic, 54321); + mock_conn->simulate_receive(version1); + io_context.poll(); + + // Send VERACK to complete handshake + auto verack = create_verack_message(magic); + mock_conn->simulate_receive(verack); + io_context.poll(); + + REQUIRE(peer->state() == PeerConnectionState::READY); + + // Capture message count before second VERSION + size_t count_before = mock_conn->sent_message_count(); + + // Second VERSION: ignored (Bitcoin Core pattern) + auto version2 = create_version_message(magic, 99999); + mock_conn->simulate_receive(version2); + io_context.poll(); + + // Bitcoin Core: Stays connected, ignores duplicate, sends no response + CHECK(peer->state() == PeerConnectionState::READY); + CHECK(mock_conn->sent_message_count() == count_before); // No egress! + } + + SECTION("Multiple VERACK messages - ignored per Bitcoin Core") { + // Bitcoin Core: Ignores duplicate VERACK (checks if pfrom.fSuccessfullyConnected) + // Prevents: timer churn from repeated schedule_ping() calls + // Pattern: Log warning and return, don't disconnect + + auto peer = Peer::create_inbound(io_context, mock_conn, magic, 0); + peer->start(); + io_context.poll(); + + auto version = create_version_message(magic, 54321); + mock_conn->simulate_receive(version); + io_context.poll(); + + auto verack1 = create_verack_message(magic); + mock_conn->simulate_receive(verack1); + io_context.poll(); + + REQUIRE(peer->state() == PeerConnectionState::READY); + + // Capture message count before second VERACK + size_t count_before = mock_conn->sent_message_count(); + + // Second VERACK: ignored (Bitcoin Core pattern) + auto verack2 = create_verack_message(magic); + mock_conn->simulate_receive(verack2); + io_context.poll(); + + // Bitcoin Core: Stays connected, ignores duplicate, sends no response + CHECK(peer->state() == PeerConnectionState::READY); + CHECK(mock_conn->sent_message_count() == count_before); // No egress! + } + + SECTION("VERSION after READY state - ignored per Bitcoin Core") { + // Bitcoin Core: Duplicate VERSION is ignored (same as multiple VERSION test) + // Even after READY, peer_version_ != 0 so duplicate is ignored + + auto peer = Peer::create_inbound(io_context, mock_conn, magic, 0); + peer->start(); + io_context.poll(); + + auto version = create_version_message(magic, 54321); + mock_conn->simulate_receive(version); + io_context.poll(); + + auto verack = create_verack_message(magic); + mock_conn->simulate_receive(verack); + io_context.poll(); + + REQUIRE(peer->state() == PeerConnectionState::READY); + + // Capture message count before VERSION after READY + size_t count_before = mock_conn->sent_message_count(); + + // Send VERSION after READY - ignored (same logic as duplicate VERSION) + auto version2 = create_version_message(magic, 99999); + mock_conn->simulate_receive(version2); + io_context.poll(); + + // Bitcoin Core: Stays connected, ignores duplicate + CHECK(peer->state() == PeerConnectionState::READY); + CHECK(mock_conn->sent_message_count() == count_before); // No egress! + } + + SECTION("GETHEADERS before handshake complete - ignored") { + // SECURITY: Non-handshake messages ignored before successfully_connected_ + // Prevents: resource exhaustion from unauthenticated peers + // Pattern: peer.cpp:770 checks successfully_connected_, logs and returns + + auto peer = Peer::create_inbound(io_context, mock_conn, magic, 0); + peer->start(); + io_context.poll(); + + auto version = create_version_message(magic, 54321); + mock_conn->simulate_receive(version); + io_context.poll(); + + // Peer has received VERSION but not yet VERACK - not in READY state + // Capture message count before sending GETHEADERS + size_t count_before = mock_conn->sent_message_count(); + + // Send GETHEADERS before VERACK - ignored + message::MessageSerializer s; + s.write_uint32(protocol::PROTOCOL_VERSION); + s.write_varint(1); // 1 locator hash + std::array hash{}; + s.write_bytes(hash.data(), 32); + std::array hash_stop{}; + s.write_bytes(hash_stop.data(), 32); + auto getheaders = create_test_message(magic, protocol::commands::GETHEADERS, s.data()); + mock_conn->simulate_receive(getheaders); + io_context.poll(); + + // Stays connected (still in VERSION_SENT state), ignores message, sends no response + CHECK(peer->state() == PeerConnectionState::VERSION_SENT); // Still in handshake + CHECK(mock_conn->sent_message_count() == count_before); // No egress! + } + + SECTION("HEADERS before handshake complete - ignored") { + // SECURITY: Non-handshake messages ignored before successfully_connected_ + // Prevents: DoS attacks from unauthenticated peers sending expensive HEADERS + // Pattern: peer.cpp:770 checks successfully_connected_, logs and returns + + auto peer = Peer::create_inbound(io_context, mock_conn, magic, 0); + peer->start(); + io_context.poll(); + + auto version = create_version_message(magic, 54321); + mock_conn->simulate_receive(version); + io_context.poll(); + + // Peer has received VERSION but not yet VERACK - not in READY state + // Capture message count before sending HEADERS + size_t count_before = mock_conn->sent_message_count(); + + // Send HEADERS before VERACK - ignored + message::MessageSerializer s; + s.write_varint(0); // 0 headers (empty, but still ignored before READY) + auto headers = create_test_message(magic, protocol::commands::HEADERS, s.data()); + mock_conn->simulate_receive(headers); + io_context.poll(); + + // Stays connected (still in VERSION_SENT state), ignores message, sends no response + CHECK(peer->state() == PeerConnectionState::VERSION_SENT); // Still in handshake + CHECK(mock_conn->sent_message_count() == count_before); // No egress! + } +} + +TEST_CASE("Adversarial - UnknownMessageFlooding", "[adversarial][flood][quickwin]") { + // Bitcoin Core parity: unknown commands are silently ignored + // This provides forward compatibility for protocol upgrades + asio::io_context io_context; + auto mock_conn = std::make_shared(); + const uint32_t magic = protocol::magic::REGTEST; + + auto peer = Peer::create_inbound(io_context, mock_conn, magic, 0); + peer->start(); + io_context.poll(); + + auto version = create_version_message(magic, 54321); + mock_conn->simulate_receive(version); + io_context.poll(); + + auto verack = create_verack_message(magic); + mock_conn->simulate_receive(verack); + io_context.poll(); + + REQUIRE(peer->state() == PeerConnectionState::READY); + + std::vector fake_commands = { + "FAKECMD1", "FAKECMD2", "XYZABC", "UNKNOWN", + "BOGUS", "INVALID", "NOTREAL", "JUNK", + "GARBAGE", "RANDOM" + }; + + // Send many unknown commands - all should be silently ignored + const int messages_to_send = 100; + for (int i = 0; i < messages_to_send; i++) { + std::string fake_cmd = fake_commands[i % fake_commands.size()]; + std::vector dummy_payload = {0x01, 0x02, 0x03, 0x04}; + auto unknown_msg = create_test_message(magic, fake_cmd, dummy_payload); + mock_conn->simulate_receive(unknown_msg); + io_context.poll(); + } + + // Peer should still be connected - unknown commands are ignored + CHECK(peer->is_connected()); + CHECK(peer->state() == PeerConnectionState::READY); +} + +TEST_CASE("Adversarial - StatisticsOverflow", "[adversarial][resource][quickwin]") { + asio::io_context io_context; + auto mock_conn = std::make_shared(); + const uint32_t magic = protocol::magic::REGTEST; + + auto peer = Peer::create_outbound(io_context, mock_conn, magic, 0); + peer->start(); + io_context.poll(); + + auto version = create_version_message(magic, 54321); + mock_conn->simulate_receive(version); + io_context.poll(); + + auto verack = create_verack_message(magic); + mock_conn->simulate_receive(verack); + io_context.poll(); + + REQUIRE(peer->state() == PeerConnectionState::READY); + + // Snapshot stats before injecting PINGs + auto& stats = peer->stats(); + uint64_t msg_before = stats.messages_received.load(); + uint64_t bytes_before = stats.bytes_received.load(); + const int ping_count = 1000; + + for (int i = 0; i < ping_count; i++) { + auto ping = create_ping_message(magic, 5000 + i); + mock_conn->simulate_receive(ping); + io_context.poll(); + } + + // Verify exact stat increments + uint64_t msg_after = stats.messages_received.load(); + uint64_t bytes_after = stats.bytes_received.load(); + CHECK(msg_after == msg_before + ping_count); + CHECK(bytes_after > bytes_before); + CHECK(peer->is_connected()); +} + +TEST_CASE("Adversarial - MessageHandlerBlocking", "[adversarial][threading][p2]") { + // Tests that message handlers can perform work without crashing the peer + // Removed sleep_for() per TESTING.md guidelines (deterministic tests only) + asio::io_context io_context; + auto mock_conn = std::make_shared(); + const uint32_t magic = protocol::magic::REGTEST; + + auto peer = Peer::create_outbound(io_context, mock_conn, magic, 0); + + int handler_call_count = 0; + const auto msgs_before = peer->stats().messages_received.load(); + + peer->set_message_handler([&](PeerPtr p, std::unique_ptr msg) { + handler_call_count++; + // Simulate some work (without sleep - tests should be fast) + // In real usage, handlers might do validation, chainstate queries, etc. + int work = 0; + for (int i = 0; i < 1000; ++i) { + work = work + i; // Avoid volatile compound assignment (deprecated in C++20) + } + // Prevent optimization of the loop + if (work < 0) handler_call_count = 0; // Never true, but compiler can't prove it + }); + + peer->start(); + io_context.poll(); + + auto version = create_version_message(magic, 54321); + mock_conn->simulate_receive(version); + io_context.poll(); + + auto verack = create_verack_message(magic); + mock_conn->simulate_receive(verack); + io_context.poll(); + + REQUIRE(peer->state() == PeerConnectionState::READY); + REQUIRE(handler_call_count > 0); // Handler was called + + const auto msgs_after = peer->stats().messages_received.load(); + CHECK(msgs_after > msgs_before); // Messages were processed + CHECK(peer->is_connected()); // Peer still connected after handler execution +} + +TEST_CASE("Adversarial - ConcurrentDisconnectDuringProcessing", "[adversarial][race][p2]") { + // Tests that disconnect() can be safely called at any time during message processing + // This verifies the shared_ptr-based lifecycle management in peer.cpp:164-168 + // Removed sleep_for() per TESTING.md guidelines (deterministic tests only) + asio::io_context io_context; + auto mock_conn = std::make_shared(); + const uint32_t magic = protocol::magic::REGTEST; + + auto peer = Peer::create_outbound(io_context, mock_conn, magic, 0); + + bool handler_executed = false; + PeerConnectionState state_during_handler = PeerConnectionState::DISCONNECTED; + + peer->set_message_handler([&](PeerPtr p, std::unique_ptr msg) { + handler_executed = true; + // Capture state during handler execution + state_during_handler = p->state(); + // Handler completes successfully even if disconnect is imminent + }); + + peer->start(); + io_context.poll(); + + auto version = create_version_message(magic, 54321); + mock_conn->simulate_receive(version); + io_context.poll(); + + auto verack = create_verack_message(magic); + mock_conn->simulate_receive(verack); + io_context.poll(); + + REQUIRE(peer->state() == PeerConnectionState::READY); + REQUIRE(handler_executed); // Handler ran during handshake + + // The adversarial test: call disconnect() after message processing has occurred + // This verifies shared_ptr lifecycle management prevents use-after-free + // (The handler was called above during VERSION/VERACK, proving message processing works) + + peer->disconnect(); + io_context.poll(); + + // Peer should be cleanly disconnected + CHECK(peer->state() == PeerConnectionState::DISCONNECTED); + + // Success: No crashes or use-after-free during or after disconnect + // The test passes if we reach here without crashing +} + +TEST_CASE("Adversarial - SelfConnectionEdgeCases", "[adversarial][protocol][p2]") { + asio::io_context io_context; + const uint32_t magic = protocol::magic::REGTEST; + + SECTION("Inbound self-connection with matching nonce") { + auto mock_conn = std::make_shared(); + auto peer = Peer::create_inbound(io_context, mock_conn, magic, 0); + peer->start(); + io_context.poll(); + auto version = create_version_message(magic, peer->get_local_nonce()); + mock_conn->simulate_receive(version); + io_context.poll(); + CHECK(peer->state() == PeerConnectionState::DISCONNECTED); + } + + SECTION("Outbound self-connection detection") { + // Both inbound AND outbound should detect self-connection (Bitcoin Core pattern) + auto mock_conn = std::make_shared(); + auto peer = Peer::create_outbound(io_context, mock_conn, magic, 0); + peer->start(); + io_context.poll(); + auto version = create_version_message(magic, peer->get_local_nonce()); + mock_conn->simulate_receive(version); + io_context.poll(); + CHECK(peer->state() == PeerConnectionState::DISCONNECTED); + } +} + +TEST_CASE("Adversarial - MaxMessageSizeEdgeCases", "[adversarial][edge][p2]") { + asio::io_context io_context; + auto mock_conn = std::make_shared(); + const uint32_t magic = protocol::magic::REGTEST; + + auto peer = Peer::create_inbound(io_context, mock_conn, magic, 0); + peer->start(); + io_context.poll(); + + auto version = create_version_message(magic, 54321); + mock_conn->simulate_receive(version); + io_context.poll(); + + auto verack = create_verack_message(magic); + mock_conn->simulate_receive(verack); + io_context.poll(); + + REQUIRE(peer->state() == PeerConnectionState::READY); + + SECTION("Exactly MAX_PROTOCOL_MESSAGE_LENGTH PING rejected") { + // Even though this is within global MAX_PROTOCOL_MESSAGE_LENGTH (4 MB), + // PING has per-message-type limit of exactly 8 bytes + std::vector max_payload(protocol::MAX_PROTOCOL_MESSAGE_LENGTH, 0xAA); + auto max_msg = create_test_message(magic, protocol::commands::PING, max_payload); + mock_conn->simulate_receive(max_msg); + io_context.poll(); + CHECK(peer->state() == PeerConnectionState::DISCONNECTED); + } + + SECTION("Exactly MAX_PROTOCOL_MESSAGE_LENGTH + 1") { + std::vector payload(protocol::MAX_PROTOCOL_MESSAGE_LENGTH + 1, 0xBB); + protocol::MessageHeader header(magic, protocol::commands::PING, + protocol::MAX_PROTOCOL_MESSAGE_LENGTH + 1); + header.checksum = [&](const auto& data) { uint256 hash = Hash(data); std::array checksum; std::memcpy(checksum.data(), hash.begin(), 4); return checksum; }(payload); + auto header_bytes = message::serialize_header(header); + mock_conn->simulate_receive(header_bytes); + io_context.poll(); + CHECK(peer->state() == PeerConnectionState::DISCONNECTED); + } + + SECTION("Oversized PING rejected (DoS protection)") { + // SECURITY: PING must be exactly 8 bytes (Bitcoin Core pattern) + // Accepting 3 MB PING messages is a DoS footgun (bandwidth/memory exhaustion) + // This test validates per-message-type size limits, not just global MAX limit + std::vector large_payload(3 * 1024 * 1024, 0xEE); // 3 MB PING! + auto large_msg = create_test_message(magic, protocol::commands::PING, large_payload); + mock_conn->simulate_receive(large_msg); + io_context.poll(); + CHECK(peer->state() == PeerConnectionState::DISCONNECTED); // Must disconnect! + } +} + +TEST_CASE("Adversarial - MessageRateLimiting", "[adversarial][flood][p3]") { + asio::io_context io_context; + auto mock_conn = std::make_shared(); + const uint32_t magic = protocol::magic::REGTEST; + + auto peer = Peer::create_inbound(io_context, mock_conn, magic, 0); + peer->start(); + io_context.poll(); + + auto version = create_version_message(magic, 54321); + mock_conn->simulate_receive(version); + io_context.poll(); + + auto verack = create_verack_message(magic); + mock_conn->simulate_receive(verack); + io_context.poll(); + + REQUIRE(peer->state() == PeerConnectionState::READY); + + // Snapshot stats before flood + auto& stats = peer->stats(); + uint64_t msg_before = stats.messages_received.load(); + const int ping_count = 1000; + int sent = 0; + + for (int i = 0; i < ping_count; i++) { + auto ping = create_ping_message(magic, 8000 + i); + mock_conn->simulate_receive(ping); + io_context.poll(); + sent++; + if (!peer->is_connected()) { break; } + } + + // Verify exact stat increments + uint64_t msg_after = stats.messages_received.load(); + CHECK(peer->is_connected()); + CHECK(msg_after == msg_before + sent); +} + +TEST_CASE("Adversarial - TransportCallbackOrdering", "[adversarial][race][p3]") { + asio::io_context io_context; + auto mock_conn = std::make_shared(); + const uint32_t magic = protocol::magic::REGTEST; + + auto peer = Peer::create_inbound(io_context, mock_conn, magic, 0); + peer->start(); + io_context.poll(); + + SECTION("Receive callback after disconnect") { + peer->disconnect(); + io_context.poll(); // Process the disconnect operation + CHECK(peer->state() == PeerConnectionState::DISCONNECTED); + auto version = create_version_message(magic, 54321); + mock_conn->simulate_receive(version); + io_context.poll(); + // SECURITY: After disconnect(), callbacks are cleared to prevent use-after-free + // Messages received after disconnect() should NOT be processed + CHECK(peer->state() == PeerConnectionState::DISCONNECTED); + CHECK(peer->version() == 0); // VERSION not processed (callback cleared) + } + + SECTION("Disconnect callback fires twice") { + auto version = create_version_message(magic, 54321); + mock_conn->simulate_receive(version); + io_context.poll(); + auto verack = create_verack_message(magic); + mock_conn->simulate_receive(verack); + io_context.poll(); + REQUIRE(peer->state() == PeerConnectionState::READY); + peer->disconnect(); + io_context.poll(); // Process first disconnect + CHECK(peer->state() == PeerConnectionState::DISCONNECTED); + peer->disconnect(); + io_context.poll(); // Process second disconnect (should be no-op) + CHECK(peer->state() == PeerConnectionState::DISCONNECTED); + } +} + +TEST_CASE("Adversarial - CommandFieldPadding", "[adversarial][malformed][p3]") { + asio::io_context io_context; + auto mock_conn = std::make_shared(); + const uint32_t magic = protocol::magic::REGTEST; + + auto peer = Peer::create_inbound(io_context, mock_conn, magic, 0); + peer->start(); + io_context.poll(); + + SECTION("VERSION with null padding") { + protocol::MessageHeader header; + header.magic = magic; + header.command.fill(0); + std::string cmd = "version"; + std::copy(cmd.begin(), cmd.end(), header.command.begin()); + message::VersionMessage msg; + msg.version = protocol::PROTOCOL_VERSION; + msg.services = protocol::NODE_NETWORK; + msg.timestamp = 1234567890; + msg.nonce = 54321; + msg.user_agent = "/Test:1.0.0/"; + msg.start_height = 0; + auto payload = msg.serialize(); + header.length = payload.size(); + header.checksum = [&](const auto& data) { uint256 hash = Hash(data); std::array checksum; std::memcpy(checksum.data(), hash.begin(), 4); return checksum; }(payload); + auto header_bytes = message::serialize_header(header); + std::vector full_message; + full_message.insert(full_message.end(), header_bytes.begin(), header_bytes.end()); + full_message.insert(full_message.end(), payload.begin(), payload.end()); + mock_conn->simulate_receive(full_message); + io_context.poll(); + CHECK(peer->version() == protocol::PROTOCOL_VERSION); + CHECK(peer->is_connected()); + } + + SECTION("Command with trailing spaces") { + protocol::MessageHeader header; + header.magic = magic; + header.command.fill(' '); + std::string cmd = "version"; + std::copy(cmd.begin(), cmd.end(), header.command.begin()); + message::VersionMessage msg; + msg.version = protocol::PROTOCOL_VERSION; + msg.services = protocol::NODE_NETWORK; + msg.timestamp = 1234567890; + msg.nonce = 54321; + msg.user_agent = "/Test:1.0.0/"; + msg.start_height = 0; + auto payload = msg.serialize(); + header.length = payload.size(); + header.checksum = [&](const auto& data) { uint256 hash = Hash(data); std::array checksum; std::memcpy(checksum.data(), hash.begin(), 4); return checksum; }(payload); + auto header_bytes = message::serialize_header(header); + std::vector full_message; + full_message.insert(full_message.end(), header_bytes.begin(), header_bytes.end()); + full_message.insert(full_message.end(), payload.begin(), payload.end()); + mock_conn->simulate_receive(full_message); + io_context.poll(); + bool connected = peer->is_connected(); + bool version_set = (peer->version() == protocol::PROTOCOL_VERSION); + CHECK((connected == version_set)); + } +} +// ============================================================================= +// HANDSHAKE SECURITY TESTS - Phase 1: Critical Security +// ============================================================================= +// Tests for handshake state machine enforcement - prevents information +// disclosure and DoS attacks from unauthenticated peers. +// +// These tests validate the fix in src/network/peer.cpp:733-765 which adds +// successfully_connected_ checks before processing non-handshake messages. +// ============================================================================= + +// Helper functions for creating protocol messages +static std::vector create_getaddr_message(uint32_t magic) { + // GETADDR has no payload + std::vector empty_payload; + return create_test_message(magic, protocol::commands::GETADDR, empty_payload); +} + +static std::vector create_getheaders_message(uint32_t magic) { + // Minimal GETHEADERS: version (4 bytes) + hash_count (1 byte = 0) + hash_stop (32 bytes = 0) + message::GetHeadersMessage msg; + msg.version = protocol::PROTOCOL_VERSION; + // Empty locator hashes + msg.hash_stop.SetNull(); + auto payload = msg.serialize(); + return create_test_message(magic, protocol::commands::GETHEADERS, payload); +} + +static std::vector create_addr_message(uint32_t magic) { + // ADDR with one address + message::AddrMessage msg; + protocol::TimestampedAddress addr; + addr.timestamp = 1234567890; + addr.address.services = protocol::NODE_NETWORK; + addr.address.ip.fill(0); + addr.address.ip[10] = 0xff; + addr.address.ip[11] = 0xff; + addr.address.ip[12] = 127; + addr.address.ip[13] = 0; + addr.address.ip[14] = 0; + addr.address.ip[15] = 1; + addr.address.port = 9590; + msg.addresses.push_back(addr); + auto payload = msg.serialize(); + return create_test_message(magic, protocol::commands::ADDR, payload); +} + +static std::vector create_headers_message(uint32_t magic) { + // HEADERS with one header + message::HeadersMessage msg; + CBlockHeader header; + header.nVersion = 1; + header.hashPrevBlock.SetNull(); + header.payloadRoot.SetNull(); + header.nTime = 1234567890; + header.nBits = 0x1d00ffff; + header.nNonce = 0; + header.hashRandomX.SetNull(); + msg.headers.push_back(header); + auto payload = msg.serialize(); + return create_test_message(magic, protocol::commands::HEADERS, payload); +} + +// Helper to create a message with specific header length field +static std::vector create_message_with_length(uint32_t magic, const std::string& command, uint32_t length_field) { + protocol::MessageHeader header; + header.magic = magic; + header.set_command(command); + header.length = length_field; + header.checksum.fill(0); // Invalid checksum, but we're testing length handling + + auto header_bytes = message::serialize_header(header); + return header_bytes; // Return just the header, no payload +} + +// ============================================================================= +// TEST 1.1: PING before VERACK +// ============================================================================= +TEST_CASE("Handshake Security - PING before VERACK", "[adversarial][handshake][security]") { + asio::io_context io_context; + auto mock_conn = std::make_shared(); + const uint32_t magic = protocol::magic::REGTEST; + + auto peer = Peer::create_inbound(io_context, mock_conn, magic, 0); + peer->start(); + io_context.poll(); + + // Send VERSION + auto version = create_version_message(magic, 12345); + mock_conn->simulate_receive(version); + io_context.poll(); + + // Peer should be in CONNECTED state but not READY + REQUIRE(peer->state() == PeerConnectionState::VERSION_SENT); + + // Clear any outgoing messages (VERSION, VERACK) + mock_conn->clear_sent_messages(); + + // Send PING before completing handshake + auto ping = create_ping_message(magic, 0xDEADBEEF); + mock_conn->simulate_receive(ping); + io_context.poll(); + + // CRITICAL: Peer must NOT respond with PONG + // Verify NO egress traffic (security: no information disclosure) + CHECK(mock_conn->sent_message_count() == 0); + + // Double-check: scan for PONG specifically + auto sent_messages = mock_conn->get_sent_messages(); + bool pong_sent = false; + for (const auto& msg : sent_messages) { + if (msg.size() >= 24) { + std::string command(msg.begin() + 4, msg.begin() + 16); + if (command.find("pong") != std::string::npos) { + pong_sent = true; + break; + } + } + } + CHECK(!pong_sent); + + // Verify peer stays connected and in correct state + CHECK(peer->is_connected()); + CHECK(peer->state() == PeerConnectionState::VERSION_SENT); +} + +// ============================================================================= +// TEST 1.3: GETADDR before VERACK +// ============================================================================= +TEST_CASE("Handshake Security - GETADDR before VERACK", "[adversarial][handshake][security]") { + asio::io_context io_context; + auto mock_conn = std::make_shared(); + const uint32_t magic = protocol::magic::REGTEST; + + auto peer = Peer::create_inbound(io_context, mock_conn, magic, 0); + peer->start(); + io_context.poll(); + + // Send VERSION + auto version = create_version_message(magic, 12345); + mock_conn->simulate_receive(version); + io_context.poll(); + + REQUIRE(peer->state() == PeerConnectionState::VERSION_SENT); + mock_conn->clear_sent_messages(); + + // Attack: Send GETADDR before VERACK to enumerate addresses + auto getaddr = create_getaddr_message(magic); + mock_conn->simulate_receive(getaddr); + io_context.poll(); + + // CRITICAL: Peer must NOT respond with ADDR + // Verify NO egress traffic (security: no network topology disclosure) + CHECK(mock_conn->sent_message_count() == 0); + + // Double-check: scan for ADDR specifically + auto sent_messages = mock_conn->get_sent_messages(); + bool addr_sent = false; + for (const auto& msg : sent_messages) { + if (msg.size() >= 24) { + std::string command(msg.begin() + 4, msg.begin() + 16); + if (command.find("addr") != std::string::npos) { + addr_sent = true; + break; + } + } + } + CHECK(!addr_sent); + + // Verify peer stays connected and in correct state + CHECK(peer->is_connected()); + CHECK(peer->state() == PeerConnectionState::VERSION_SENT); +} + +// ============================================================================= +// TEST 1.4: GETHEADERS before VERACK +// ============================================================================= +TEST_CASE("Handshake Security - GETHEADERS before VERACK", "[adversarial][handshake][security]") { + asio::io_context io_context; + auto mock_conn = std::make_shared(); + const uint32_t magic = protocol::magic::REGTEST; + + auto peer = Peer::create_inbound(io_context, mock_conn, magic, 0); + peer->start(); + io_context.poll(); + + auto version = create_version_message(magic, 12345); + mock_conn->simulate_receive(version); + io_context.poll(); + + REQUIRE(peer->state() == PeerConnectionState::VERSION_SENT); + mock_conn->clear_sent_messages(); + + // Attack: Send GETHEADERS before VERACK to fingerprint chain state + auto getheaders = create_getheaders_message(magic); + mock_conn->simulate_receive(getheaders); + io_context.poll(); + + // CRITICAL: Peer must NOT respond with HEADERS + // Verify NO egress traffic (security: no chain state disclosure) + CHECK(mock_conn->sent_message_count() == 0); + + // Double-check: scan for HEADERS specifically + auto sent_messages = mock_conn->get_sent_messages(); + bool headers_sent = false; + for (const auto& msg : sent_messages) { + if (msg.size() >= 24) { + std::string command(msg.begin() + 4, msg.begin() + 16); + if (command.find("headers") != std::string::npos) { + headers_sent = true; + break; + } + } + } + + CHECK(!headers_sent); // HEADERS must NOT be sent (chain fingerprinting protection) + + // Verify peer stays connected and in correct state + CHECK(peer->is_connected()); + CHECK(peer->state() == PeerConnectionState::VERSION_SENT); +} + +// ============================================================================= +// TEST 1.6: ADDR before VERACK +// ============================================================================= +TEST_CASE("Handshake Security - ADDR before VERACK", "[adversarial][handshake][security]") { + asio::io_context io_context; + auto mock_conn = std::make_shared(); + const uint32_t magic = protocol::magic::REGTEST; + + auto peer = Peer::create_inbound(io_context, mock_conn, magic, 0); + peer->start(); + io_context.poll(); + + auto version = create_version_message(magic, 12345); + mock_conn->simulate_receive(version); + io_context.poll(); + + REQUIRE(peer->state() == PeerConnectionState::VERSION_SENT); + mock_conn->clear_sent_messages(); + + // Attack: Send malicious ADDR before VERACK to poison address database + auto addr = create_addr_message(magic); + mock_conn->simulate_receive(addr); + io_context.poll(); + + // CRITICAL: Peer should ignore ADDR (address table must not be polluted) + // Verify NO egress traffic (security: no response to pre-handshake ADDR) + CHECK(mock_conn->sent_message_count() == 0); + + // This test validates that ADDR processing is deferred until handshake completes + // Note: We can't directly inspect the address manager, but the message should be ignored + CHECK(peer->is_connected()); + CHECK(peer->state() == PeerConnectionState::VERSION_SENT); + + // Now complete handshake and verify peer reaches READY state + auto verack = create_verack_message(magic); + mock_conn->simulate_receive(verack); + io_context.poll(); + + CHECK(peer->state() == PeerConnectionState::READY); +} + +// ============================================================================= +// TEST 6.5: Header length field overflow +// ============================================================================= +TEST_CASE("Handshake Security - Header length overflow", "[adversarial][handshake][security]") { + asio::io_context io_context; + auto mock_conn = std::make_shared(); + const uint32_t magic = protocol::magic::REGTEST; + + auto peer = Peer::create_inbound(io_context, mock_conn, magic, 0); + peer->start(); + io_context.poll(); + + // Attack: Send VERSION header with length = 0xFFFFFFFF (4GB) + // This could cause integer overflow or massive memory allocation + auto malicious_header = create_message_with_length(magic, protocol::commands::VERSION, 0xFFFFFFFF); + mock_conn->simulate_receive(malicious_header); + io_context.poll(); + + // Peer should disconnect or reject the message (not crash!) + // The exact behavior depends on implementation, but it must not allocate 4GB + // The peer should either be disconnected or still waiting for valid VERSION + bool safe_state = (peer->state() == PeerConnectionState::DISCONNECTED || + peer->state() == PeerConnectionState::CONNECTING || + peer->version() == 0); + + CHECK(safe_state); // Must not process 4GB message +} + +// ============================================================================= +// TEST 6.4: Oversized VERSION +// ============================================================================= +TEST_CASE("Handshake Security - Oversized VERSION", "[adversarial][handshake][security]") { + asio::io_context io_context; + auto mock_conn = std::make_shared(); + const uint32_t magic = protocol::magic::REGTEST; + + auto peer = Peer::create_inbound(io_context, mock_conn, magic, 0); + peer->start(); + io_context.poll(); + + // Attack: Send VERSION with length = MAX_PROTOCOL_MESSAGE_LENGTH + // Peer should reject this before allocating memory + auto malicious_header = create_message_with_length(magic, protocol::commands::VERSION, + protocol::MAX_PROTOCOL_MESSAGE_LENGTH); + mock_conn->simulate_receive(malicious_header); + io_context.poll(); + + // Peer should disconnect (not allocate 4MB for VERSION) + bool safe_state = (peer->state() == PeerConnectionState::DISCONNECTED || + peer->version() == 0); + + CHECK(safe_state); // Must not allocate maximum size for VERSION +} + +// ============================================================================= +// TEST 6.6: VERSION with bad checksum +// ============================================================================= +TEST_CASE("Handshake Security - VERSION with bad checksum", "[adversarial][handshake][security]") { + asio::io_context io_context; + auto mock_conn = std::make_shared(); + const uint32_t magic = protocol::magic::REGTEST; + + auto peer = Peer::create_inbound(io_context, mock_conn, magic, 0); + peer->start(); + io_context.poll(); + + // Create a valid VERSION message + message::VersionMessage msg; + msg.version = protocol::PROTOCOL_VERSION; + msg.services = protocol::NODE_NETWORK; + msg.timestamp = 1234567890; + msg.nonce = 12345; + msg.user_agent = "/Test:1.0.0/"; + msg.start_height = 0; + auto payload = msg.serialize(); + + // Create header with CORRECT length but WRONG checksum + protocol::MessageHeader header; + header.magic = magic; + header.set_command(protocol::commands::VERSION); + header.length = static_cast(payload.size()); + header.checksum.fill(0); // Wrong checksum (should be hash of payload) + + auto header_bytes = message::serialize_header(header); + + // Send complete message: header + payload + std::vector malicious_message; + malicious_message.insert(malicious_message.end(), header_bytes.begin(), header_bytes.end()); + malicious_message.insert(malicious_message.end(), payload.begin(), payload.end()); + + mock_conn->simulate_receive(malicious_message); + io_context.poll(); + + // CRITICAL: Peer must disconnect on checksum mismatch + // Bad checksum indicates corrupted or malicious message + bool safe_state = (peer->state() == PeerConnectionState::DISCONNECTED || + peer->version() == 0); + + CHECK(safe_state); // Must reject message with bad checksum +} + +// ============================================================================= +// TEST 6.7: Wrong network magic during handshake +// ============================================================================= +TEST_CASE("Handshake Security - Wrong network magic during handshake", "[adversarial][handshake][security]") { + asio::io_context io_context; + auto mock_conn = std::make_shared(); + const uint32_t magic = protocol::magic::REGTEST; + + auto peer = Peer::create_inbound(io_context, mock_conn, magic, 0); + peer->start(); + io_context.poll(); + + // Attack: Send VERSION with TESTNET magic to REGTEST node + const uint32_t wrong_magic = protocol::magic::TESTNET; + + message::VersionMessage msg; + msg.version = protocol::PROTOCOL_VERSION; + msg.services = protocol::NODE_NETWORK; + msg.timestamp = 1234567890; + msg.nonce = 12345; + msg.user_agent = "/Test:1.0.0/"; + msg.start_height = 0; + auto payload = msg.serialize(); + + // Create message with WRONG magic + auto malicious_message = create_test_message(wrong_magic, protocol::commands::VERSION, payload); + + mock_conn->simulate_receive(malicious_message); + io_context.poll(); + + // CRITICAL: Peer must disconnect on magic mismatch + // Prevents cross-network pollution and fingerprinting + bool safe_state = (peer->state() == PeerConnectionState::DISCONNECTED || + peer->version() == 0); + + CHECK(safe_state); // Must reject wrong network magic +} + +// ============================================================================= +// TEST 6.8: Checksum for zeros with non-zero payload +// ============================================================================= +TEST_CASE("Handshake Security - Checksum for zeros with non-zero payload", "[adversarial][handshake][security]") { + asio::io_context io_context; + auto mock_conn = std::make_shared(); + const uint32_t magic = protocol::magic::REGTEST; + + auto peer = Peer::create_inbound(io_context, mock_conn, magic, 0); + peer->start(); + io_context.poll(); + + // Create a valid VERSION message payload + message::VersionMessage msg; + msg.version = protocol::PROTOCOL_VERSION; + msg.services = protocol::NODE_NETWORK; + msg.timestamp = 1234567890; + msg.nonce = 12345; + msg.user_agent = "/Test:1.0.0/"; + msg.start_height = 0; + auto payload = msg.serialize(); + + // Calculate checksum of all zeros (different from actual payload) + std::vector zeros(payload.size(), 0); + protocol::MessageHeader wrong_header(magic, protocol::commands::VERSION, static_cast(zeros.size())); + uint256 hash = Hash(zeros); + std::memcpy(wrong_header.checksum.data(), hash.begin(), 4); + auto header_bytes = message::serialize_header(wrong_header); + + // Send header with checksum for zeros, but actual non-zero payload + std::vector malicious_message; + malicious_message.insert(malicious_message.end(), header_bytes.begin(), header_bytes.end()); + malicious_message.insert(malicious_message.end(), payload.begin(), payload.end()); + + mock_conn->simulate_receive(malicious_message); + io_context.poll(); + + // CRITICAL: Peer must disconnect on checksum mismatch + // Header claims checksum for zeros, but payload is non-zero + bool safe_state = (peer->state() == PeerConnectionState::DISCONNECTED || + peer->version() == 0); + + CHECK(safe_state); // Must detect checksum mismatch +} + +// ============================================================================= +// TEST 1.2: PONG before VERACK +// ============================================================================= +TEST_CASE("Handshake Security - PONG before VERACK", "[adversarial][handshake][security]") { + asio::io_context io_context; + auto mock_conn = std::make_shared(); + const uint32_t magic = protocol::magic::REGTEST; + + auto peer = Peer::create_inbound(io_context, mock_conn, magic, 0); + peer->start(); + io_context.poll(); + + // Send VERSION + auto version = create_version_message(magic, 12345); + mock_conn->simulate_receive(version); + io_context.poll(); + + REQUIRE(peer->state() == PeerConnectionState::VERSION_SENT); + mock_conn->clear_sent_messages(); + + // Attack: Send unsolicited PONG before VERACK + auto pong = create_pong_message(magic, 0xDEADBEEF); + mock_conn->simulate_receive(pong); + io_context.poll(); + + // CRITICAL: Peer must ignore unsolicited PONG (prevents state confusion) + CHECK(mock_conn->sent_message_count() == 0); + CHECK(peer->is_connected()); + CHECK(peer->state() == PeerConnectionState::VERSION_SENT); +} + +// ============================================================================= +// TEST 1.7: HEADERS before VERACK +// ============================================================================= +TEST_CASE("Handshake Security - HEADERS before VERACK", "[adversarial][handshake][security]") { + asio::io_context io_context; + auto mock_conn = std::make_shared(); + const uint32_t magic = protocol::magic::REGTEST; + + auto peer = Peer::create_inbound(io_context, mock_conn, magic, 0); + peer->start(); + io_context.poll(); + + auto version = create_version_message(magic, 12345); + mock_conn->simulate_receive(version); + io_context.poll(); + + REQUIRE(peer->state() == PeerConnectionState::VERSION_SENT); + mock_conn->clear_sent_messages(); + + // Attack: Send unsolicited HEADERS before VERACK + auto headers = create_headers_message(magic); + mock_conn->simulate_receive(headers); + io_context.poll(); + + // CRITICAL: Peer must ignore HEADERS (prevents DoS via header processing) + CHECK(mock_conn->sent_message_count() == 0); + CHECK(peer->is_connected()); + CHECK(peer->state() == PeerConnectionState::VERSION_SENT); +} + +// ============================================================================= +// TEST 2.3: Multiple VERACKs +// ============================================================================= +TEST_CASE("Handshake Security - Multiple VERACKs", "[adversarial][handshake][security]") { + asio::io_context io_context; + auto mock_conn = std::make_shared(); + const uint32_t magic = protocol::magic::REGTEST; + + auto peer = Peer::create_inbound(io_context, mock_conn, magic, 0); + peer->start(); + io_context.poll(); + + // Complete handshake + auto version = create_version_message(magic, 12345); + mock_conn->simulate_receive(version); + io_context.poll(); + + auto verack = create_verack_message(magic); + mock_conn->simulate_receive(verack); + io_context.poll(); + + REQUIRE(peer->state() == PeerConnectionState::READY); + mock_conn->clear_sent_messages(); + + // Attack: Send duplicate VERACK messages + for (int i = 0; i < 5; ++i) { + auto duplicate_verack = create_verack_message(magic); + mock_conn->simulate_receive(duplicate_verack); + io_context.poll(); + } + + // CRITICAL: Peer should ignore duplicate VERACKs, stay in READY state + CHECK(peer->state() == PeerConnectionState::READY); + CHECK(peer->is_connected()); +} + +// ============================================================================= +// TEST 4.1: GETADDR flood before VERACK +// ============================================================================= +TEST_CASE("Handshake Security - GETADDR flood before VERACK", "[adversarial][handshake][security]") { + asio::io_context io_context; + auto mock_conn = std::make_shared(); + const uint32_t magic = protocol::magic::REGTEST; + + auto peer = Peer::create_inbound(io_context, mock_conn, magic, 0); + peer->start(); + io_context.poll(); + + auto version = create_version_message(magic, 12345); + mock_conn->simulate_receive(version); + io_context.poll(); + + REQUIRE(peer->state() == PeerConnectionState::VERSION_SENT); + mock_conn->clear_sent_messages(); + + // Attack: Flood with 100 GETADDR messages before VERACK + for (int i = 0; i < 100; ++i) { + auto getaddr = create_getaddr_message(magic); + mock_conn->simulate_receive(getaddr); + io_context.poll(); + } + + // CRITICAL: All GETADDR messages ignored, no ADDR responses + CHECK(mock_conn->sent_message_count() == 0); + + // Now complete handshake + auto verack = create_verack_message(magic); + mock_conn->simulate_receive(verack); + io_context.poll(); + + // Peer should reach READY state successfully + CHECK(peer->state() == PeerConnectionState::READY); +} + +// ============================================================================= +// TEST 4.2: Large message before VERACK +// ============================================================================= +TEST_CASE("Handshake Security - Large message before VERACK", "[adversarial][handshake][security]") { + asio::io_context io_context; + auto mock_conn = std::make_shared(); + const uint32_t magic = protocol::magic::REGTEST; + + auto peer = Peer::create_inbound(io_context, mock_conn, magic, 0); + peer->start(); + io_context.poll(); + + auto version = create_version_message(magic, 12345); + mock_conn->simulate_receive(version); + io_context.poll(); + + REQUIRE(peer->state() == PeerConnectionState::VERSION_SENT); + + // Attack: Send large HEADERS message (near max size) before VERACK + message::HeadersMessage msg; + // Add 2000 headers (2000 * 112 bytes = 224KB) + for (int i = 0; i < 2000; ++i) { + CBlockHeader header; + header.nVersion = 1; + header.hashPrevBlock.SetNull(); + header.payloadRoot.SetNull(); + header.nTime = 1234567890 + i; + header.nBits = 0x1d00ffff; + header.nNonce = i; + header.hashRandomX.SetNull(); + msg.headers.push_back(header); + } + auto payload = msg.serialize(); + auto large_headers = create_test_message(magic, protocol::commands::HEADERS, payload); + + mock_conn->simulate_receive(large_headers); + io_context.poll(); + + // CRITICAL: Message should be ignored, no memory exhaustion + // Peer should remain connected or disconnect (both acceptable) + bool safe_state = (peer->is_connected() && peer->state() == PeerConnectionState::VERSION_SENT) || + peer->state() == PeerConnectionState::DISCONNECTED; + CHECK(safe_state); +} + +// ============================================================================= +// TEST 4.3: Message storm before VERACK +// ============================================================================= +TEST_CASE("Handshake Security - Message storm before VERACK", "[adversarial][handshake][security]") { + asio::io_context io_context; + auto mock_conn = std::make_shared(); + const uint32_t magic = protocol::magic::REGTEST; + + auto peer = Peer::create_inbound(io_context, mock_conn, magic, 0); + peer->start(); + io_context.poll(); + + auto version = create_version_message(magic, 12345); + mock_conn->simulate_receive(version); + io_context.poll(); + + REQUIRE(peer->state() == PeerConnectionState::VERSION_SENT); + mock_conn->clear_sent_messages(); + + // Attack: Rapidly send multiple message types before VERACK + for (int i = 0; i < 20; ++i) { + mock_conn->simulate_receive(create_ping_message(magic, i)); + mock_conn->simulate_receive(create_getaddr_message(magic)); + mock_conn->simulate_receive(create_getheaders_message(magic)); + io_context.poll(); + } + + // CRITICAL: All non-handshake messages ignored + CHECK(mock_conn->sent_message_count() == 0); + + // Handshake should complete normally + auto verack = create_verack_message(magic); + mock_conn->simulate_receive(verack); + io_context.poll(); + + CHECK(peer->state() == PeerConnectionState::READY); +} + +// ============================================================================= +// TEST 6.1: VERSION with truncated payload +// ============================================================================= +TEST_CASE("Handshake Security - VERSION with truncated payload", "[adversarial][handshake][security]") { + asio::io_context io_context; + auto mock_conn = std::make_shared(); + const uint32_t magic = protocol::magic::REGTEST; + + auto peer = Peer::create_inbound(io_context, mock_conn, magic, 0); + peer->start(); + io_context.poll(); + + // Create valid VERSION message + auto version = create_version_message(magic, 12345); + + // Truncate payload by 10 bytes (but keep header length field unchanged) + if (version.size() > 34) { // 24-byte header + some payload + version.resize(version.size() - 10); + } + + mock_conn->simulate_receive(version); + io_context.poll(); + + // CRITICAL: Peer should disconnect or reject malformed message + bool safe_state = (peer->state() == PeerConnectionState::DISCONNECTED || + peer->version() == 0); + CHECK(safe_state); +} + +// ============================================================================= +// TEST 6.2: VERSION with extra bytes +// ============================================================================= +TEST_CASE("Handshake Security - VERSION with extra bytes", "[adversarial][handshake][security]") { + asio::io_context io_context; + auto mock_conn = std::make_shared(); + const uint32_t magic = protocol::magic::REGTEST; + + auto peer = Peer::create_inbound(io_context, mock_conn, magic, 0); + peer->start(); + io_context.poll(); + + // Create valid VERSION message + auto version = create_version_message(magic, 12345); + + // Append garbage bytes beyond declared length + std::vector garbage = {0xDE, 0xAD, 0xBE, 0xEF, 0xCA, 0xFE, 0xBA, 0xBE}; + version.insert(version.end(), garbage.begin(), garbage.end()); + + mock_conn->simulate_receive(version); + io_context.poll(); + + // CRITICAL: Peer should either: + // 1. Disconnect due to protocol violation, OR + // 2. Ignore extra bytes and process valid portion (implementation-dependent) + // Both behaviors are acceptable; the key is no crash or undefined behavior + bool safe_state = (peer->state() == PeerConnectionState::DISCONNECTED || + peer->state() == PeerConnectionState::VERSION_SENT || + peer->version() > 0); + CHECK(safe_state); +} + +// ============================================================================= +// TEST 7.2: Invalid magic bytes +// ============================================================================= +TEST_CASE("Handshake Security - Invalid magic bytes", "[adversarial][handshake][security]") { + asio::io_context io_context; + auto mock_conn = std::make_shared(); + const uint32_t magic = protocol::magic::REGTEST; + + auto peer = Peer::create_inbound(io_context, mock_conn, magic, 0); + peer->start(); + io_context.poll(); + + // Send VERSION with invalid magic (not MAINNET, TESTNET, or REGTEST) + const uint32_t invalid_magic = 0xDEADBEEF; + auto version = create_version_message(invalid_magic, 12345); + + mock_conn->simulate_receive(version); + io_context.poll(); + + // CRITICAL: Peer must disconnect on invalid magic + CHECK(peer->state() == PeerConnectionState::DISCONNECTED); +} + +// ============================================================================= +// TEST 7.3: Magic bytes change mid-handshake +// ============================================================================= +TEST_CASE("Handshake Security - Magic bytes change mid-handshake", "[adversarial][handshake][security]") { + asio::io_context io_context; + auto mock_conn = std::make_shared(); + const uint32_t magic = protocol::magic::REGTEST; + + auto peer = Peer::create_inbound(io_context, mock_conn, magic, 0); + peer->start(); + io_context.poll(); + + // Send VERSION with correct magic + auto version = create_version_message(magic, 12345); + mock_conn->simulate_receive(version); + io_context.poll(); + + REQUIRE(peer->state() == PeerConnectionState::VERSION_SENT); + + // Attack: Send VERACK with different magic + const uint32_t wrong_magic = protocol::magic::TESTNET; + auto verack = create_verack_message(wrong_magic); + mock_conn->simulate_receive(verack); + io_context.poll(); + + // CRITICAL: Peer must disconnect on magic mismatch + CHECK(peer->state() == PeerConnectionState::DISCONNECTED); +} + +// ============================================================================= +// TEST 3.2: Messages queued during handshake +// ============================================================================= +TEST_CASE("Handshake Security - Messages queued during handshake", "[adversarial][handshake][security]") { + asio::io_context io_context; + auto mock_conn = std::make_shared(); + const uint32_t magic = protocol::magic::REGTEST; + + auto peer = Peer::create_inbound(io_context, mock_conn, magic, 0); + peer->start(); + io_context.poll(); + + // Send VERSION, PING, VERACK in rapid succession (all in one batch) + auto version = create_version_message(magic, 12345); + auto ping = create_ping_message(magic, 0x12345678); + auto verack = create_verack_message(magic); + + mock_conn->simulate_receive(version); + mock_conn->simulate_receive(ping); // PING sent before VERACK + mock_conn->simulate_receive(verack); + io_context.poll(); + + // Peer should reach READY state + REQUIRE(peer->state() == PeerConnectionState::READY); + mock_conn->clear_sent_messages(); + + // Wait briefly to see if PING gets processed later (it shouldn't) + io_context.poll(); + + // CRITICAL: PING sent before VERACK should NOT trigger PONG later + auto sent_messages = mock_conn->get_sent_messages(); + bool pong_sent = false; + for (const auto& msg : sent_messages) { + if (msg.size() >= 24) { + std::string command(msg.begin() + 4, msg.begin() + 16); + if (command.find("pong") != std::string::npos) { + pong_sent = true; + break; + } + } + } + CHECK(!pong_sent); // PING was ignored, no PONG should be sent +} + +// ============================================================================ +// PHASE 3: RESOURCE EXHAUSTION TESTS +// ============================================================================ +// Tests for DoS protection via resource limits: +// - Recv buffer exhaustion (DEFAULT_RECV_FLOOD_SIZE = 10 MB) +// - Send queue exhaustion (DEFAULT_SEND_QUEUE_SIZE = 10 MB) +// - GETADDR rate limiting +// +// Limits must be >= MAX_PROTOCOL_MESSAGE_LENGTH (8 MB) to allow valid messages. +// See protocol.hpp for limit definitions and peer.cpp for enforcement. + +TEST_CASE("Adversarial - RecvBufferExhaustion", "[adversarial][resource][flood]") { + // SECURITY: Enforces DEFAULT_RECV_FLOOD_SIZE (10 MB) limit + // to prevent memory exhaustion via large messages + // + // Attack scenario: Attacker sends message header claiming large payload, + // then sends partial data. If we buffer unbounded, attacker can OOM us. + // + // Defense: peer.cpp checks if buffer exceeds limit and disconnects + // + // This test verifies we disconnect before buffer exceeds 5MB. + + asio::io_context io_context; + auto mock_conn = std::make_shared(); + const uint32_t magic = protocol::magic::REGTEST; + + auto peer = Peer::create_inbound(io_context, mock_conn, magic, 0); + peer->start(); + io_context.poll(); + + // Complete handshake to reach READY state + auto version = create_version_message(magic, 54321); + mock_conn->simulate_receive(version); + io_context.poll(); + + auto verack = create_verack_message(magic); + mock_conn->simulate_receive(verack); + io_context.poll(); + + REQUIRE(peer->state() == PeerConnectionState::READY); + + // Record baseline stats + uint64_t bytes_received_before = peer->stats().bytes_received.load(); + + // Create a message header claiming 12MB payload (exceeds 10MB limit) + // We'll send the header + 1MB of data, triggering flood protection + // when peer tries to buffer it (total would be >10MB). + const uint32_t claimed_payload_size = 12 * 1024 * 1024; // 12MB + protocol::MessageHeader hdr; + hdr.magic = magic; + hdr.set_command("fakecmd"); // Unknown command (non-zero payload allowed) + hdr.length = claimed_payload_size; + hdr.checksum = {0x00, 0x00, 0x00, 0x00}; // Fake checksum (we won't get to validation) + + // Serialize header (24 bytes) + message::MessageSerializer s; + s.write_uint32(hdr.magic); + for (char c : hdr.command) { + s.write_uint8(static_cast(c)); + } + s.write_uint32(hdr.length); + for (uint8_t b : hdr.checksum) { + s.write_uint8(b); + } + + std::vector header_bytes = s.data(); + REQUIRE(header_bytes.size() == protocol::MESSAGE_HEADER_SIZE); + + // Send header + mock_conn->simulate_receive(header_bytes); + io_context.poll(); + + // Check if disconnected already (header parsing might trigger limit check) + if (peer->state() == PeerConnectionState::DISCONNECTED || + peer->state() == PeerConnectionState::DISCONNECTING) { + // Good! Disconnected early (before buffering full payload) + CHECK(peer->state() != PeerConnectionState::READY); + return; + } + + // Still connected after header, now send 1MB of payload data + // This should push buffer over 10MB limit and trigger disconnect + const size_t chunk_size = 1 * 1024 * 1024; // 1MB + std::vector payload_chunk(chunk_size, 0xAA); + + size_t sent_before_disconnect = mock_conn->sent_message_count(); + + mock_conn->simulate_receive(payload_chunk); + io_context.poll(); + + // CRITICAL SECURITY CHECK: Peer must disconnect when recv buffer would exceed 10MB + // peer.cpp:338: if (usable_bytes + data.size() > DEFAULT_RECV_FLOOD_SIZE) disconnect + bool disconnected = (peer->state() == PeerConnectionState::DISCONNECTED || + peer->state() == PeerConnectionState::DISCONNECTING); + + CHECK(disconnected); // Must disconnect on flood + + // Verify no response sent (egress silence on resource exhaustion) + CHECK(mock_conn->sent_message_count() == sent_before_disconnect); + + // Stats verification: bytes_received should reflect what was buffered before disconnect + uint64_t bytes_received_after = peer->stats().bytes_received.load(); + uint64_t delta = bytes_received_after - bytes_received_before; + + // We sent header (24 bytes) + chunk (1MB), but peer may have disconnected + // before processing all of it. Just verify some bytes were received. + CHECK(delta > 0); + CHECK(delta <= header_bytes.size() + chunk_size); +} + +// NOTE: Send queue exhaustion testing is covered in test/network/real_transport_tests.cpp +// ("Send-queue overflow closes connection") because it's a transport-level protection, +// not a Peer-level protocol concern. That test verifies real_transport.cpp:274 enforcement. + +// NOTE: GETADDR "rate limiting" test +// Bitcoin Core (and Unicity) don't rate-limit GETADDR requests per se. +// Instead, they use once-per-connection gating: addr_relay_manager.cpp:312-319 +// Only the FIRST GETADDR on each connection gets a response. +// This is a simpler and more effective DoS protection than rate limiting. +// +// The test below verifies this once-per-connection gating behavior. + +TEST_CASE("Adversarial - GetAddrOncePerConnection", "[adversarial][resource][getaddr]") { + // SECURITY: Bitcoin Core responds to GETADDR only once per connection + // (addr_relay_manager.cpp:312-319) + // + // Attack scenario: Attacker sends many GETADDR messages to exhaust CPU/bandwidth + // + // Defense: Once-per-connection gating - only first GETADDR gets a response + // + // This test verifies the gating is enforced (not a full integration test, + // just verifies the message is accepted without error - full test would + // require NetworkManager/AddrRelayManager integration) + + asio::io_context io_context; + auto mock_conn = std::make_shared(); + const uint32_t magic = protocol::magic::REGTEST; + + auto peer = Peer::create_inbound(io_context, mock_conn, magic, 0); + peer->start(); + io_context.poll(); + + // Complete handshake + auto version = create_version_message(magic, 54321); + mock_conn->simulate_receive(version); + io_context.poll(); + + auto verack = create_verack_message(magic); + mock_conn->simulate_receive(verack); + io_context.poll(); + + REQUIRE(peer->state() == PeerConnectionState::READY); + + // Send first GETADDR (zero-length payload allowed for GETADDR) + auto getaddr1 = create_test_message(magic, protocol::commands::GETADDR, {}); + mock_conn->simulate_receive(getaddr1); + io_context.poll(); + + // Peer should still be connected (GETADDR is valid) + CHECK(peer->is_connected()); + + // Send second GETADDR + auto getaddr2 = create_test_message(magic, protocol::commands::GETADDR, {}); + mock_conn->simulate_receive(getaddr2); + io_context.poll(); + + // Peer should STILL be connected (once-per-connection gating doesn't disconnect, + // it just silently ignores subsequent GETADDR requests) + CHECK(peer->is_connected()); + + // Send third GETADDR + auto getaddr3 = create_test_message(magic, protocol::commands::GETADDR, {}); + mock_conn->simulate_receive(getaddr3); + io_context.poll(); + + // Still connected - gating is passive (no disconnect) + CHECK(peer->is_connected()); + + // NOTE: This test verifies Peer-level handling (message acceptance). + // Full verification of once-per-connection gating happens at AddrRelayManager level + // (addr_relay_manager.cpp:312-319) and is covered by discovery tests. + // The adversarial aspect we're testing here is: spamming GETADDR doesn't crash/disconnect. +} + +// ============================================================================= +// PHASE 4: NETWORK SECURITY TESTS +// ============================================================================= + +TEST_CASE("Adversarial - WrongNetworkMagic", "[adversarial][security][network-magic]") { + // Bitcoin Core: net_processing.cpp rejects messages with wrong magic bytes immediately + // Security: Prevents cross-network pollution (mainnet peer sending testnet messages) + asio::io_context io_context; + auto mock_conn = std::make_shared(); + const uint32_t correct_magic = protocol::magic::REGTEST; + const uint32_t wrong_magic = protocol::magic::MAINNET; // Different network + + auto peer = Peer::create_inbound(io_context, mock_conn, correct_magic, 0); + peer->start(); + io_context.poll(); + + const auto msgs_before = peer->stats().messages_received.load(); + const auto bytes_before = peer->stats().bytes_received.load(); + + SECTION("VERSION with wrong magic → disconnect") { + // Create VERSION message with MAINNET magic instead of REGTEST + message::VersionMessage msg; + msg.version = protocol::PROTOCOL_VERSION; + msg.services = protocol::NODE_NETWORK; + msg.timestamp = 1234567890; + msg.nonce = 999999; + msg.user_agent = "/Attacker:1.0.0/"; + msg.start_height = 0; + + auto payload = msg.serialize(); + auto malicious_msg = create_test_message(wrong_magic, protocol::commands::VERSION, payload); + + mock_conn->simulate_receive(malicious_msg); + io_context.poll(); + + // Peer should disconnect (wrong magic is protocol violation) + CHECK_FALSE(peer->is_connected()); + + // No message should be processed (magic check happens before deserialization) + const auto msgs_after = peer->stats().messages_received.load(); + const auto bytes_after = peer->stats().bytes_received.load(); + CHECK(msgs_after == msgs_before); + // Note: bytes_received is updated at transport layer (before validation) + // so bytes_after > bytes_before is expected. What matters is msgs_received didn't increment. + CHECK(bytes_after > bytes_before); + } + + SECTION("Correct magic followed by wrong magic → disconnect on second") { + // First message: correct magic (REGTEST) + auto version1 = create_version_message(correct_magic, 111111); + mock_conn->simulate_receive(version1); + io_context.poll(); + + CHECK(peer->is_connected()); + const auto msgs_middle = peer->stats().messages_received.load(); + CHECK(msgs_middle == msgs_before + 1); + + // Second message: wrong magic (MAINNET) + auto version2 = create_version_message(wrong_magic, 222222); + mock_conn->simulate_receive(version2); + io_context.poll(); + + // Should disconnect on wrong magic + CHECK_FALSE(peer->is_connected()); + + // Only first message processed + const auto msgs_after = peer->stats().messages_received.load(); + CHECK(msgs_after == msgs_middle); // No increment + } + + SECTION("Magic bytes in payload → correctly framed, not confused") { + // Ensure magic bytes appearing in message payload don't confuse parser + // This tests that framing is robust against payload content + + // Create payload containing wrong magic bytes + std::vector payload; + uint32_t embedded_magic = wrong_magic; + payload.insert(payload.end(), + reinterpret_cast(&embedded_magic), + reinterpret_cast(&embedded_magic) + 4); + payload.insert(payload.end(), 100, 0xAA); // Padding + + // But header has correct magic + auto framed_msg = create_test_message(correct_magic, protocol::commands::VERSION, payload); + + mock_conn->simulate_receive(framed_msg); + io_context.poll(); + + // Should NOT confuse embedded magic with real magic + // Message processed normally (though it will fail deserialization due to invalid payload) + const auto msgs_after = peer->stats().messages_received.load(); + + // Message was received at network layer (magic was correct) + // Deserialization might fail but that's okay - we're testing magic check isolation + CHECK(msgs_after >= msgs_before); + } +} + +TEST_CASE("Adversarial - UnsupportedProtocolVersion", "[adversarial][security][version]") { + // Bitcoin Core: Rejects peers with version < MIN_PROTOCOL_VERSION + // Ref: protocol.hpp MIN_PROTOCOL_VERSION = 1 + asio::io_context io_context; + auto mock_conn = std::make_shared(); + const uint32_t magic = protocol::magic::REGTEST; + + auto peer = Peer::create_inbound(io_context, mock_conn, magic, 0); + peer->start(); + io_context.poll(); + + const auto msgs_before = peer->stats().messages_received.load(); + + SECTION("VERSION with protocol_version = 0 → disconnect") { + message::VersionMessage msg; + msg.version = 0; // Too old (< MIN_PROTOCOL_VERSION = 1) + msg.services = protocol::NODE_NETWORK; + msg.timestamp = 1234567890; + msg.nonce = 123456; + msg.user_agent = "/OldNode:0.1.0/"; + msg.start_height = 0; + + auto payload = msg.serialize(); + auto version_msg = create_test_message(magic, protocol::commands::VERSION, payload); + + mock_conn->simulate_receive(version_msg); + io_context.poll(); + + // Peer should disconnect (unsupported version) + CHECK_FALSE(peer->is_connected()); + + // Message was received but caused disconnect + const auto msgs_after = peer->stats().messages_received.load(); + CHECK(msgs_after == msgs_before + 1); + + // Peer version should not be set (handshake failed) + CHECK(peer->version() == 0); + } + + SECTION("VERSION with future protocol_version → accept") { + // Bitcoin Core accepts peers with newer versions (forward compatibility) + message::VersionMessage msg; + msg.version = 99999; // Far future version + msg.services = protocol::NODE_NETWORK; + msg.timestamp = 1234567890; + msg.nonce = 654321; + msg.user_agent = "/FutureNode:99.0.0/"; + msg.start_height = 100; + + auto payload = msg.serialize(); + auto version_msg = create_test_message(magic, protocol::commands::VERSION, payload); + + mock_conn->simulate_receive(version_msg); + io_context.poll(); + + // Should accept (forward compatibility) + CHECK(peer->is_connected()); + + // Peer version should be set + CHECK(peer->version() == 99999); + + const auto msgs_after = peer->stats().messages_received.load(); + CHECK(msgs_after == msgs_before + 1); + } + + SECTION("VERSION with exactly MIN_PROTOCOL_VERSION → accept") { + message::VersionMessage msg; + msg.version = protocol::MIN_PROTOCOL_VERSION; // Exactly at boundary + msg.services = protocol::NODE_NETWORK; + msg.timestamp = 1234567890; + msg.nonce = 111222; + msg.user_agent = "/MinNode:1.0.0/"; + msg.start_height = 0; + + auto payload = msg.serialize(); + auto version_msg = create_test_message(magic, protocol::commands::VERSION, payload); + + mock_conn->simulate_receive(version_msg); + io_context.poll(); + + // Should accept (at minimum) + CHECK(peer->is_connected()); + CHECK(peer->version() == protocol::MIN_PROTOCOL_VERSION); + + const auto msgs_after = peer->stats().messages_received.load(); + CHECK(msgs_after == msgs_before + 1); + } +} + +TEST_CASE("Adversarial - InvalidChecksum", "[adversarial][security][checksum]") { + // Bitcoin Core: Disconnects peers sending messages with invalid checksums + // Ref: src/net.cpp V1Transport::CompleteMessage() + asio::io_context io_context; + auto mock_conn = std::make_shared(); + const uint32_t magic = protocol::magic::REGTEST; + + auto peer = Peer::create_inbound(io_context, mock_conn, magic, 0); + peer->start(); + io_context.poll(); + + const auto msgs_before = peer->stats().messages_received.load(); + const auto bytes_before = peer->stats().bytes_received.load(); + + SECTION("VERSION with corrupted checksum → disconnect") { + message::VersionMessage msg; + msg.version = protocol::PROTOCOL_VERSION; + msg.services = protocol::NODE_NETWORK; + msg.timestamp = 1234567890; + msg.nonce = 123456; + msg.user_agent = "/Test:1.0.0/"; + msg.start_height = 0; + + auto payload = msg.serialize(); + + // Create message with valid checksum first + protocol::MessageHeader header(magic, protocol::commands::VERSION, static_cast(payload.size())); + uint256 hash = Hash(payload); + std::memcpy(header.checksum.data(), hash.begin(), 4); + auto header_bytes = message::serialize_header(header); + + // Corrupt the checksum (bytes 20-23 of header) + header_bytes[20] ^= 0xFF; + header_bytes[21] ^= 0xFF; + header_bytes[22] ^= 0xFF; + header_bytes[23] ^= 0xFF; + + std::vector corrupted_msg; + corrupted_msg.insert(corrupted_msg.end(), header_bytes.begin(), header_bytes.end()); + corrupted_msg.insert(corrupted_msg.end(), payload.begin(), payload.end()); + + mock_conn->simulate_receive(corrupted_msg); + io_context.poll(); + + // Peer should disconnect (checksum mismatch) + CHECK_FALSE(peer->is_connected()); + + // Message should not be processed (checksum validation before processing) + const auto msgs_after = peer->stats().messages_received.load(); + const auto bytes_after = peer->stats().bytes_received.load(); + CHECK(msgs_after == msgs_before); // No increment (validation failed) + // Note: bytes_received is updated at transport layer (before validation) + CHECK(bytes_after > bytes_before); // Bytes were received, just not accepted + } + + SECTION("Valid checksum → accepted") { + // Verify that valid messages still work (control test) + auto version_msg = create_version_message(magic, 999888); + mock_conn->simulate_receive(version_msg); + io_context.poll(); + + CHECK(peer->is_connected()); + + const auto msgs_after = peer->stats().messages_received.load(); + CHECK(msgs_after == msgs_before + 1); + } + + SECTION("Empty payload with zero checksum → special case") { + // Bitcoin Core: Empty payloads have checksum 0x5df6e0e2 (hash of empty string) + // NOT all-zeros. Test that we handle empty payloads correctly. + + std::vector empty_payload; + protocol::MessageHeader header(magic, protocol::commands::VERACK, static_cast(empty_payload.size())); + uint256 hash = Hash(empty_payload); + std::memcpy(header.checksum.data(), hash.begin(), 4); + + // VERACK has empty payload - checksum should be hash(empty), not 0x00000000 + // If our implementation uses 0x00000000 for empty payloads, this is a bug + auto header_bytes = message::serialize_header(header); + + // Extract checksum from generated header + uint32_t checksum; + std::memcpy(&checksum, &header_bytes[20], 4); + + // Bitcoin Core: SHA256(SHA256(""))[:4] = 0x5df6e0e2 + // Our implementation should match + CHECK(checksum != 0x00000000); // Not all-zeros + + // Send the message + std::vector verack_msg; + verack_msg.insert(verack_msg.end(), header_bytes.begin(), header_bytes.end()); + + // First send VERSION to enable VERACK + auto version = create_version_message(magic, 111222); + mock_conn->simulate_receive(version); + io_context.poll(); + + const auto msgs_middle = peer->stats().messages_received.load(); + + mock_conn->simulate_receive(verack_msg); + io_context.poll(); + + // Should accept (valid checksum for empty payload) + // Note: might disconnect due to protocol reasons (unexpected VERACK from inbound) + // but NOT due to checksum + const auto msgs_after = peer->stats().messages_received.load(); + CHECK(msgs_after == msgs_middle + 1); // Message was processed + } +} + +// ============================================================================= +// MESSAGE LIBRARY EDGE CASE TESTS +// ============================================================================= + + +TEST_CASE("Adversarial - PONG with short payload", "[adversarial][pong][security]") { + // SECURITY: PONG must be exactly 8 bytes (nonce) + // Short PONG should be rejected + asio::io_context io_context; + auto mock_conn = std::make_shared(); + const uint32_t magic = protocol::magic::REGTEST; + + auto peer = Peer::create_inbound(io_context, mock_conn, magic, 0); + peer->start(); + io_context.poll(); + + // Complete handshake + auto version = create_version_message(magic, 54321); + mock_conn->simulate_receive(version); + io_context.poll(); + + auto verack = create_verack_message(magic); + mock_conn->simulate_receive(verack); + io_context.poll(); + REQUIRE(peer->state() == PeerConnectionState::READY); + + SECTION("PONG with 0 bytes (empty)") { + std::vector empty_payload; + auto malformed_pong = create_test_message(magic, protocol::commands::PONG, empty_payload); + mock_conn->simulate_receive(malformed_pong); + io_context.poll(); + CHECK(peer->state() == PeerConnectionState::DISCONNECTED); + } + + SECTION("PONG with 4 bytes (half nonce)") { + std::vector short_payload = {0x01, 0x02, 0x03, 0x04}; + auto malformed_pong = create_test_message(magic, protocol::commands::PONG, short_payload); + mock_conn->simulate_receive(malformed_pong); + io_context.poll(); + CHECK(peer->state() == PeerConnectionState::DISCONNECTED); + } + + SECTION("PONG with 7 bytes (one byte short)") { + std::vector almost_payload = {0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07}; + auto malformed_pong = create_test_message(magic, protocol::commands::PONG, almost_payload); + mock_conn->simulate_receive(malformed_pong); + io_context.poll(); + CHECK(peer->state() == PeerConnectionState::DISCONNECTED); + } +} + +TEST_CASE("Adversarial - ADDR edge cases", "[adversarial][addr][security]") { + asio::io_context io_context; + auto mock_conn = std::make_shared(); + const uint32_t magic = protocol::magic::REGTEST; + + auto peer = Peer::create_inbound(io_context, mock_conn, magic, 0); + peer->start(); + io_context.poll(); + + // Complete handshake + auto version = create_version_message(magic, 54321); + mock_conn->simulate_receive(version); + io_context.poll(); + + auto verack = create_verack_message(magic); + mock_conn->simulate_receive(verack); + io_context.poll(); + REQUIRE(peer->state() == PeerConnectionState::READY); + + SECTION("ADDR with truncated address entry") { + // Each ADDR entry is 30 bytes: timestamp(4) + services(8) + ip(16) + port(2) + message::MessageSerializer s; + s.write_varint(1); // 1 address + s.write_uint32(0); // timestamp + s.write_uint64(1); // services + // Missing IP and port (only 12 bytes instead of 30) + + auto payload = s.data(); + auto addr_msg = create_test_message(magic, protocol::commands::ADDR, payload); + mock_conn->simulate_receive(addr_msg); + io_context.poll(); + + // Should disconnect due to incomplete message + CHECK(peer->state() == PeerConnectionState::DISCONNECTED); + } + + SECTION("ADDR with zero addresses") { + message::MessageSerializer s; + s.write_varint(0); // 0 addresses + + auto payload = s.data(); + auto addr_msg = create_test_message(magic, protocol::commands::ADDR, payload); + mock_conn->simulate_receive(addr_msg); + io_context.poll(); + + // Empty ADDR should be accepted (valid but useless) + CHECK(peer->is_connected()); + } + + SECTION("ADDR with all-zero IP address") { + message::MessageSerializer s; + s.write_varint(1); + s.write_uint32(static_cast(std::time(nullptr))); // timestamp + s.write_uint64(1); // services + // IPv4-mapped 0.0.0.0 + for (int i = 0; i < 10; ++i) s.write_uint8(0); + s.write_uint8(0xFF); s.write_uint8(0xFF); + s.write_uint8(0); s.write_uint8(0); s.write_uint8(0); s.write_uint8(0); + s.write_uint16(9590); // port + + auto payload = s.data(); + auto addr_msg = create_test_message(magic, protocol::commands::ADDR, payload); + mock_conn->simulate_receive(addr_msg); + io_context.poll(); + + // Should remain connected (invalid IPs are filtered at application layer) + CHECK(peer->is_connected()); + } + + SECTION("ADDR with loopback address") { + message::MessageSerializer s; + s.write_varint(1); + s.write_uint32(static_cast(std::time(nullptr))); + s.write_uint64(1); + // IPv4-mapped 127.0.0.1 + for (int i = 0; i < 10; ++i) s.write_uint8(0); + s.write_uint8(0xFF); s.write_uint8(0xFF); + s.write_uint8(127); s.write_uint8(0); s.write_uint8(0); s.write_uint8(1); + s.write_uint16(9590); + + auto payload = s.data(); + auto addr_msg = create_test_message(magic, protocol::commands::ADDR, payload); + mock_conn->simulate_receive(addr_msg); + io_context.poll(); + + // Loopback addresses may be filtered but shouldn't crash + CHECK(peer->is_connected()); + } +} + +TEST_CASE("Adversarial - GETHEADERS edge cases", "[adversarial][getheaders][security]") { + asio::io_context io_context; + auto mock_conn = std::make_shared(); + const uint32_t magic = protocol::magic::REGTEST; + + auto peer = Peer::create_inbound(io_context, mock_conn, magic, 0); + peer->start(); + io_context.poll(); + + // Complete handshake + auto version = create_version_message(magic, 54321); + mock_conn->simulate_receive(version); + io_context.poll(); + + auto verack = create_verack_message(magic); + mock_conn->simulate_receive(verack); + io_context.poll(); + REQUIRE(peer->state() == PeerConnectionState::READY); + + SECTION("GETHEADERS with empty locator (just stop hash)") { + message::MessageSerializer s; + s.write_uint32(protocol::PROTOCOL_VERSION); // version + s.write_varint(0); // 0 locator hashes + for (int i = 0; i < 32; ++i) s.write_uint8(0); // stop hash (all zeros) + + auto payload = s.data(); + auto msg = create_test_message(magic, protocol::commands::GETHEADERS, payload); + mock_conn->simulate_receive(msg); + io_context.poll(); + + // Empty locator is valid (means "give me headers from genesis") + CHECK(peer->is_connected()); + } + + SECTION("GETHEADERS with truncated payload") { + // Only version field, no locator count + message::MessageSerializer s; + s.write_uint32(protocol::PROTOCOL_VERSION); + + auto payload = s.data(); + auto msg = create_test_message(magic, protocol::commands::GETHEADERS, payload); + mock_conn->simulate_receive(msg); + io_context.poll(); + + CHECK(peer->state() == PeerConnectionState::DISCONNECTED); + } +} diff --git a/test/integration-network/penalty_tests.cpp b/test/integration-network/penalty_tests.cpp index 3f010a7..1bfddda 100644 --- a/test/integration-network/penalty_tests.cpp +++ b/test/integration-network/penalty_tests.cpp @@ -150,7 +150,7 @@ class AlreadyValidatedWorkParams : public chain::ChainParams { consensus.nNetworkExpirationGracePeriod = 0; consensus.nSuspiciousReorgDepth = 100; nDefaultPort = 29591; - genesis = chain::CreateGenesisBlock(1296688602, 2, 0x207fffff, 1); + genesis = chain::CreateGenesisBlock(1296688602, 2, 0x207fffff, chain::GlobalChainParams::Get().GenesisBlock().GetUTB(), 1); consensus.hashGenesisBlock = genesis.GetHash(); } }; diff --git a/test/integration-network/rpc_integration_tests.cpp b/test/integration-network/rpc_integration_tests.cpp index afcf9f4..9697ad7 100644 --- a/test/integration-network/rpc_integration_tests.cpp +++ b/test/integration-network/rpc_integration_tests.cpp @@ -4,16 +4,23 @@ #include "catch_amalgamated.hpp" #include "chain/chainparams.hpp" #include "chain/chainstate_manager.hpp" +#include "chain/token_manager.hpp" +#include "chain/trust_base_manager.hpp" #include "network/network_manager.hpp" #include "network/rpc_client.hpp" #include "network/rpc_server.hpp" #include "util/logging.hpp" #include +#include "common/mock_bft_client.hpp" +#include "common/mock_trust_base_manager.hpp" #include #include #include #include +#include +#include +#include using namespace unicity; using namespace std::chrono_literals; @@ -32,7 +39,7 @@ class RPCTestFixture { // Initialize components auto params_unique = chain::ChainParams::CreateRegTest(); params_ = params_unique.release(); // Take ownership as raw pointer - chainstate_ = new validation::ChainstateManager(*params_); + chainstate_ = new validation::ChainstateManager(*params_, *tbm_); // Create NetworkManager config for regtest network::NetworkManager::Config net_config; @@ -43,9 +50,12 @@ class RPCTestFixture { network_ = new network::NetworkManager(*chainstate_, net_config); + tbm_ = new chain::LocalTrustBaseManager(temp_dir_, std::make_shared()); + token_manager_ = new mining::TokenManager(temp_dir_, *chainstate_); + // Create RPC server (without miner for basic tests) server_ = new rpc::RPCServer( - socket_path_, *chainstate_, *network_, nullptr, *params_); + socket_path_, *chainstate_, *network_, nullptr, *token_manager_, *tbm_, *params_); } ~RPCTestFixture() { @@ -59,6 +69,8 @@ class RPCTestFixture { util::LogManager::SetLogLevel("off"); delete server_; delete network_; + delete token_manager_; + delete tbm_; delete chainstate_; delete params_; std::filesystem::remove_all(temp_dir_); @@ -86,6 +98,8 @@ class RPCTestFixture { chain::ChainParams* params_; validation::ChainstateManager* chainstate_; network::NetworkManager* network_; + chain::TrustBaseManager* tbm_; + mining::TokenManager* token_manager_; rpc::RPCServer* server_; }; @@ -301,7 +315,8 @@ TEST_CASE("RPC: Socket Path Validation", "[rpc][integration][validation]") { auto temp_dir = std::filesystem::temp_directory_path() / "rpc_long_path_test"; std::filesystem::create_directories(temp_dir); - validation::ChainstateManager chainstate(*params_ptr); + test::MockTrustBaseManager mock_tbm; + validation::ChainstateManager chainstate(*params_ptr, mock_tbm); network::NetworkManager::Config net_config; net_config.network_magic = params_ptr->GetNetworkMagic(); @@ -310,7 +325,10 @@ TEST_CASE("RPC: Socket Path Validation", "[rpc][integration][validation]") { net_config.io_threads = 0; network::NetworkManager network(chainstate, net_config); - rpc::RPCServer server(long_path, chainstate, network, nullptr, *params_ptr); + chain::LocalTrustBaseManager tbm(temp_dir, std::make_shared()); + mining::TokenManager token_manager(temp_dir, chainstate); + + rpc::RPCServer server(long_path, chainstate, network, nullptr, token_manager, tbm, *params_ptr); // Should fail to start due to path too long REQUIRE_FALSE(server.Start()); @@ -779,17 +797,17 @@ TEST_CASE("RPC Commands: submitblock", "[rpc][integration][mining]") { SECTION("Invalid hex length returns error") { rpc::RPCClient client(fixture.GetSocketPath()); REQUIRE_FALSE(client.Connect().has_value()); - // 100 bytes = 200 hex chars expected - std::string response = client.ExecuteCommand("submitblock", {"abcd1234"}); + // 112 bytes header + 32 bytes payload = 144 bytes = 288 hex chars expected + std::string response = client.ExecuteCommand("submitblock", {"abcd1234", "0000000000000000000000000000000000000000000000000000000000000000"}); REQUIRE(response.find("error") != std::string::npos); - REQUIRE(response.find("length") != std::string::npos); + REQUIRE(response.find("288 hex chars") != std::string::npos); } SECTION("Invalid hex characters returns error") { rpc::RPCClient client(fixture.GetSocketPath()); REQUIRE_FALSE(client.Connect().has_value()); - // 200 chars but with invalid hex (contains 'g') - std::string invalid_hex(200, 'g'); + // 224 chars but with invalid hex (contains 'g') + std::string invalid_hex(224, 'g'); std::string response = client.ExecuteCommand("submitblock", {invalid_hex}); REQUIRE(response.find("error") != std::string::npos); } @@ -1265,3 +1283,62 @@ TEST_CASE("RPC Commands: getpeerinfo extended fields", "[rpc][integration][netwo // setting up peer connections, which is done in dedicated peer tests. // Here we just verify the RPC command executes without errors. } + +TEST_CASE("RPC Server: Request ID Mirroring", "[rpc][integration]") { + RPCTestFixture fixture; + REQUIRE(fixture.StartServer()); + + // Give server time to bind + std::this_thread::sleep_for(100ms); + + auto send_raw_http = [&](const std::string& body) -> std::string { + int fd = socket(AF_UNIX, SOCK_STREAM, 0); + if (fd < 0) return ""; + + struct sockaddr_un addr; + std::memset(&addr, 0, sizeof(addr)); + addr.sun_family = AF_UNIX; + std::strncpy(addr.sun_path, fixture.GetSocketPath().c_str(), sizeof(addr.sun_path) - 1); + + if (connect(fd, (struct sockaddr*)&addr, sizeof(addr)) < 0) { + close(fd); + return ""; + } + + std::string request = "POST / HTTP/1.1\r\n" + "Content-Length: " + std::to_string(body.length()) + "\r\n" + "\r\n" + body; + + send(fd, request.c_str(), request.length(), 0); + + char buffer[4096]; + ssize_t received = recv(fd, buffer, sizeof(buffer) - 1, 0); + close(fd); + + if (received <= 0) return ""; + buffer[received] = '\0'; + return std::string(buffer); + }; + + SECTION("Mirrors integer ID") { + std::string body = "{\"method\":\"getinfo\",\"id\":42}"; + std::string response = send_raw_http(body); + REQUIRE(!response.empty()); + REQUIRE(response.find("\"id\":42") != std::string::npos); + } + + SECTION("Mirrors string ID") { + std::string body = "{\"method\":\"getinfo\",\"id\":\"test-id\"}"; + std::string response = send_raw_http(body); + REQUIRE(!response.empty()); + REQUIRE(response.find("\"id\":\"test-id\"") != std::string::npos); + } + + SECTION("Defaults to 0 if ID missing") { + // technically does not follow rpc spec, but matches original implementation + std::string body = "{\"method\":\"getinfo\"}"; + std::string response = send_raw_http(body); + REQUIRE(!response.empty()); + REQUIRE(response.find("\"id\":0") != std::string::npos); + } +} diff --git a/test/integration-network/startup_shutdown_tests.cpp b/test/integration-network/startup_shutdown_tests.cpp index d619049..39e49ec 100644 --- a/test/integration-network/startup_shutdown_tests.cpp +++ b/test/integration-network/startup_shutdown_tests.cpp @@ -14,6 +14,7 @@ #include #include #include +#include "common/mock_trust_base_manager.hpp" using namespace unicity; using namespace unicity::network; @@ -28,13 +29,15 @@ class MinimalChainstate { MinimalChainstate() { GlobalChainParams::Select(ChainType::REGTEST); params_ = ChainParams::CreateRegTest(); - manager_ = std::make_unique(*params_); + tbm_ = std::make_unique(); + manager_ = std::make_unique(*params_, *tbm_); } ChainstateManager& get() { return *manager_; } private: std::unique_ptr params_; + std::unique_ptr tbm_; std::unique_ptr manager_; }; diff --git a/test/unit/chain/bft_client_tests.cpp b/test/unit/chain/bft_client_tests.cpp new file mode 100644 index 0000000..5f3e24e --- /dev/null +++ b/test/unit/chain/bft_client_tests.cpp @@ -0,0 +1,131 @@ +#include "chain/bft_client.hpp" +#include "util/string_parsing.hpp" + +#include "catch_amalgamated.hpp" + +#include +#include +#include + +using namespace unicity; +using namespace unicity::chain; + +namespace { + +const std::string epochs_1_to_3 = + "8183d903f58a010301018383783531365569753248416b776456513347774234586f4b554c624b733174504b7671386e666438594159445041" + "5a395341766d61427663582102d23be5a4932867088fd6fbf12cbc2744ff1393ec1cc7eb47addcec6d908a9e71018378353136556975324841" + "6d476b734c324674316d3156506a3774326463484c59343661686a6d726245483655316873486d346e72473268582103411c106956451afa8a" + "596f9d3ef443c073f6d6d40c5d4539fa3e140465301f3e0183783531365569753248416d514d7052575343736b576773486e41507148436355" + "487165396848533353787461337a6941737a37795531685821027f0fe7ba12dc544fe9d4f8bdc8f71de106b97d234e769c87a938cc7c5f9587" + "570103f6f6f6a3783531365569753248416b776456513347774234586f4b554c624b733174504b7671386e6664385941594450415a39534176" + "6d614276635841dde5ce121337851277214f4cfdb12e1470115987bab6d66cc754df325a8b1ee21fbc022514dcafd8aa73e1b95a599a684a2d" + "2c2f6b373c66becc7975af84588d00783531365569753248416d476b734c324674316d3156506a3774326463484c59343661686a6d72624548" + "3655316873486d346e724732685841027cc807c54a74c6530be68d4797b9d1e1154a34559a0f5c608d402db074ef6b71b26c04e16594ca7f63" + "a82bf26b4253e94b987c2b92ac4ff72667f5b56d0ac601783531365569753248416d514d7052575343736b576773486e415071484363554871" + "65396848533353787461337a6941737a377955316858413ddb9ce2a7f1ee255966b91f06409a791cd101ce865c2c5ff1054ff8ca0dc7db145d" + "e2c71a0b5ed7776532a6581e3fe6b685ea5d48568f898a9193bb0444c3ab01d903f58a01030218648483783531365569753248416b77645651" + "3347774234586f4b554c624b733174504b7671386e6664385941594450415a395341766d61427663582102d23be5a4932867088fd6fbf12cbc" + "2744ff1393ec1cc7eb47addcec6d908a9e710183783531365569753248416d34504469796269337572455a4245796f324d4e42357163675867" + "5633693535487576486b784777746a677959582102ca80e240b1b1b812f4042104fdbb341f857e36eaca3985f4bb17f1e61c29bed201837835" + "31365569753248416d476b734c324674316d3156506a3774326463484c59343661686a6d726245483655316873486d346e7247326858210341" + "1c106956451afa8a596f9d3ef443c073f6d6d40c5d4539fa3e140465301f3e0183783531365569753248416d514d7052575343736b57677348" + "6e41507148436355487165396848533353787461337a6941737a37795531685821027f0fe7ba12dc544fe9d4f8bdc8f71de106b97d234e769c" + "87a938cc7c5f9587570103f6f6582030e6a16a4aefc85a5ee9f8516b00e624e8d6cb17c5dba3bbf16cc5959077865ca3783531365569753248" + "416b776456513347774234586f4b554c624b733174504b7671386e6664385941594450415a395341766d614276635841dd24a0500bdd88aa61" + "37cd1ab4ae442ebd1c2b4e3e630a09d7e6254d968ffe9a2d3e82b4da93ebf3bc27ae6e95f67b020d1cbecc7015a50929d6ea68393f43950078" + "3531365569753248416d476b734c324674316d3156506a3774326463484c59343661686a6d726245483655316873486d346e724732685841f4" + "f0ff9976fbbd1898d331a3914a9c3cddafdd2af559bcee25ca6455775b85f7050000b54139f6abc8dfdd1a73973327c0e8a6a0da7205d79b55" + "8be13430c76300783531365569753248416d514d7052575343736b576773486e41507148436355487165396848533353787461337a6941737a" + "377955316858416e02eb6f1c8395aa20a31ddf625faa4694c5034cb6f32689489e965c8772633944bdc3f7d67a7215d5fb497e4ec4e94048e6" + "6fd956fd6f69ce2415fec3b9b36001d903f58a01030319012c8583783531365569753248416b776456513347774234586f4b554c624b733174" + "504b7671386e6664385941594450415a395341766d61427663582102d23be5a4932867088fd6fbf12cbc2744ff1393ec1cc7eb47addcec6d90" + "8a9e710183783531365569753248416d34504469796269337572455a4245796f324d4e423571636758675633693535487576486b784777746a" + "677959582102ca80e240b1b1b812f4042104fdbb341f857e36eaca3985f4bb17f1e61c29bed20183783531365569753248416d415a31503776" + "6b663475547169394d4d32446e7a3744766a43445a43625958416441704832383676676d414158210364c5ff41407afad8bbc5af28ed872691" + "fcb0ded759c3447779166aa5ba9836640183783531365569753248416d476b734c324674316d3156506a3774326463484c59343661686a6d72" + "6245483655316873486d346e72473268582103411c106956451afa8a596f9d3ef443c073f6d6d40c5d4539fa3e140465301f3e018378353136" + "5569753248416d514d7052575343736b576773486e41507148436355487165396848533353787461337a6941737a37795531685821027f0fe7" + "ba12dc544fe9d4f8bdc8f71de106b97d234e769c87a938cc7c5f9587570104f6f6582005a65e1125eeb837f269ffd11e23f88f6f5dd23b24df" + "61660a0dade111822173a4783531365569753248416b776456513347774234586f4b554c624b733174504b7671386e6664385941594450415a" + "395341766d6142766358414a94d2802a4be2e5f05d839e19bac2548c9b8a257548d8755c224b0d9ce9e9e448d1611f502f327ef7953d6b08d4" + "a3c69985c8c9c99a6e88b54e0cd2e9523a0000783531365569753248416d34504469796269337572455a4245796f324d4e4235716367586756" + "33693535487576486b784777746a6779595841be89783d0631f5fcc94294e6b2979976237e120b3f81c95b9f27438f4c45ff21040689325ed8" + "1c53cf3ce63a422d12a73731b630721f3b057bf768015589db6501783531365569753248416d476b734c324674316d3156506a377432646348" + "4c59343661686a6d726245483655316873486d346e7247326858417ece9161eeb21419a631fbcdf9f6d19a1e8f81e70aa749c4ea3a9f436d81" + "90b358bb81bbc3d814d59176568504bd9aaf35f3a0de998f069d9de9bd4dc8ffa14f01783531365569753248416d514d7052575343736b5767" + "73486e41507148436355487165396848533353787461337a6941737a3779553168584187394810c28e063a505b5c55ef0ea7492385b27e1fba" + "5650eac8a7d40ee2f6682de264f9f6416dbc6786b53cdbe90a0af992ccc083d60bd5ccef2888c9d693ee01"; + +const std::string epoch1_single = + "8181d903f58a010301018383783531365569753248416b776456513347774234586f4b554c624b733174504b7671386e666438594159445041" + "5a395341766d61427663582102d23be5a4932867088fd6fbf12cbc2744ff1393ec1cc7eb47addcec6d908a9e71018378353136556975324841" + "6d476b734c324674316d3156506a3774326463484c59343661686a6d726245483655316873486d346e72473268582103411c106956451afa8a" + "596f9d3ef443c073f6d6d40c5d4539fa3e140465301f3e0183783531365569753248416d514d7052575343736b576773486e41507148436355" + "487165396848533353787461337a6941737a37795531685821027f0fe7ba12dc544fe9d4f8bdc8f71de106b97d234e769c87a938cc7c5f9587" + "570103f6f6f6a3783531365569753248416b776456513347774234586f4b554c624b733174504b7671386e6664385941594450415a39534176" + "6d614276635841dde5ce121337851277214f4cfdb12e1470115987bab6d66cc754df325a8b1ee21fbc022514dcafd8aa73e1b95a599a684a2d" + "2c2f6b373c66becc7975af84588d00783531365569753248416d476b734c324674316d3156506a3774326463484c59343661686a6d72624548" + "3655316873486d346e724732685841027cc807c54a74c6530be68d4797b9d1e1154a34559a0f5c608d402db074ef6b71b26c04e16594ca7f63" + "a82bf26b4253e94b987c2b92ac4ff72667f5b56d0ac601783531365569753248416d514d7052575343736b576773486e415071484363554871" + "65396848533353787461337a6941737a377955316858413ddb9ce2a7f1ee255966b91f06409a791cd101ce865c2c5ff1054ff8ca0dc7db145d" + "e2c71a0b5ed7776532a6581e3fe6b685ea5d48568f898a9193bb0444c3ab01"; + +} // namespace + +TEST_CASE("HttpBFTClient tests", "[chain][bftclient]") { + httplib::Server svr; + int port = svr.bind_to_any_port("127.0.0.1"); + std::thread svr_thread([&svr]() { svr.listen_after_bind(); }); + + std::string addr = "http://127.0.0.1:" + std::to_string(port); + HttpBFTClient client(addr); + + SECTION("FetchTrustBases: valid response with 3 trust bases") { + svr.Get("/api/v1/trustbases", [](const httplib::Request&, httplib::Response& res) { + std::vector data = util::ParseHex(epochs_1_to_3); + res.set_content(reinterpret_cast(data.data()), data.size(), "application/octet-stream"); + }); + + auto tbs = client.FetchTrustBases(1); + + REQUIRE(tbs.size() == 3); + CHECK(tbs[0].epoch == 1); + CHECK(tbs[1].epoch == 2); + CHECK(tbs[2].epoch == 3); + } + + SECTION("FetchTrustBase: valid response with 1 trust base") { + svr.Get("/api/v1/trustbases", [](const httplib::Request&, httplib::Response& res) { + std::vector data = util::ParseHex(epoch1_single); + res.set_content(reinterpret_cast(data.data()), data.size(), "application/octet-stream"); + }); + + auto tb = client.FetchTrustBase(1); + + REQUIRE(tb.has_value()); + CHECK(tb->epoch == 1); + CHECK(tb->version == 1); + CHECK(tb->network_id == 3); + } + + SECTION("Size limit: response too large") { + svr.Get("/api/v1/trustbases", [](const httplib::Request&, httplib::Response& res) { + res.set_content(std::string(HttpBFTClient::MAX_BFT_RESPONSE_SIZE + 1, 'A'), "application/octet-stream"); + }); + + CHECK_THROWS_WITH(client.FetchTrustBases(1), Catch::Matchers::ContainsSubstring("BFT response too large")); + } + + SECTION("Error handling: HTTP error code") { + svr.Get("/api/v1/trustbases", [](const httplib::Request&, httplib::Response& res) { + res.status = 500; + res.set_content("Internal Server Error", "text/plain"); + }); + + CHECK_THROWS_WITH(client.FetchTrustBases(1), Catch::Matchers::ContainsSubstring("HTTP request failed with status code 500")); + } + + svr.stop(); + svr_thread.join(); +} diff --git a/test/unit/chain/block_index_tests.cpp b/test/unit/chain/block_index_tests.cpp index 3932006..b9a862e 100644 --- a/test/unit/chain/block_index_tests.cpp +++ b/test/unit/chain/block_index_tests.cpp @@ -12,7 +12,7 @@ CBlockHeader CreateTestHeader(uint32_t nTime = 1234567890, uint32_t nBits = 0x1d CBlockHeader header; header.nVersion = 1; header.hashPrevBlock.SetNull(); - header.minerAddress.SetNull(); + header.payloadRoot.SetNull(); header.nTime = nTime; header.nBits = nBits; header.nNonce = 0; @@ -31,7 +31,7 @@ TEST_CASE("CBlockIndex - Construction and initialization", "[block_index]") { REQUIRE(index.nHeight == 0); REQUIRE(index.nChainWork == 0); REQUIRE(index.nVersion == 0); - REQUIRE(index.minerAddress.IsNull()); + REQUIRE(index.payloadRoot.IsNull()); REQUIRE(index.nTime == 0); REQUIRE(index.nBits == 0); REQUIRE(index.nNonce == 0); @@ -42,7 +42,7 @@ TEST_CASE("CBlockIndex - Construction and initialization", "[block_index]") { CBlockHeader header = CreateTestHeader(1000, 0x1d00ffff); header.nVersion = 2; header.nNonce = 12345; - header.minerAddress.SetHex("0102030405060708090a0b0c0d0e0f1011121314"); + header.payloadRoot.SetHex("0102030405060708090a0b0c0d0e0f1011121314"); CBlockIndex index(header); @@ -50,7 +50,7 @@ TEST_CASE("CBlockIndex - Construction and initialization", "[block_index]") { REQUIRE(index.nTime == 1000); REQUIRE(index.nBits == 0x1d00ffff); REQUIRE(index.nNonce == 12345); - REQUIRE(index.minerAddress == header.minerAddress); + REQUIRE(index.payloadRoot == header.payloadRoot); REQUIRE(index.hashRandomX == header.hashRandomX); // Metadata fields should be default-initialized @@ -100,7 +100,7 @@ TEST_CASE("CBlockIndex - GetBlockHeader", "[block_index]") { CBlockHeader original = CreateTestHeader(1000, 0x1d00ffff); original.nVersion = 2; original.nNonce = 54321; - original.minerAddress.SetHex("0102030405060708090a0b0c0d0e0f1011121314"); + original.payloadRoot.SetHex("0102030405060708090a0b0c0d0e0f1011121314"); original.hashRandomX.SetHex("1111111111111111111111111111111111111111111111111111111111111111"); CBlockIndex index(original); @@ -111,7 +111,7 @@ TEST_CASE("CBlockIndex - GetBlockHeader", "[block_index]") { REQUIRE(reconstructed.nTime == original.nTime); REQUIRE(reconstructed.nBits == original.nBits); REQUIRE(reconstructed.nNonce == original.nNonce); - REQUIRE(reconstructed.minerAddress == original.minerAddress); + REQUIRE(reconstructed.payloadRoot == original.payloadRoot); REQUIRE(reconstructed.hashRandomX == original.hashRandomX); REQUIRE(reconstructed.hashPrevBlock.IsNull()); // No parent } @@ -426,7 +426,7 @@ TEST_CASE("CBlockIndex - ToString", "[block_index]") { index.nHeight = 100; index.m_block_hash = hash; - index.minerAddress.SetHex("0102030405060708090a0b0c0d0e0f1011121314"); + index.payloadRoot.SetHex("0102030405060708090a0b0c0d0e0f1011121314"); std::string str = index.ToString(); diff --git a/test/unit/chain/block_manager_tests.cpp b/test/unit/chain/block_manager_tests.cpp index dd60c14..ee22129 100644 --- a/test/unit/chain/block_manager_tests.cpp +++ b/test/unit/chain/block_manager_tests.cpp @@ -28,6 +28,12 @@ #include #include +#include "util/hash.hpp" +#include "util/logging.hpp" +#include "util/string_parsing.hpp" +#include "chain/trust_base.hpp" + +using namespace unicity; using namespace unicity::chain; using json = nlohmann::json; @@ -36,11 +42,29 @@ using json = nlohmann::json; //============================================================================== // Helper to create a test block header -static CBlockHeader CreateTestHeader(uint32_t nTime = 1234567890, uint32_t nBits = 0x1d00ffff, uint32_t nNonce = 0) { +static CBlockHeader CreateTestHeader(uint32_t nTime = 1234567890, uint32_t nBits = 0x1d00ffff, uint32_t nNonce = 0, bool include_utb = false) { + auto params = ChainParams::CreateRegTest(); CBlockHeader header; header.nVersion = 1; header.hashPrevBlock.SetNull(); - header.minerAddress.SetNull(); + + uint256 token_id; + token_id.SetHex("0000000000000000000000000000000000000000000000000000000000000001"); + uint256 leaf_0 = SingleHash(std::span(token_id.begin(), 32)); + uint256 leaf_1 = uint256::ZERO; + std::span utb_bytes; + + if (include_utb) { + utb_bytes = params->GenesisBlock().GetUTB(); + leaf_1 = SingleHash(utb_bytes); + } + + header.payloadRoot = CBlockHeader::ComputePayloadRoot(leaf_0, leaf_1); + header.vPayload.assign(leaf_0.begin(), leaf_0.end()); + if (include_utb) { + header.vPayload.insert(header.vPayload.end(), utb_bytes.begin(), utb_bytes.end()); + } + header.nTime = nTime; header.nBits = nBits; header.nNonce = nNonce; @@ -50,20 +74,28 @@ static CBlockHeader CreateTestHeader(uint32_t nTime = 1234567890, uint32_t nBits // Helper to create a child header static CBlockHeader CreateChildHeader(const uint256& prevHash, uint32_t nTime = 1234567890, uint32_t nBits = 0x1d00ffff) { - CBlockHeader header = CreateTestHeader(nTime, nBits); + CBlockHeader header = CreateTestHeader(nTime, nBits, 0, false); header.hashPrevBlock = prevHash; return header; } // Helper to create a child block with specific difficulty (for ChainstateManager tests) static CBlockHeader MakeChild(const CBlockIndex *parent, uint32_t nTime, uint32_t nBits) { + auto params = ChainParams::CreateRegTest(); CBlockHeader child; child.nVersion = 1; child.hashPrevBlock = parent->GetBlockHash(); + + uint256 token_id; + token_id.SetHex("0000000000000000000000000000000000000000000000000000000000000001"); + uint256 leaf_0 = SingleHash(std::span(token_id.begin(), 32)); + uint256 leaf_1 = uint256::ZERO; + child.payloadRoot = CBlockHeader::ComputePayloadRoot(leaf_0, leaf_1); + child.vPayload.assign(leaf_0.begin(), leaf_0.end()); + child.nTime = nTime; child.nBits = nBits; child.nNonce = 0; - child.minerAddress.SetNull(); child.hashRandomX.SetNull(); return child; } @@ -112,13 +144,15 @@ class BlockManagerTestFixture { json block_data; block_data["hash"] = hash.ToString(); block_data["version"] = block_index.nVersion; - block_data["miner_address"] = block_index.minerAddress.ToString(); + block_data["payload_root"] = block_index.payloadRoot.ToString(); block_data["time"] = block_index.nTime; block_data["bits"] = block_index.nBits; block_data["nonce"] = block_index.nNonce; block_data["hash_randomx"] = block_index.hashRandomX.ToString(); + block_data["payload"] = util::ToHex(block_index.vPayload); block_data["height"] = block_index.nHeight; block_data["chainwork"] = block_index.nChainWork.GetHex(); + block_data["bft_epoch"] = block_index.bftEpoch; block_data["status"] = { {"validation", block_index.status.validation}, {"failure", block_index.status.failure} @@ -1333,7 +1367,7 @@ TEST_CASE("BlockManager - Corruption Recovery", "[chain][block_manager][corrupti json blocks = json::array(); json block; block["hash"] = CreateTestHeader().GetHash().ToString(); - // Missing: prev_hash, version, miner_address, time, bits, nonce, hash_randomx, height, chainwork, status + // Missing: prev_hash, version, payload_root, time, bits, nonce, hash_randomx, height, chainwork, status blocks.push_back(block); root["blocks"] = blocks; diff --git a/test/unit/chain/block_tests.cpp b/test/unit/chain/block_tests.cpp index cabd0bd..4a3d38b 100644 --- a/test/unit/chain/block_tests.cpp +++ b/test/unit/chain/block_tests.cpp @@ -9,7 +9,7 @@ TEST_CASE("CBlockHeader serialization and deserialization", "[block]") { CBlockHeader header; header.nVersion = 1; header.hashPrevBlock.SetNull(); - header.minerAddress.SetNull(); + header.payloadRoot.SetNull(); header.nTime = 1234567890; header.nBits = 0x1d00ffff; header.nNonce = 42; @@ -45,7 +45,7 @@ TEST_CASE("CBlockHeader hashing", "[block]") { CBlockHeader header; header.nVersion = 1; header.hashPrevBlock.SetNull(); - header.minerAddress.SetNull(); + header.payloadRoot.SetNull(); header.nTime = 1234567890; header.nBits = 0x1d00ffff; header.nNonce = 42; @@ -120,7 +120,7 @@ TEST_CASE("CBlockHeader golden vector", "[block]") { CBlockHeader header; header.nVersion = 1; header.hashPrevBlock.SetNull(); - header.minerAddress.SetNull(); + header.payloadRoot.SetNull(); header.nTime = 1234567890; header.nBits = 0x1d00ffff; header.nNonce = 42; @@ -128,7 +128,7 @@ TEST_CASE("CBlockHeader golden vector", "[block]") { // Serialize and verify exact bytes auto serialized = header.Serialize(); - REQUIRE(serialized.size() == 100); + REQUIRE(serialized.size() == 112); // Verify specific byte offsets (little-endian) // nVersion = 1 at offset 0 @@ -137,23 +137,23 @@ TEST_CASE("CBlockHeader golden vector", "[block]") { REQUIRE(serialized[2] == 0x00); REQUIRE(serialized[3] == 0x00); - // nTime = 1234567890 (0x499602D2) at offset 56 - REQUIRE(serialized[56] == 0xD2); - REQUIRE(serialized[57] == 0x02); - REQUIRE(serialized[58] == 0x96); - REQUIRE(serialized[59] == 0x49); + // nTime = 1234567890 (0x499602D2) at offset 68 + REQUIRE(serialized[68] == 0xD2); + REQUIRE(serialized[69] == 0x02); + REQUIRE(serialized[70] == 0x96); + REQUIRE(serialized[71] == 0x49); - // nBits = 0x1d00ffff at offset 60 - REQUIRE(serialized[60] == 0xFF); - REQUIRE(serialized[61] == 0xFF); - REQUIRE(serialized[62] == 0x00); - REQUIRE(serialized[63] == 0x1D); + // nBits = 0x1d00ffff at offset 72 + REQUIRE(serialized[72] == 0xFF); + REQUIRE(serialized[73] == 0xFF); + REQUIRE(serialized[74] == 0x00); + REQUIRE(serialized[75] == 0x1D); - // nNonce = 42 (0x2A) at offset 64 - REQUIRE(serialized[64] == 0x2A); - REQUIRE(serialized[65] == 0x00); - REQUIRE(serialized[66] == 0x00); - REQUIRE(serialized[67] == 0x00); + // nNonce = 42 (0x2A) at offset 76 + REQUIRE(serialized[76] == 0x2A); + REQUIRE(serialized[77] == 0x00); + REQUIRE(serialized[78] == 0x00); + REQUIRE(serialized[79] == 0x00); // Compute hash and verify it's deterministic uint256 hash1 = header.GetHash(); @@ -173,7 +173,7 @@ TEST_CASE("CBlockHeader endianness verification", "[block]") { header.nBits = 3; header.nNonce = 4; header.hashPrevBlock.SetNull(); - header.minerAddress.SetNull(); + header.payloadRoot.SetNull(); header.hashRandomX.SetNull(); auto serialized = header.Serialize(); @@ -184,23 +184,23 @@ TEST_CASE("CBlockHeader endianness verification", "[block]") { REQUIRE(serialized[2] == 0x00); REQUIRE(serialized[3] == 0x00); - // nTime = 2 at offset 56 (little-endian: 02 00 00 00) - REQUIRE(serialized[56] == 0x02); - REQUIRE(serialized[57] == 0x00); - REQUIRE(serialized[58] == 0x00); - REQUIRE(serialized[59] == 0x00); - - // nBits = 3 at offset 60 (little-endian: 03 00 00 00) - REQUIRE(serialized[60] == 0x03); - REQUIRE(serialized[61] == 0x00); - REQUIRE(serialized[62] == 0x00); - REQUIRE(serialized[63] == 0x00); - - // nNonce = 4 at offset 64 (little-endian: 04 00 00 00) - REQUIRE(serialized[64] == 0x04); - REQUIRE(serialized[65] == 0x00); - REQUIRE(serialized[66] == 0x00); - REQUIRE(serialized[67] == 0x00); + // nTime = 2 at offset 68 (little-endian: 02 00 00 00) + REQUIRE(serialized[68] == 0x02); + REQUIRE(serialized[69] == 0x00); + REQUIRE(serialized[70] == 0x00); + REQUIRE(serialized[71] == 0x00); + + // nBits = 3 at offset 72 (little-endian: 03 00 00 00) + REQUIRE(serialized[72] == 0x03); + REQUIRE(serialized[73] == 0x00); + REQUIRE(serialized[74] == 0x00); + REQUIRE(serialized[75] == 0x00); + + // nNonce = 4 at offset 76 (little-endian: 04 00 00 00) + REQUIRE(serialized[76] == 0x04); + REQUIRE(serialized[77] == 0x00); + REQUIRE(serialized[78] == 0x00); + REQUIRE(serialized[79] == 0x00); } SECTION("Big-endian values serialize correctly") { @@ -210,7 +210,7 @@ TEST_CASE("CBlockHeader endianness verification", "[block]") { header.nBits = 0x090A0B0C; header.nNonce = 0x0D0E0F10; header.hashPrevBlock.SetNull(); - header.minerAddress.SetNull(); + header.payloadRoot.SetNull(); header.hashRandomX.SetNull(); auto serialized = header.Serialize(); @@ -222,10 +222,10 @@ TEST_CASE("CBlockHeader endianness verification", "[block]") { REQUIRE(serialized[3] == 0x01); // nTime = 0x05060708 (little-endian: 08 07 06 05) - REQUIRE(serialized[56] == 0x08); - REQUIRE(serialized[57] == 0x07); - REQUIRE(serialized[58] == 0x06); - REQUIRE(serialized[59] == 0x05); + REQUIRE(serialized[68] == 0x08); + REQUIRE(serialized[69] == 0x07); + REQUIRE(serialized[70] == 0x06); + REQUIRE(serialized[71] == 0x05); } } @@ -269,7 +269,7 @@ TEST_CASE("CBlockHeader round-trip with random data", "[block]") { header1.hashRandomX.begin()[i] = static_cast(255 - i); } for (int i = 0; i < 20; i++) { - header1.minerAddress.begin()[i] = static_cast(i * 2); + header1.payloadRoot.begin()[i] = static_cast(i * 2); } // Serialize @@ -287,7 +287,7 @@ TEST_CASE("CBlockHeader round-trip with random data", "[block]") { REQUIRE(header2.nBits == header1.nBits); REQUIRE(header2.nNonce == header1.nNonce); REQUIRE(header2.hashPrevBlock == header1.hashPrevBlock); - REQUIRE(header2.minerAddress == header1.minerAddress); + REQUIRE(header2.payloadRoot == header1.payloadRoot); REQUIRE(header2.hashRandomX == header1.hashRandomX); // Verify hashes match @@ -296,11 +296,11 @@ TEST_CASE("CBlockHeader round-trip with random data", "[block]") { } TEST_CASE("CBlockHeader Serialize returns fixed-size array", "[block]") { - SECTION("Serialize produces exact 100-byte array") { + SECTION("Serialize produces exact 112-byte array") { CBlockHeader header; header.nVersion = 1; header.hashPrevBlock.SetNull(); - header.minerAddress.SetNull(); + header.payloadRoot.SetNull(); header.nTime = 1234567890; header.nBits = 0x1d00ffff; header.nNonce = 42; @@ -310,7 +310,7 @@ TEST_CASE("CBlockHeader Serialize returns fixed-size array", "[block]") { // Verify it's exactly HEADER_SIZE REQUIRE(fixed.size() == CBlockHeader::HEADER_SIZE); - REQUIRE(fixed.size() == 100); + REQUIRE(fixed.size() == 112); } SECTION("Serialize uses field offset constants") { @@ -320,7 +320,7 @@ TEST_CASE("CBlockHeader Serialize returns fixed-size array", "[block]") { header.nBits = 3; header.nNonce = 4; header.hashPrevBlock.SetNull(); - header.minerAddress.SetNull(); + header.payloadRoot.SetNull(); header.hashRandomX.SetNull(); auto fixed = header.Serialize(); @@ -346,7 +346,7 @@ TEST_CASE("CBlockHeader span-based Deserialize", "[block]") { header1.hashRandomX.begin()[i] = static_cast(255 - i); } for (int i = 0; i < 20; i++) { - header1.minerAddress.begin()[i] = static_cast(i * 2); + header1.payloadRoot.begin()[i] = static_cast(i * 2); } auto serialized = header1.Serialize(); @@ -360,7 +360,7 @@ TEST_CASE("CBlockHeader span-based Deserialize", "[block]") { REQUIRE(header2.nBits == header1.nBits); REQUIRE(header2.nNonce == header1.nNonce); REQUIRE(header2.hashPrevBlock == header1.hashPrevBlock); - REQUIRE(header2.minerAddress == header1.minerAddress); + REQUIRE(header2.payloadRoot == header1.payloadRoot); REQUIRE(header2.hashRandomX == header1.hashRandomX); } } @@ -373,7 +373,7 @@ TEST_CASE("CBlockHeader array-based Deserialize", "[block]") { header1.nBits = 0x1d00ffff; header1.nNonce = 42; header1.hashPrevBlock.SetNull(); - header1.minerAddress.SetNull(); + header1.payloadRoot.SetNull(); header1.hashRandomX.SetNull(); auto fixed = header1.Serialize(); @@ -400,21 +400,21 @@ TEST_CASE("CBlockHeader array-based Deserialize", "[block]") { TEST_CASE("CBlockHeader MainNet genesis block golden vector", "[block]") { SECTION("MainNet genesis block from chainparams") { // This is the actual genesis block from chainparams.cpp - // Mined on: 2025-10-27 - // Expected hash: 938f0a2ca374ea2fade1911b254269a82576d0c95a97807a2120e1e508f0d688 + // Mined on: 2025-10-24 + // Expected hash: 4d84216a9a2cf3854488f85a49d8331818e376cfe88c0f0883a81df2ffd86092 CBlockHeader genesis; genesis.nVersion = 1; genesis.hashPrevBlock.SetNull(); - genesis.minerAddress.SetNull(); - genesis.nTime = 1761564252; // Oct 27, 2025 + genesis.payloadRoot.SetNull(); + genesis.nTime = 1761330012; // Oct 24, 2025 genesis.nBits = 0x1f06a000; // Target: ~2.5 minutes at 50 H/s genesis.nNonce = 8497; // Found by genesis miner genesis.hashRandomX.SetNull(); // Serialize and verify exact size auto serialized = genesis.Serialize(); - REQUIRE(serialized.size() == 100); + REQUIRE(serialized.size() == 112); // Verify the serialized header bytes match expected format // nVersion = 1 at offset 0 (little-endian: 01 00 00 00) @@ -428,31 +428,31 @@ TEST_CASE("CBlockHeader MainNet genesis block golden vector", "[block]") { REQUIRE(serialized[i] == 0x00); } - // minerAddress is all zeros (offset 36-55) - for (size_t i = 36; i < 56; i++) { + // payloadRoot is all zeros (offset 36-67) + for (size_t i = 36; i < 68; i++) { REQUIRE(serialized[i] == 0x00); } - // nTime = 1761564252 (0x68FF565C) at offset 56 (little-endian: 5C 56 FF 68) - REQUIRE(serialized[56] == 0x5C); - REQUIRE(serialized[57] == 0x56); - REQUIRE(serialized[58] == 0xFF); - REQUIRE(serialized[59] == 0x68); - - // nBits = 0x1f06a000 at offset 60 (little-endian: 00 A0 06 1F) - REQUIRE(serialized[60] == 0x00); - REQUIRE(serialized[61] == 0xA0); - REQUIRE(serialized[62] == 0x06); - REQUIRE(serialized[63] == 0x1F); - - // nNonce = 8497 (0x00002131) at offset 64 (little-endian: 31 21 00 00) - REQUIRE(serialized[64] == 0x31); - REQUIRE(serialized[65] == 0x21); - REQUIRE(serialized[66] == 0x00); - REQUIRE(serialized[67] == 0x00); - - // hashRandomX is all zeros (offset 68-99) - for (size_t i = 68; i < 100; i++) { + // nTime = 1761330012 (0x68FBC35C) at offset 68 (little-endian: 5C C3 FB 68) + REQUIRE(static_cast(serialized[68]) == 0x5C); + REQUIRE(static_cast(serialized[69]) == 0xC3); + REQUIRE(static_cast(serialized[70]) == 0xFB); + REQUIRE(static_cast(serialized[71]) == 0x68); + + // nBits = 0x1f06a000 at offset 72 (little-endian: 00 A0 06 1F) + REQUIRE(serialized[72] == 0x00); + REQUIRE(serialized[73] == 0xA0); + REQUIRE(serialized[74] == 0x06); + REQUIRE(serialized[75] == 0x1F); + + // nNonce = 8497 (0x00002131) at offset 76 (little-endian: 31 21 00 00) + REQUIRE(serialized[76] == 0x31); + REQUIRE(serialized[77] == 0x21); + REQUIRE(serialized[78] == 0x00); + REQUIRE(serialized[79] == 0x00); + + // hashRandomX is all zeros (offset 80-111) + for (size_t i = 80; i < 112; i++) { REQUIRE(serialized[i] == 0x00); } @@ -460,17 +460,17 @@ TEST_CASE("CBlockHeader MainNet genesis block golden vector", "[block]") { uint256 hash = genesis.GetHash(); std::string hashHex = hash.GetHex(); - // Expected: 938f0a2ca374ea2fade1911b254269a82576d0c95a97807a2120e1e508f0d688 + // Expected: 4d84216a9a2cf3854488f85a49d8331818e376cfe88c0f0883a81df2ffd86092 // (This is the display format; GetHex() reverses bytes per Bitcoin convention) - REQUIRE(hashHex == "938f0a2ca374ea2fade1911b254269a82576d0c95a97807a2120e1e508f0d688"); + REQUIRE(hashHex == "4d84216a9a2cf3854488f85a49d8331818e376cfe88c0f0883a81df2ffd86092"); } SECTION("Genesis block round-trip preserves hash") { CBlockHeader genesis; genesis.nVersion = 1; genesis.hashPrevBlock.SetNull(); - genesis.minerAddress.SetNull(); - genesis.nTime = 1761564252; + genesis.payloadRoot.SetNull(); + genesis.nTime = 1761330012; genesis.nBits = 0x1f06a000; genesis.nNonce = 8497; genesis.hashRandomX.SetNull(); @@ -490,14 +490,14 @@ TEST_CASE("CBlockHeader MainNet genesis block golden vector", "[block]") { } TEST_CASE("CBlockHeader comprehensive hex golden vector", "[block]") { - SECTION("Complete 100-byte header with expected hash") { + SECTION("Complete 112-byte header with expected hash") { // Manually constructed test vector for interoperability testing // This serves as a reference for alternative implementations CBlockHeader header; header.nVersion = 1; header.hashPrevBlock.SetNull(); - header.minerAddress.SetNull(); + header.payloadRoot.SetNull(); header.nTime = 1234567890; header.nBits = 0x1d00ffff; header.nNonce = 42; @@ -505,12 +505,12 @@ TEST_CASE("CBlockHeader comprehensive hex golden vector", "[block]") { // Serialize to get exact hex bytes auto serialized = header.Serialize(); - REQUIRE(serialized.size() == 100); + REQUIRE(serialized.size() == 112); // Expected hex representation (for documentation/interop) // 01000000 (version=1) // 0000000000000000000000000000000000000000000000000000000000000000 (hashPrevBlock) - // 00000000000000000000000000000000000000000000 (minerAddress, 20 bytes) + // 0000000000000000000000000000000000000000000000000000000000000000 (payloadRoot, 32 bytes) // d2029649 (nTime=1234567890) // ffff001d (nBits=0x1d00ffff) // 2a000000 (nNonce=42) @@ -546,7 +546,7 @@ TEST_CASE("CBlockHeader ToString", "[block]") { h.nNonce = 42; h.hashPrevBlock.SetNull(); h.hashRandomX.SetNull(); - h.minerAddress.SetNull(); + h.payloadRoot.SetNull(); auto s = h.ToString(); REQUIRE(s.find("version") != std::string::npos); @@ -566,133 +566,3 @@ TEST_CASE("CBlockLocator basic semantics", "[block]") { loc.SetNull(); REQUIRE(loc.IsNull()); } - -TEST_CASE("CBlockHeader alpha-release compatibility", "[block][alpha]") { - SECTION("Hash computation matches alpha-release (no byte reversal)") { - // Alpha-release uses HashWriter pattern: double SHA256 with NO byte reversal - // Our refactored code should produce identical hashes - - CBlockHeader header; - header.nVersion = 1; - header.hashPrevBlock.SetNull(); - header.minerAddress.SetNull(); - header.nTime = 1234567890; - header.nBits = 0x1d00ffff; - header.nNonce = 42; - header.hashRandomX.SetNull(); - - // Get hash from our implementation - uint256 our_hash = header.GetHash(); - - // Compute hash using alpha-release logic (copied from alpha-release src/hash.h) - // Alpha HashWriter::GetHash() does: double SHA256, NO byte reversal - auto serialized = header.Serialize(); - uint256 alpha_hash; - CSHA256().Write(serialized.data(), serialized.size()).Finalize(alpha_hash.begin()); - CSHA256().Write(alpha_hash.begin(), CSHA256::OUTPUT_SIZE).Finalize(alpha_hash.begin()); - - // Our hash MUST match alpha-release hash exactly - REQUIRE(our_hash == alpha_hash); - - INFO("Our hash: " << our_hash.GetHex()); - INFO("Alpha hash: " << alpha_hash.GetHex()); - } - - SECTION("Genesis block hash matches alpha-release mainnet genesis") { - // Mainnet genesis from chainparams.cpp - CBlockHeader genesis; - genesis.nVersion = 1; - genesis.hashPrevBlock.SetNull(); - genesis.minerAddress.SetNull(); - genesis.nTime = 1761564252; // Oct 27, 2025 - genesis.nBits = 0x1f06a000; - genesis.nNonce = 8497; - genesis.hashRandomX.SetNull(); - - // Our implementation - uint256 our_hash = genesis.GetHash(); - - // Alpha-release logic (double SHA256, no reversal) - auto serialized = genesis.Serialize(); - uint256 alpha_hash; - CSHA256().Write(serialized.data(), serialized.size()).Finalize(alpha_hash.begin()); - CSHA256().Write(alpha_hash.begin(), CSHA256::OUTPUT_SIZE).Finalize(alpha_hash.begin()); - - // Must match - REQUIRE(our_hash == alpha_hash); - - // Also verify against the expected mainnet genesis hash (GetHex() displays in reversed byte order) - REQUIRE(our_hash.GetHex() == "938f0a2ca374ea2fade1911b254269a82576d0c95a97807a2120e1e508f0d688"); - } - - SECTION("Multiple test vectors match alpha-release") { - // Test several different headers to ensure consistency - struct TestVector { - int32_t version; - uint32_t time; - uint32_t bits; - uint32_t nonce; - }; - - TestVector vectors[] = { - {1, 0, 0x207fffff, 0}, - {1, 1234567890, 0x1d00ffff, 42}, - {1, 1761564252, 0x1f06a000, 8497}, - {2, 9999999, 0x1a0fffff, 123456}, - }; - - for (const auto& vec : vectors) { - CBlockHeader header; - header.nVersion = vec.version; - header.hashPrevBlock.SetNull(); - header.minerAddress.SetNull(); - header.nTime = vec.time; - header.nBits = vec.bits; - header.nNonce = vec.nonce; - header.hashRandomX.SetNull(); - - // Our implementation - uint256 our_hash = header.GetHash(); - - // Alpha-release logic - auto serialized = header.Serialize(); - uint256 alpha_hash; - CSHA256().Write(serialized.data(), serialized.size()).Finalize(alpha_hash.begin()); - CSHA256().Write(alpha_hash.begin(), CSHA256::OUTPUT_SIZE).Finalize(alpha_hash.begin()); - - // Must match for every test vector - REQUIRE(our_hash == alpha_hash); - - INFO("Test vector: version=" << vec.version << " time=" << vec.time - << " bits=0x" << std::hex << vec.bits << " nonce=" << std::dec << vec.nonce); - INFO("Hash: " << our_hash.GetHex()); - } - } - - SECTION("Regtest genesis matches alpha-release") { - // Regtest genesis from chainparams.cpp - CBlockHeader genesis; - genesis.nVersion = 1; - genesis.hashPrevBlock.SetNull(); - genesis.minerAddress.SetNull(); - genesis.nTime = 1760549555; - genesis.nBits = 0x207fffff; - genesis.nNonce = 2; - genesis.hashRandomX.SetNull(); - - // Our implementation - uint256 our_hash = genesis.GetHash(); - - // Alpha-release logic - auto serialized = genesis.Serialize(); - uint256 alpha_hash; - CSHA256().Write(serialized.data(), serialized.size()).Finalize(alpha_hash.begin()); - CSHA256().Write(alpha_hash.begin(), CSHA256::OUTPUT_SIZE).Finalize(alpha_hash.begin()); - - // Must match - REQUIRE(our_hash == alpha_hash); - - // Verify against expected regtest genesis hash - REQUIRE(our_hash.GetHex() == "0555faa88836f4ce189235a28279af4614432234b6f7e2f350e4fc0dadb1ffa7"); - } -} diff --git a/test/unit/chain/chain_selector_candidate_tests.cpp b/test/unit/chain/chain_selector_candidate_tests.cpp index a965dba..56c43ff 100644 --- a/test/unit/chain/chain_selector_candidate_tests.cpp +++ b/test/unit/chain/chain_selector_candidate_tests.cpp @@ -1,235 +1,235 @@ -// Candidate pruning and invariants tests (requires UNICITY_TESTS) - -#include "catch_amalgamated.hpp" -#include "chain/chainstate_manager.hpp" -#include "chain/chainparams.hpp" -#include "chain/block.hpp" -#include "chain/validation.hpp" -#include "common/test_chainstate_manager.hpp" - -using namespace unicity; -using namespace unicity::chain; -using namespace unicity::validation; -using unicity::test::TestChainstateManager; - -static CBlockHeader Mkh(const CBlockIndex* prev, uint32_t nTime) { - CBlockHeader h; h.nVersion=1; h.hashPrevBlock = prev ? prev->GetBlockHash() : uint256(); - h.minerAddress.SetNull(); h.nTime=nTime; h.nBits=0x207fffff; h.nNonce=0; h.hashRandomX.SetNull(); return h; -} - -static bool HasChild(const BlockManager& bm, const CBlockIndex* idx) { - for (const auto& [hash, block] : bm.GetBlockIndex()) { - if (block.pprev == idx) return true; - } - return false; -} - -TEST_CASE("Candidate set invariants across activation and invalidation", "[chain][candidates]") { - auto params = ChainParams::CreateRegTest(); - TestChainstateManager csm(*params); - REQUIRE(csm.Initialize(params->GenesisBlock())); - - const CBlockIndex* g = csm.GetTip(); - - // Add A1 and activate - CBlockHeader A1 = Mkh(g, g->nTime + 120); - ValidationState s; auto* pA1 = csm.AcceptBlockHeader(A1, s); REQUIRE(pA1); - csm.TryAddBlockIndexCandidate(pA1); - REQUIRE(csm.DebugCandidateCount() >= 1); - REQUIRE(csm.ActivateBestChain()); - - // After activation, candidates should be pruned (no tip, no lower-work) - REQUIRE(csm.DebugCandidateCount() == 0); - - // Add competing fork B1 (lower work than current tip) - CBlockHeader B1 = Mkh(g, g->nTime + 130); - auto* pB1 = csm.AcceptBlockHeader(B1, s); REQUIRE(pB1); - csm.TryAddBlockIndexCandidate(pB1); - - // Activate best chain keeps A1 as tip; B1 remains as a lower-work candidate (Core keeps candidates on no-op) - REQUIRE(csm.ActivateBestChain()); - REQUIRE(csm.DebugCandidateCount() >= 1); - - // Extend fork to surpass tip: B2, B3 - CBlockHeader B2 = Mkh(pB1, pB1->nTime + 120); - auto* pB2 = csm.AcceptBlockHeader(B2, s); REQUIRE(pB2); - csm.TryAddBlockIndexCandidate(pB2); - - CBlockHeader B3 = Mkh(pB2, pB2->nTime + 120); - auto* pB3 = csm.AcceptBlockHeader(B3, s); REQUIRE(pB3); - csm.TryAddBlockIndexCandidate(pB3); - - // Before activation, candidate should include B3 (a leaf) - REQUIRE(csm.DebugCandidateCount() >= 1); - - // Activate reorg to B3; candidates pruned again - REQUIRE(csm.ActivateBestChain()); - REQUIRE(csm.DebugCandidateCount() == 0); - - // Invalidate current tip (B3) – should populate candidates without activating - REQUIRE(csm.InvalidateBlock(pB3->GetBlockHash())); - - auto hashes = csm.DebugCandidateHashes(); - REQUIRE_FALSE(hashes.empty()); - - // None of the candidates should be the invalidated block - for (const auto& h : hashes) { - REQUIRE(h != pB3->GetBlockHash()); - } -} - -TEST_CASE("ANCESTOR_FAILED propagation marks descendants", "[chain][candidates][ancestor_failed]") { - auto params = ChainParams::CreateRegTest(); - TestChainstateManager csm(*params); - REQUIRE(csm.Initialize(params->GenesisBlock())); - - const CBlockIndex* g = csm.GetTip(); - - // Build chain: genesis -> A1 -> A2 -> A3 - CBlockHeader A1 = Mkh(g, g->nTime + 120); - ValidationState s; auto* pA1 = csm.AcceptBlockHeader(A1, s); REQUIRE(pA1); - - CBlockHeader A2 = Mkh(pA1, pA1->nTime + 120); - auto* pA2 = csm.AcceptBlockHeader(A2, s); REQUIRE(pA2); - - CBlockHeader A3 = Mkh(pA2, pA2->nTime + 120); - auto* pA3 = csm.AcceptBlockHeader(A3, s); REQUIRE(pA3); - - // Activate the chain - csm.TryAddBlockIndexCandidate(pA3); - REQUIRE(csm.ActivateBestChain()); - REQUIRE(csm.GetTip() == pA3); - - // Invalidate A1 (middle of chain) - REQUIRE(csm.InvalidateBlock(pA1->GetBlockHash())); - - // A1 should be VALIDATION_FAILED - REQUIRE(pA1->status.IsFailed()); - - // A2 and A3 should be ANCESTOR_FAILED - REQUIRE(pA2->status.IsFailed()); - REQUIRE(pA3->status.IsFailed()); - - // Neither A1, A2, nor A3 should be in candidates - auto hashes = csm.DebugCandidateHashes(); - for (const auto& h : hashes) { - REQUIRE(h != pA1->GetBlockHash()); - REQUIRE(h != pA2->GetBlockHash()); - REQUIRE(h != pA3->GetBlockHash()); - } - - // Tip should have rolled back to genesis - REQUIRE(csm.GetTip() == g); -} - -TEST_CASE("Parent re-added as candidate after child invalidation", "[chain][candidates][parent_readd]") { - auto params = ChainParams::CreateRegTest(); - TestChainstateManager csm(*params); - REQUIRE(csm.Initialize(params->GenesisBlock())); - - const CBlockIndex* g = csm.GetTip(); - - // Build chain: genesis -> A1 -> A2 - CBlockHeader A1 = Mkh(g, g->nTime + 120); - ValidationState s; auto* pA1 = csm.AcceptBlockHeader(A1, s); REQUIRE(pA1); - - CBlockHeader A2 = Mkh(pA1, pA1->nTime + 120); - auto* pA2 = csm.AcceptBlockHeader(A2, s); REQUIRE(pA2); - - // Activate the chain to A2 - csm.TryAddBlockIndexCandidate(pA2); - REQUIRE(csm.ActivateBestChain()); - REQUIRE(csm.GetTip() == pA2); - - // Candidates may include lower-work blocks (kept for InvalidateBlock fallback) - // The key invariant is that the tip is correctly activated - - // Invalidate A2 (the tip) - REQUIRE(csm.InvalidateBlock(pA2->GetBlockHash())); - - // A1 should now be the tip - REQUIRE(csm.GetTip() == pA1); - - // A1's parent (genesis) should be re-added to candidates OR - // we should have some candidate available for fallback - // Since A1 is now the tip, candidates may be empty after prune - // The key invariant: we didn't lose track of valid chain state - REQUIRE(csm.GetTip()->IsValid()); -} - -TEST_CASE("Competing fork becomes candidate after main chain invalidation", "[chain][candidates][fork_activation]") { - auto params = ChainParams::CreateRegTest(); - TestChainstateManager csm(*params); - REQUIRE(csm.Initialize(params->GenesisBlock())); - - const CBlockIndex* g = csm.GetTip(); - - // Build main chain: genesis -> A1 -> A2 -> A3 - CBlockHeader A1 = Mkh(g, g->nTime + 120); - ValidationState s; auto* pA1 = csm.AcceptBlockHeader(A1, s); REQUIRE(pA1); - - CBlockHeader A2 = Mkh(pA1, pA1->nTime + 120); - auto* pA2 = csm.AcceptBlockHeader(A2, s); REQUIRE(pA2); - - CBlockHeader A3 = Mkh(pA2, pA2->nTime + 120); - auto* pA3 = csm.AcceptBlockHeader(A3, s); REQUIRE(pA3); - - // Build competing fork: genesis -> B1 -> B2 (shorter but valid) - CBlockHeader B1 = Mkh(g, g->nTime + 130); - auto* pB1 = csm.AcceptBlockHeader(B1, s); REQUIRE(pB1); - - CBlockHeader B2 = Mkh(pB1, pB1->nTime + 120); - auto* pB2 = csm.AcceptBlockHeader(B2, s); REQUIRE(pB2); - - // Activate main chain (A3 has more work) - csm.TryAddBlockIndexCandidate(pA3); - csm.TryAddBlockIndexCandidate(pB2); - REQUIRE(csm.ActivateBestChain()); - REQUIRE(csm.GetTip() == pA3); - - // Invalidate A1 (kills entire main chain) - REQUIRE(csm.InvalidateBlock(pA1->GetBlockHash())); - - // B2 should now be a candidate (it's the best valid chain) - auto hashes = csm.DebugCandidateHashes(); - bool b2_in_candidates = false; - for (const auto& h : hashes) { - if (h == pB2->GetBlockHash()) { - b2_in_candidates = true; - break; - } - } - REQUIRE(b2_in_candidates); - - // Activate best chain should switch to fork B - REQUIRE(csm.ActivateBestChain()); - REQUIRE(csm.GetTip() == pB2); -} - -TEST_CASE("AcceptBlockHeader rejects headers descending from failed block", "[chain][candidates][reject_descendant]") { - auto params = ChainParams::CreateRegTest(); - TestChainstateManager csm(*params); - REQUIRE(csm.Initialize(params->GenesisBlock())); - - const CBlockIndex* g = csm.GetTip(); - - // Build chain: genesis -> A1 - CBlockHeader A1 = Mkh(g, g->nTime + 120); - ValidationState s; auto* pA1 = csm.AcceptBlockHeader(A1, s); REQUIRE(pA1); - - // Activate A1 - csm.TryAddBlockIndexCandidate(pA1); - REQUIRE(csm.ActivateBestChain()); - - // Invalidate A1 - REQUIRE(csm.InvalidateBlock(pA1->GetBlockHash())); - - // Now try to accept A2 which extends the invalidated A1 - CBlockHeader A2 = Mkh(pA1, pA1->nTime + 120); - ValidationState s2; - auto* pA2 = csm.AcceptBlockHeader(A2, s2); - - // Should be rejected because parent is failed - REQUIRE(pA2 == nullptr); - REQUIRE(s2.IsInvalid()); +// Candidate pruning and invariants tests (requires UNICITY_TESTS) + +#include "catch_amalgamated.hpp" +#include "chain/chainstate_manager.hpp" +#include "chain/chainparams.hpp" +#include "chain/block.hpp" +#include "chain/validation.hpp" +#include "common/test_chainstate_manager.hpp" + +using namespace unicity; +using namespace unicity::chain; +using namespace unicity::validation; +using unicity::test::TestChainstateManager; + +static CBlockHeader Mkh(const CBlockIndex* prev, uint32_t nTime) { + CBlockHeader h; h.nVersion=1; h.hashPrevBlock = prev ? prev->GetBlockHash() : uint256(); + h.payloadRoot.SetNull(); h.nTime=nTime; h.nBits=0x207fffff; h.nNonce=0; h.hashRandomX.SetNull(); return h; +} + +static bool HasChild(const BlockManager& bm, const CBlockIndex* idx) { + for (const auto& [hash, block] : bm.GetBlockIndex()) { + if (block.pprev == idx) return true; + } + return false; +} + +TEST_CASE("Candidate set invariants across activation and invalidation", "[chain][candidates]") { + auto params = ChainParams::CreateRegTest(); + TestChainstateManager csm(*params); + REQUIRE(csm.Initialize(params->GenesisBlock())); + + const CBlockIndex* g = csm.GetTip(); + + // Add A1 and activate + CBlockHeader A1 = Mkh(g, g->nTime + 120); + ValidationState s; auto* pA1 = csm.AcceptBlockHeader(A1, s); REQUIRE(pA1); + csm.TryAddBlockIndexCandidate(pA1); + REQUIRE(csm.DebugCandidateCount() >= 1); + REQUIRE(csm.ActivateBestChain()); + + // After activation, candidates should be pruned (no tip, no lower-work) + REQUIRE(csm.DebugCandidateCount() == 0); + + // Add competing fork B1 (lower work than current tip) + CBlockHeader B1 = Mkh(g, g->nTime + 130); + auto* pB1 = csm.AcceptBlockHeader(B1, s); REQUIRE(pB1); + csm.TryAddBlockIndexCandidate(pB1); + + // Activate best chain keeps A1 as tip; B1 remains as a lower-work candidate (Core keeps candidates on no-op) + REQUIRE(csm.ActivateBestChain()); + REQUIRE(csm.DebugCandidateCount() >= 1); + + // Extend fork to surpass tip: B2, B3 + CBlockHeader B2 = Mkh(pB1, pB1->nTime + 120); + auto* pB2 = csm.AcceptBlockHeader(B2, s); REQUIRE(pB2); + csm.TryAddBlockIndexCandidate(pB2); + + CBlockHeader B3 = Mkh(pB2, pB2->nTime + 120); + auto* pB3 = csm.AcceptBlockHeader(B3, s); REQUIRE(pB3); + csm.TryAddBlockIndexCandidate(pB3); + + // Before activation, candidate should include B3 (a leaf) + REQUIRE(csm.DebugCandidateCount() >= 1); + + // Activate reorg to B3; candidates pruned again + REQUIRE(csm.ActivateBestChain()); + REQUIRE(csm.DebugCandidateCount() == 0); + + // Invalidate current tip (B3) – should populate candidates without activating + REQUIRE(csm.InvalidateBlock(pB3->GetBlockHash())); + + auto hashes = csm.DebugCandidateHashes(); + REQUIRE_FALSE(hashes.empty()); + + // None of the candidates should be the invalidated block + for (const auto& h : hashes) { + REQUIRE(h != pB3->GetBlockHash()); + } +} + +TEST_CASE("ANCESTOR_FAILED propagation marks descendants", "[chain][candidates][ancestor_failed]") { + auto params = ChainParams::CreateRegTest(); + TestChainstateManager csm(*params); + REQUIRE(csm.Initialize(params->GenesisBlock())); + + const CBlockIndex* g = csm.GetTip(); + + // Build chain: genesis -> A1 -> A2 -> A3 + CBlockHeader A1 = Mkh(g, g->nTime + 120); + ValidationState s; auto* pA1 = csm.AcceptBlockHeader(A1, s); REQUIRE(pA1); + + CBlockHeader A2 = Mkh(pA1, pA1->nTime + 120); + auto* pA2 = csm.AcceptBlockHeader(A2, s); REQUIRE(pA2); + + CBlockHeader A3 = Mkh(pA2, pA2->nTime + 120); + auto* pA3 = csm.AcceptBlockHeader(A3, s); REQUIRE(pA3); + + // Activate the chain + csm.TryAddBlockIndexCandidate(pA3); + REQUIRE(csm.ActivateBestChain()); + REQUIRE(csm.GetTip() == pA3); + + // Invalidate A1 (middle of chain) + REQUIRE(csm.InvalidateBlock(pA1->GetBlockHash())); + + // A1 should be VALIDATION_FAILED + REQUIRE(pA1->status.IsFailed()); + + // A2 and A3 should be ANCESTOR_FAILED + REQUIRE(pA2->status.IsFailed()); + REQUIRE(pA3->status.IsFailed()); + + // Neither A1, A2, nor A3 should be in candidates + auto hashes = csm.DebugCandidateHashes(); + for (const auto& h : hashes) { + REQUIRE(h != pA1->GetBlockHash()); + REQUIRE(h != pA2->GetBlockHash()); + REQUIRE(h != pA3->GetBlockHash()); + } + + // Tip should have rolled back to genesis + REQUIRE(csm.GetTip() == g); +} + +TEST_CASE("Parent re-added as candidate after child invalidation", "[chain][candidates][parent_readd]") { + auto params = ChainParams::CreateRegTest(); + TestChainstateManager csm(*params); + REQUIRE(csm.Initialize(params->GenesisBlock())); + + const CBlockIndex* g = csm.GetTip(); + + // Build chain: genesis -> A1 -> A2 + CBlockHeader A1 = Mkh(g, g->nTime + 120); + ValidationState s; auto* pA1 = csm.AcceptBlockHeader(A1, s); REQUIRE(pA1); + + CBlockHeader A2 = Mkh(pA1, pA1->nTime + 120); + auto* pA2 = csm.AcceptBlockHeader(A2, s); REQUIRE(pA2); + + // Activate the chain to A2 + csm.TryAddBlockIndexCandidate(pA2); + REQUIRE(csm.ActivateBestChain()); + REQUIRE(csm.GetTip() == pA2); + + // Candidates may include lower-work blocks (kept for InvalidateBlock fallback) + // The key invariant is that the tip is correctly activated + + // Invalidate A2 (the tip) + REQUIRE(csm.InvalidateBlock(pA2->GetBlockHash())); + + // A1 should now be the tip + REQUIRE(csm.GetTip() == pA1); + + // A1's parent (genesis) should be re-added to candidates OR + // we should have some candidate available for fallback + // Since A1 is now the tip, candidates may be empty after prune + // The key invariant: we didn't lose track of valid chain state + REQUIRE(csm.GetTip()->IsValid()); +} + +TEST_CASE("Competing fork becomes candidate after main chain invalidation", "[chain][candidates][fork_activation]") { + auto params = ChainParams::CreateRegTest(); + TestChainstateManager csm(*params); + REQUIRE(csm.Initialize(params->GenesisBlock())); + + const CBlockIndex* g = csm.GetTip(); + + // Build main chain: genesis -> A1 -> A2 -> A3 + CBlockHeader A1 = Mkh(g, g->nTime + 120); + ValidationState s; auto* pA1 = csm.AcceptBlockHeader(A1, s); REQUIRE(pA1); + + CBlockHeader A2 = Mkh(pA1, pA1->nTime + 120); + auto* pA2 = csm.AcceptBlockHeader(A2, s); REQUIRE(pA2); + + CBlockHeader A3 = Mkh(pA2, pA2->nTime + 120); + auto* pA3 = csm.AcceptBlockHeader(A3, s); REQUIRE(pA3); + + // Build competing fork: genesis -> B1 -> B2 (shorter but valid) + CBlockHeader B1 = Mkh(g, g->nTime + 130); + auto* pB1 = csm.AcceptBlockHeader(B1, s); REQUIRE(pB1); + + CBlockHeader B2 = Mkh(pB1, pB1->nTime + 120); + auto* pB2 = csm.AcceptBlockHeader(B2, s); REQUIRE(pB2); + + // Activate main chain (A3 has more work) + csm.TryAddBlockIndexCandidate(pA3); + csm.TryAddBlockIndexCandidate(pB2); + REQUIRE(csm.ActivateBestChain()); + REQUIRE(csm.GetTip() == pA3); + + // Invalidate A1 (kills entire main chain) + REQUIRE(csm.InvalidateBlock(pA1->GetBlockHash())); + + // B2 should now be a candidate (it's the best valid chain) + auto hashes = csm.DebugCandidateHashes(); + bool b2_in_candidates = false; + for (const auto& h : hashes) { + if (h == pB2->GetBlockHash()) { + b2_in_candidates = true; + break; + } + } + REQUIRE(b2_in_candidates); + + // Activate best chain should switch to fork B + REQUIRE(csm.ActivateBestChain()); + REQUIRE(csm.GetTip() == pB2); +} + +TEST_CASE("AcceptBlockHeader rejects headers descending from failed block", "[chain][candidates][reject_descendant]") { + auto params = ChainParams::CreateRegTest(); + TestChainstateManager csm(*params); + REQUIRE(csm.Initialize(params->GenesisBlock())); + + const CBlockIndex* g = csm.GetTip(); + + // Build chain: genesis -> A1 + CBlockHeader A1 = Mkh(g, g->nTime + 120); + ValidationState s; auto* pA1 = csm.AcceptBlockHeader(A1, s); REQUIRE(pA1); + + // Activate A1 + csm.TryAddBlockIndexCandidate(pA1); + REQUIRE(csm.ActivateBestChain()); + + // Invalidate A1 + REQUIRE(csm.InvalidateBlock(pA1->GetBlockHash())); + + // Now try to accept A2 which extends the invalidated A1 + CBlockHeader A2 = Mkh(pA1, pA1->nTime + 120); + ValidationState s2; + auto* pA2 = csm.AcceptBlockHeader(A2, s2); + + // Should be rejected because parent is failed + REQUIRE(pA2 == nullptr); + REQUIRE(s2.IsInvalid()); } \ No newline at end of file diff --git a/test/unit/chain/chain_tests.cpp b/test/unit/chain/chain_tests.cpp index 2dc0c7b..d2d1b73 100644 --- a/test/unit/chain/chain_tests.cpp +++ b/test/unit/chain/chain_tests.cpp @@ -16,7 +16,7 @@ TEST_CASE("BlockManager basic operations", "[chain]") { CBlockHeader genesis; genesis.nVersion = 1; genesis.hashPrevBlock.SetNull(); - genesis.minerAddress.SetNull(); + genesis.payloadRoot.SetNull(); genesis.nTime = 1231006505; // genesis.nBits = 0x1d00ffff; genesis.nNonce = 2083236893; @@ -36,7 +36,7 @@ TEST_CASE("BlockManager basic operations", "[chain]") { CBlockHeader block1; block1.nVersion = 1; block1.hashPrevBlock = genesis.GetHash(); - block1.minerAddress.SetNull(); + block1.payloadRoot.SetNull(); block1.nTime = genesis.nTime + 600; block1.nBits = genesis.nBits; block1.nNonce = 123456; @@ -52,7 +52,7 @@ TEST_CASE("BlockManager basic operations", "[chain]") { CBlockHeader block2; block2.nVersion = 1; block2.hashPrevBlock = block1.GetHash(); - block2.minerAddress.SetNull(); + block2.payloadRoot.SetNull(); block2.nTime = block1.nTime + 600; block2.nBits = block1.nBits; block2.nNonce = 789012; diff --git a/test/unit/chain/chainparams_tests.cpp b/test/unit/chain/chainparams_tests.cpp index 2384dc0..778bd46 100644 --- a/test/unit/chain/chainparams_tests.cpp +++ b/test/unit/chain/chainparams_tests.cpp @@ -1,99 +1,99 @@ -// Copyright (c) 2025 The Unicity Foundation -// Test suite for chain parameters - -#include "catch_amalgamated.hpp" -#include "chain/chainparams.hpp" - -using namespace unicity::chain; - -TEST_CASE("ChainParams creation", "[chainparams]") { - SECTION("Create MainNet") { - auto params = ChainParams::CreateMainNet(); - REQUIRE(params != nullptr); - REQUIRE(params->GetChainType() == ChainType::MAIN); - REQUIRE(params->GetChainTypeString() == "main"); - REQUIRE(params->GetDefaultPort() == 9590); - - const auto& consensus = params->GetConsensus(); - REQUIRE(consensus.nPowTargetSpacing == 8640); // 2.4 hours (mainnet) - REQUIRE(consensus.nRandomXEpochDuration == 7 * 24 * 60 * 60); // 1 week - } - - SECTION("Create TestNet") { - auto params = ChainParams::CreateTestNet(); - REQUIRE(params != nullptr); - REQUIRE(params->GetChainType() == ChainType::TESTNET); - REQUIRE(params->GetChainTypeString() == "test"); - REQUIRE(params->GetDefaultPort() == 19590); - } - - SECTION("Create RegTest") { - auto params = ChainParams::CreateRegTest(); - REQUIRE(params != nullptr); - REQUIRE(params->GetChainType() == ChainType::REGTEST); - REQUIRE(params->GetChainTypeString() == "regtest"); - REQUIRE(params->GetDefaultPort() == 29590); - - const auto& consensus = params->GetConsensus(); - // RegTest has easy difficulty for instant mining - } -} - -TEST_CASE("GlobalChainParams singleton", "[chainparams]") { - SECTION("Select and get params") { - // Select mainnet - GlobalChainParams::Select(ChainType::MAIN); - REQUIRE(GlobalChainParams::IsInitialized()); - - const auto& params = GlobalChainParams::Get(); - REQUIRE(params.GetChainType() == ChainType::MAIN); - - // Switch to regtest - GlobalChainParams::Select(ChainType::REGTEST); - const auto& params2 = GlobalChainParams::Get(); - REQUIRE(params2.GetChainType() == ChainType::REGTEST); - } -} - -TEST_CASE("Genesis block creation", "[chainparams]") { - auto params = ChainParams::CreateRegTest(); - const auto& genesis = params->GenesisBlock(); - - SECTION("Genesis block properties") { - REQUIRE(genesis.nVersion == 1); - REQUIRE(genesis.hashPrevBlock.IsNull()); - REQUIRE(genesis.minerAddress.IsNull()); - REQUIRE(genesis.nTime > 0); - REQUIRE(genesis.nBits > 0); - } - - SECTION("Genesis hash") { - uint256 hash = genesis.GetHash(); - REQUIRE(!hash.IsNull()); - - const auto& consensus = params->GetConsensus(); - REQUIRE(consensus.hashGenesisBlock == hash); - } -} - -TEST_CASE("Network magic bytes", "[chainparams]") { - SECTION("Different networks have different magic") { - auto main = ChainParams::CreateMainNet(); - auto test = ChainParams::CreateTestNet(); - auto reg = ChainParams::CreateRegTest(); - - uint32_t mainMagic = main->GetNetworkMagic(); - uint32_t testMagic = test->GetNetworkMagic(); - uint32_t regMagic = reg->GetNetworkMagic(); - - // Check expected values from protocol::magic - REQUIRE(mainMagic == 0x554E4943); // "UNIC" - REQUIRE(testMagic == 0xA3F8D412); - REQUIRE(regMagic == 0x4B7C2E91); - - // All should be different - REQUIRE(mainMagic != testMagic); - REQUIRE(mainMagic != regMagic); - REQUIRE(testMagic != regMagic); - } -} +// Copyright (c) 2025 The Unicity Foundation +// Test suite for chain parameters + +#include "catch_amalgamated.hpp" +#include "chain/chainparams.hpp" + +using namespace unicity::chain; + +TEST_CASE("ChainParams creation", "[chainparams]") { + SECTION("Create MainNet") { + auto params = ChainParams::CreateMainNet(); + REQUIRE(params != nullptr); + REQUIRE(params->GetChainType() == ChainType::MAIN); + REQUIRE(params->GetChainTypeString() == "main"); + REQUIRE(params->GetDefaultPort() == 9590); + + const auto& consensus = params->GetConsensus(); + REQUIRE(consensus.nPowTargetSpacing == 8640); // 2.4 hours (mainnet) + REQUIRE(consensus.nRandomXEpochDuration == 7 * 24 * 60 * 60); // 1 week + } + + SECTION("Create TestNet") { + auto params = ChainParams::CreateTestNet(); + REQUIRE(params != nullptr); + REQUIRE(params->GetChainType() == ChainType::TESTNET); + REQUIRE(params->GetChainTypeString() == "test"); + REQUIRE(params->GetDefaultPort() == 19590); + } + + SECTION("Create RegTest") { + auto params = ChainParams::CreateRegTest(); + REQUIRE(params != nullptr); + REQUIRE(params->GetChainType() == ChainType::REGTEST); + REQUIRE(params->GetChainTypeString() == "regtest"); + REQUIRE(params->GetDefaultPort() == 29590); + + const auto& consensus = params->GetConsensus(); + // RegTest has easy difficulty for instant mining + } +} + +TEST_CASE("GlobalChainParams singleton", "[chainparams]") { + SECTION("Select and get params") { + // Select mainnet + GlobalChainParams::Select(ChainType::MAIN); + REQUIRE(GlobalChainParams::IsInitialized()); + + const auto& params = GlobalChainParams::Get(); + REQUIRE(params.GetChainType() == ChainType::MAIN); + + // Switch to regtest + GlobalChainParams::Select(ChainType::REGTEST); + const auto& params2 = GlobalChainParams::Get(); + REQUIRE(params2.GetChainType() == ChainType::REGTEST); + } +} + +TEST_CASE("Genesis block creation", "[chainparams]") { + auto params = ChainParams::CreateRegTest(); + const auto& genesis = params->GenesisBlock(); + + SECTION("Genesis block properties") { + REQUIRE(genesis.nVersion == 1); + REQUIRE(genesis.hashPrevBlock.IsNull()); + REQUIRE_FALSE(genesis.payloadRoot.IsNull()); + REQUIRE(genesis.nTime > 0); + REQUIRE(genesis.nBits > 0); + } + + SECTION("Genesis hash") { + uint256 hash = genesis.GetHash(); + REQUIRE(!hash.IsNull()); + + const auto& consensus = params->GetConsensus(); + REQUIRE(consensus.hashGenesisBlock == hash); + } +} + +TEST_CASE("Network magic bytes", "[chainparams]") { + SECTION("Different networks have different magic") { + auto main = ChainParams::CreateMainNet(); + auto test = ChainParams::CreateTestNet(); + auto reg = ChainParams::CreateRegTest(); + + uint32_t mainMagic = main->GetNetworkMagic(); + uint32_t testMagic = test->GetNetworkMagic(); + uint32_t regMagic = reg->GetNetworkMagic(); + + // Check expected values from protocol::magic + REQUIRE(mainMagic == 0x554E4943); // "UNIC" + REQUIRE(testMagic == 0xA3F8D412); + REQUIRE(regMagic == 0x4B7C2E91); + + // All should be different + REQUIRE(mainMagic != testMagic); + REQUIRE(mainMagic != regMagic); + REQUIRE(testMagic != regMagic); + } +} diff --git a/test/unit/chain/chainstate_manager_tests.cpp b/test/unit/chain/chainstate_manager_tests.cpp index 294e2dd..6ef2919 100644 --- a/test/unit/chain/chainstate_manager_tests.cpp +++ b/test/unit/chain/chainstate_manager_tests.cpp @@ -16,16 +16,19 @@ #include "catch_amalgamated.hpp" #include "chain/chainstate_manager.hpp" +#include "common/mock_trust_base_manager.hpp" #include "chain/active_tip_candidates.hpp" #include "chain/block_manager.hpp" #include "chain/chainparams.hpp" #include "chain/block.hpp" #include "chain/block_index.hpp" +#include "chain/trust_base.hpp" #include "chain/chain.hpp" #include "chain/pow.hpp" #include "chain/randomx_pow.hpp" #include "chain/validation.hpp" #include "chain/notifications.hpp" +#include "util/hash.hpp" #include "util/time.hpp" #include #include @@ -46,11 +49,8 @@ using namespace unicity::chain; class TestChainstateManager : public ChainstateManager { public: explicit TestChainstateManager(const ChainParams& params) - : ChainstateManager(params), bypass_pow_validation_(true) { - if (params.GetChainType() == ChainType::REGTEST) { - TestSetSkipPoWChecks(true); - } - } + : TestChainstateManager(params, std::make_unique()) + {} // Control validation bypass void SetBypassPoW(bool bypass) { @@ -100,7 +100,17 @@ class TestChainstateManager : public ChainstateManager { } private: - bool bypass_pow_validation_{true}; + TestChainstateManager(const ChainParams& params, std::unique_ptr tbm) + : ChainstateManager(params, *tbm), + mock_tbm_(std::move(tbm)), + bypass_pow_validation_(true) { + if (params.GetChainType() == ChainType::REGTEST) { + TestSetSkipPoWChecks(true); + } + } + + std::unique_ptr mock_tbm_; + bool bypass_pow_validation_; bool bypass_contextual_{true}; bool pow_check_result_{true}; bool block_header_check_result_{true}; @@ -112,7 +122,19 @@ static CBlockHeader CreateTestHeader(uint32_t nTime = 1234567890, uint32_t nBits CBlockHeader header; header.nVersion = 1; header.hashPrevBlock.SetNull(); - header.minerAddress.SetNull(); + + uint256 token_id; + token_id.SetHex("0000000000000000000000000000000000000000000000000000000000000001"); + uint256 leaf_0 = SingleHash(token_id); + + // Use genesis UTB (epoch 1) + auto params = ChainParams::CreateRegTest(); + auto utb_bytes = params->GenesisBlock().GetUTB(); + uint256 leaf_1 = SingleHash(utb_bytes); + header.payloadRoot = CBlockHeader::ComputePayloadRoot(leaf_0, leaf_1); + header.vPayload.assign(leaf_0.begin(), leaf_0.end()); + header.vPayload.insert(header.vPayload.end(), utb_bytes.begin(), utb_bytes.end()); + header.nTime = nTime; header.nBits = nBits; header.nNonce = 0; @@ -132,7 +154,14 @@ static CBlockHeader MakeChild(const CBlockIndex* prev, uint32_t nTime, uint32_t CBlockHeader h; h.nVersion = 1; h.hashPrevBlock = prev ? prev->GetBlockHash() : uint256(); - h.minerAddress.SetNull(); + + uint256 token_id; + token_id.SetHex("0000000000000000000000000000000000000000000000000000000000000001"); + uint256 leaf_0 = SingleHash(token_id); + uint256 leaf_1 = uint256::ZERO; + h.payloadRoot = CBlockHeader::ComputePayloadRoot(leaf_0, leaf_1); + h.vPayload.assign(leaf_0.begin(), leaf_0.end()); + h.nTime = nTime; h.nBits = nBits; h.nNonce = 0; @@ -145,7 +174,14 @@ static CBlockHeader MineChild(const CBlockIndex* prev, const ChainParams& params CBlockHeader h; h.nVersion = 1; h.hashPrevBlock = prev ? prev->GetBlockHash() : uint256(); - h.minerAddress.SetNull(); + + uint256 token_id; + token_id.SetHex("0000000000000000000000000000000000000000000000000000000000000001"); + uint256 leaf_0 = SingleHash(token_id); + uint256 leaf_1 = uint256::ZERO; + h.payloadRoot = CBlockHeader::ComputePayloadRoot(leaf_0, leaf_1); + h.vPayload.assign(leaf_0.begin(), leaf_0.end()); + h.nTime = nTime; h.nBits = consensus::GetNextWorkRequired(prev, params); h.nNonce = 0; @@ -504,7 +540,8 @@ TEST_CASE("Chainstate Load hardening: recompute ignores tampered chainwork", "[c auto params = ChainParams::CreateRegTest(); - ChainstateManager csm(*params); + test::MockTrustBaseManager mock_tbm; + ChainstateManager csm(*params, mock_tbm); REQUIRE(csm.Initialize(params->GenesisBlock())); const CBlockIndex* tip = csm.GetTip(); @@ -545,7 +582,8 @@ TEST_CASE("Chainstate Load hardening: recompute ignores tampered chainwork", "[c out.close(); } - ChainstateManager csm2(*params); + test::MockTrustBaseManager mock_tbm2; + ChainstateManager csm2(*params, mock_tbm2); REQUIRE(csm2.Load(tmp_path.string(), true) == chain::LoadResult::SUCCESS); REQUIRE(csm2.ActivateBestChain(nullptr)); @@ -562,7 +600,8 @@ TEST_CASE("Chainstate Load: trust mode marks all blocks valid", "[chain][chainst auto params = ChainParams::CreateRegTest(); - ChainstateManager csm(*params); + test::MockTrustBaseManager mock_tbm; + ChainstateManager csm(*params, mock_tbm); REQUIRE(csm.Initialize(params->GenesisBlock())); const CBlockIndex* tip = csm.GetTip(); @@ -577,7 +616,8 @@ TEST_CASE("Chainstate Load: trust mode marks all blocks valid", "[chain][chainst const std::filesystem::path tmp_path = std::filesystem::temp_directory_path() / "trust_mode_test.json"; REQUIRE(csm.Save(tmp_path.string())); - ChainstateManager csm2(*params); + test::MockTrustBaseManager mock_tbm2; + ChainstateManager csm2(*params, mock_tbm2); REQUIRE(csm2.Load(tmp_path.string(), false) == chain::LoadResult::SUCCESS); csm2.ActivateBestChain(nullptr); @@ -600,7 +640,8 @@ TEST_CASE("Chainstate Load: round-trip preserves fork structure", "[chain][chain auto params = ChainParams::CreateRegTest(); - ChainstateManager csm(*params); + test::MockTrustBaseManager mock_tbm; + ChainstateManager csm(*params, mock_tbm); REQUIRE(csm.Initialize(params->GenesisBlock())); const CBlockIndex* genesis = csm.GetTip(); @@ -622,7 +663,8 @@ TEST_CASE("Chainstate Load: round-trip preserves fork structure", "[chain][chain const std::filesystem::path tmp_path = std::filesystem::temp_directory_path() / "fork_roundtrip_test.json"; REQUIRE(csm.Save(tmp_path.string())); - ChainstateManager csm2(*params); + test::MockTrustBaseManager mock_tbm2; + ChainstateManager csm2(*params, mock_tbm2); REQUIRE(csm2.Load(tmp_path.string(), true) == chain::LoadResult::SUCCESS); csm2.ActivateBestChain(nullptr); @@ -643,7 +685,8 @@ TEST_CASE("Chainstate Load: revalidate fails on corrupted block", "[chain][chain auto params = ChainParams::CreateRegTest(); - ChainstateManager csm(*params); + test::MockTrustBaseManager mock_tbm; + ChainstateManager csm(*params, mock_tbm); REQUIRE(csm.Initialize(params->GenesisBlock())); const CBlockIndex* tip = csm.GetTip(); @@ -675,7 +718,7 @@ TEST_CASE("Chainstate Load: revalidate fails on corrupted block", "[chain][chain CBlockHeader h; h.nVersion = blk["version"].get(); h.hashPrevBlock.SetHex(blk["prev_hash"].get()); - h.minerAddress.SetHex(blk["miner_address"].get()); + h.payloadRoot.SetHex(blk["payload_root"].get()); h.nTime = blk["time"].get(); h.nBits = blk["bits"].get(); h.nNonce = blk["nonce"].get(); @@ -693,7 +736,7 @@ TEST_CASE("Chainstate Load: revalidate fails on corrupted block", "[chain][chain CBlockHeader h; h.nVersion = blk["version"].get(); h.hashPrevBlock = new_A_hash; - h.minerAddress.SetHex(blk["miner_address"].get()); + h.payloadRoot.SetHex(blk["payload_root"].get()); h.nTime = blk["time"].get(); h.nBits = blk["bits"].get(); h.nNonce = blk["nonce"].get(); @@ -712,7 +755,8 @@ TEST_CASE("Chainstate Load: revalidate fails on corrupted block", "[chain][chain out << root.dump(2); } - ChainstateManager csm2(*params); + test::MockTrustBaseManager mock_tbm2; + ChainstateManager csm2(*params, mock_tbm2); REQUIRE(csm2.Load(tmp_path.string(), true) == chain::LoadResult::CORRUPTED); std::filesystem::remove(tmp_path); @@ -933,7 +977,7 @@ TEST_CASE("CChain::FindFork returns correct fork point", "[chain][findfork]") { CBlockHeader g; g.nVersion = 1; g.hashPrevBlock.SetNull(); - g.minerAddress.SetNull(); + g.payloadRoot.SetNull(); g.nTime = 1000; g.nBits = 0x207fffff; g.nNonce = 0; @@ -1252,7 +1296,7 @@ class SmallExpireParams : public ChainParams { consensus.nNetworkExpirationGracePeriod = 1; consensus.nSuspiciousReorgDepth = 100; nDefaultPort = 29590; - genesis = CreateGenesisBlock(1296688602, 2, 0x207fffff, 1); + genesis = CreateGenesisBlock(1296688602, 2, 0x207fffff, GlobalChainParams::Get().GenesisBlock().GetUTB(), 1); consensus.hashGenesisBlock = genesis.GetHash(); } }; @@ -1374,7 +1418,8 @@ TEST_CASE("TestSetSkipPoWChecks - Network type guard", "[chain][chainstate_manag SECTION("Rejects PoW skip in MAINNET mode") { auto params = ChainParams::CreateMainNet(); - ChainstateManager csm(*params); + test::MockTrustBaseManager mock_tbm; + ChainstateManager csm(*params, mock_tbm); REQUIRE(csm.Initialize(params->GenesisBlock())); REQUIRE_THROWS_AS(csm.TestSetSkipPoWChecks(true), std::runtime_error); @@ -1383,7 +1428,8 @@ TEST_CASE("TestSetSkipPoWChecks - Network type guard", "[chain][chainstate_manag SECTION("Rejects PoW skip in TESTNET mode") { auto params = ChainParams::CreateTestNet(); - ChainstateManager csm(*params); + test::MockTrustBaseManager mock_tbm; + ChainstateManager csm(*params, mock_tbm); REQUIRE(csm.Initialize(params->GenesisBlock())); REQUIRE_THROWS_AS(csm.TestSetSkipPoWChecks(true), std::runtime_error); @@ -1392,7 +1438,8 @@ TEST_CASE("TestSetSkipPoWChecks - Network type guard", "[chain][chainstate_manag SECTION("Exception contains descriptive message") { auto params = ChainParams::CreateMainNet(); - ChainstateManager csm(*params); + test::MockTrustBaseManager mock_tbm; + ChainstateManager csm(*params, mock_tbm); REQUIRE(csm.Initialize(params->GenesisBlock())); try { @@ -1740,7 +1787,9 @@ TEST_CASE("ActiveTipCandidates - Edge Cases", "[chain][active_tip_candidates][un // Test subclass for GetChainTips that bypasses PoW validation class TestChainstateManagerForTips : public ChainstateManager { public: - explicit TestChainstateManagerForTips(const ChainParams& params) : ChainstateManager(params) {} + explicit TestChainstateManagerForTips(const ChainParams& params) + : TestChainstateManagerForTips(params, std::make_unique()) + {} protected: bool CheckProofOfWork(const CBlockHeader& /*header*/, crypto::POWVerifyMode /*mode*/) const override { return true; } @@ -1753,6 +1802,14 @@ class TestChainstateManagerForTips : public ChainstateManager { int64_t /*adjusted_time*/, ValidationState& /*state*/) const override { return true; } + +private: + TestChainstateManagerForTips(const ChainParams& params, std::unique_ptr tbm) + : ChainstateManager(params, *tbm), + mock_tbm_(std::move(tbm)) + {} + + std::unique_ptr mock_tbm_; }; // Helper: Create a block header for GetChainTips tests @@ -1760,7 +1817,14 @@ static CBlockHeader CreateTipsTestHeader(uint32_t nTime = 1234567890, uint32_t n CBlockHeader header; header.nVersion = 1; header.hashPrevBlock.SetNull(); - header.minerAddress.SetNull(); + + uint256 token_id; + token_id.SetHex("0000000000000000000000000000000000000000000000000000000000000001"); + uint256 leaf_0 = SingleHash(token_id); + uint256 leaf_1 = uint256::ZERO; + header.payloadRoot = CBlockHeader::ComputePayloadRoot(leaf_0, leaf_1); + header.vPayload.assign(leaf_0.begin(), leaf_0.end()); + header.nTime = nTime; header.nBits = nBits; header.nNonce = 0; diff --git a/test/unit/chain/miner_tests.cpp b/test/unit/chain/miner_tests.cpp index 19a3449..cc99f76 100644 --- a/test/unit/chain/miner_tests.cpp +++ b/test/unit/chain/miner_tests.cpp @@ -9,12 +9,16 @@ #include "catch_amalgamated.hpp" #include "chain/miner.hpp" +#include "chain/token_manager.hpp" +#include "chain/trust_base_manager.hpp" #include "chain/chainparams.hpp" #include "chain/chainstate_manager.hpp" #include "chain/block.hpp" #include "chain/validation.hpp" #include "common/test_chainstate_manager.hpp" +#include "common/mock_bft_client.hpp" #include "util/uint.hpp" +#include "util/hash.hpp" #include using namespace unicity; @@ -32,212 +36,39 @@ class MinerTestFixture { MinerTestFixture() { GlobalChainParams::Select(ChainType::REGTEST); params = &GlobalChainParams::Get(); - chainstate = std::make_unique(*params); - miner = std::make_unique(*params, *chainstate); + + test_dir = std::filesystem::temp_directory_path() / "unicity_miner_test_XXXXXX"; + char dir_template[256]; + std::strncpy(dir_template, test_dir.string().c_str(), sizeof(dir_template)); + if (mkdtemp(dir_template)) { + test_dir = dir_template; + } + + tbm = std::make_unique(test_dir, std::make_shared()); + chainstate = std::make_unique(*params, *tbm); + + token_manager = std::make_unique(test_dir, *chainstate); miner = std::make_unique(*params, *chainstate, *tbm, *token_manager); } ~MinerTestFixture() { if (miner && miner->IsMining()) { miner->Stop(); } + if (!test_dir.empty() && std::filesystem::exists(test_dir)) { + std::filesystem::remove_all(test_dir); + } } const ChainParams* params; std::unique_ptr chainstate; + std::unique_ptr tbm; + std::unique_ptr token_manager; std::unique_ptr miner; + std::filesystem::path test_dir; }; // ============================================================================= -// Section 1: Mining Address -// ============================================================================= - -TEST_CASE("CPUMiner - Mining address management", "[miner]") { - MinerTestFixture fixture; - - SECTION("Default mining address is null (zero)") { - uint160 default_addr = fixture.miner->GetMiningAddress(); - REQUIRE(default_addr.IsNull()); - REQUIRE(default_addr == uint160()); - } - - SECTION("SetMiningAddress stores the address") { - uint160 test_addr; - test_addr.SetHex("1234567890abcdef1234567890abcdef12345678"); - - fixture.miner->SetMiningAddress(test_addr); - - uint160 retrieved = fixture.miner->GetMiningAddress(); - REQUIRE(retrieved == test_addr); - REQUIRE(retrieved.GetHex() == "1234567890abcdef1234567890abcdef12345678"); - } - - SECTION("Mining address persists across multiple set/get calls") { - uint160 addr1; - addr1.SetHex("aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"); - - fixture.miner->SetMiningAddress(addr1); - REQUIRE(fixture.miner->GetMiningAddress() == addr1); - REQUIRE(fixture.miner->GetMiningAddress() == addr1); - - uint160 addr2; - addr2.SetHex("bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"); - fixture.miner->SetMiningAddress(addr2); - - REQUIRE(fixture.miner->GetMiningAddress() == addr2); - REQUIRE(fixture.miner->GetMiningAddress() != addr1); - } - - SECTION("Can set address to null (zero)") { - uint160 test_addr; - test_addr.SetHex("1234567890abcdef1234567890abcdef12345678"); - fixture.miner->SetMiningAddress(test_addr); - REQUIRE(!fixture.miner->GetMiningAddress().IsNull()); - - uint160 null_addr; - null_addr.SetNull(); - fixture.miner->SetMiningAddress(null_addr); - - REQUIRE(fixture.miner->GetMiningAddress().IsNull()); - } - - SECTION("Different address formats are preserved correctly") { - uint160 zeros; - zeros.SetHex("0000000000000000000000000000000000000000"); - fixture.miner->SetMiningAddress(zeros); - REQUIRE(fixture.miner->GetMiningAddress() == zeros); - - uint160 ones; - ones.SetHex("ffffffffffffffffffffffffffffffffffffffff"); - fixture.miner->SetMiningAddress(ones); - REQUIRE(fixture.miner->GetMiningAddress() == ones); - - uint160 mixed; - mixed.SetHex("0123456789abcdef0123456789abcdef01234567"); - fixture.miner->SetMiningAddress(mixed); - REQUIRE(fixture.miner->GetMiningAddress() == mixed); - } -} - -TEST_CASE("CPUMiner - Address validation scenarios", "[miner]") { - MinerTestFixture fixture; - - SECTION("Valid 40-character hex address") { - uint160 addr; - addr.SetHex("1234567890abcdef1234567890abcdef12345678"); - - fixture.miner->SetMiningAddress(addr); - REQUIRE(fixture.miner->GetMiningAddress().GetHex() == "1234567890abcdef1234567890abcdef12345678"); - } - - SECTION("Address with uppercase hex characters") { - uint160 addr; - addr.SetHex("1234567890ABCDEF1234567890ABCDEF12345678"); - - fixture.miner->SetMiningAddress(addr); - REQUIRE(fixture.miner->GetMiningAddress().GetHex() == "1234567890abcdef1234567890abcdef12345678"); - } - - SECTION("Address with mixed case") { - uint160 addr; - addr.SetHex("1234567890AbCdEf1234567890aBcDeF12345678"); - - fixture.miner->SetMiningAddress(addr); - REQUIRE(fixture.miner->GetMiningAddress().GetHex() == "1234567890abcdef1234567890abcdef12345678"); - } -} - -TEST_CASE("CPUMiner - Mining address sticky behavior", "[miner]") { - MinerTestFixture fixture; - - SECTION("Address persists without explicit reset") { - uint160 addr1; - addr1.SetHex("1111111111111111111111111111111111111111"); - fixture.miner->SetMiningAddress(addr1); - - REQUIRE(fixture.miner->GetMiningAddress() == addr1); - REQUIRE(fixture.miner->GetMiningAddress() == addr1); - REQUIRE(fixture.miner->GetMiningAddress() == addr1); - } - - SECTION("Address changes only when explicitly set") { - uint160 addr1; - addr1.SetHex("aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"); - - uint160 addr2; - addr2.SetHex("bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"); - - fixture.miner->SetMiningAddress(addr1); - REQUIRE(fixture.miner->GetMiningAddress() == addr1); - - REQUIRE(fixture.miner->GetMiningAddress() == addr1); - - fixture.miner->SetMiningAddress(addr2); - REQUIRE(fixture.miner->GetMiningAddress() == addr2); - REQUIRE(fixture.miner->GetMiningAddress() != addr1); - } - - SECTION("Address survives across mining operations") { - uint160 test_addr; - test_addr.SetHex("9999999999999999999999999999999999999999"); - - fixture.miner->SetMiningAddress(test_addr); - REQUIRE(fixture.miner->GetMiningAddress() == test_addr); - - REQUIRE(fixture.miner->GetMiningAddress() == test_addr); - - bool is_mining = fixture.miner->IsMining(); - REQUIRE(!is_mining); - REQUIRE(fixture.miner->GetMiningAddress() == test_addr); - } -} - -TEST_CASE("CPUMiner - Address format edge cases", "[miner]") { - MinerTestFixture fixture; - - SECTION("Leading zeros preserved in address") { - uint160 addr; - addr.SetHex("0000000000000000000000000000000012345678"); - - fixture.miner->SetMiningAddress(addr); - - std::string hex = fixture.miner->GetMiningAddress().GetHex(); - REQUIRE(hex == "0000000000000000000000000000000012345678"); - REQUIRE(hex.length() == 40); - } - - SECTION("Trailing zeros preserved in address") { - uint160 addr; - addr.SetHex("1234567800000000000000000000000000000000"); - - fixture.miner->SetMiningAddress(addr); - - std::string hex = fixture.miner->GetMiningAddress().GetHex(); - REQUIRE(hex == "1234567800000000000000000000000000000000"); - REQUIRE(hex.length() == 40); - } - - SECTION("All zeros is a valid address") { - uint160 addr; - addr.SetHex("0000000000000000000000000000000000000000"); - - fixture.miner->SetMiningAddress(addr); - - REQUIRE(fixture.miner->GetMiningAddress().IsNull()); - REQUIRE(fixture.miner->GetMiningAddress().GetHex() == "0000000000000000000000000000000000000000"); - } - - SECTION("Maximum value address (all F's)") { - uint160 addr; - addr.SetHex("ffffffffffffffffffffffffffffffffffffffff"); - - fixture.miner->SetMiningAddress(addr); - - REQUIRE(fixture.miner->GetMiningAddress().GetHex() == "ffffffffffffffffffffffffffffffffffffffff"); - } -} - -// ============================================================================= -// Section 2: Initial State +// Section 1: Initial State // ============================================================================= TEST_CASE("CPUMiner - Initial state", "[miner]") { @@ -253,13 +84,10 @@ TEST_CASE("CPUMiner - Initial state", "[miner]") { REQUIRE(fixture.miner->GetHashrate() == 0.0); } - SECTION("Initial address is null") { - REQUIRE(fixture.miner->GetMiningAddress().IsNull()); - } } // ============================================================================= -// Section 3: Start/Stop +// Section 2: Start/Stop // ============================================================================= TEST_CASE("CPUMiner - Start/Stop and idempotency", "[miner]") { @@ -267,7 +95,13 @@ TEST_CASE("CPUMiner - Start/Stop and idempotency", "[miner]") { TestChainstateManager csm(*params); REQUIRE(csm.Initialize(params->GenesisBlock())); - mining::CPUMiner miner(*params, csm); + std::filesystem::path test_dir = std::filesystem::temp_directory_path() / "unicity_miner_test_XXXXXX"; + char dir_template[256]; + std::strncpy(dir_template, test_dir.string().c_str(), sizeof(dir_template)); + + LocalTrustBaseManager tbm(test_dir, std::make_shared()); + TokenManager token_manager(test_dir, csm); + CPUMiner miner(*params, csm, tbm, token_manager); SECTION("Start spawns worker and Stop joins") { REQUIRE(miner.Start(/*target_height=*/-1)); @@ -283,10 +117,14 @@ TEST_CASE("CPUMiner - Start/Stop and idempotency", "[miner]") { miner.Stop(); REQUIRE_FALSE(miner.IsMining()); } + + if (std::filesystem::exists(test_dir)) { + std::filesystem::remove_all(test_dir); + } } // ============================================================================= -// Section 4: Block Template +// Section 3: Block Template // ============================================================================= TEST_CASE("CPUMiner - DebugCreateBlockTemplate and DebugShouldRegenerateTemplate", "[miner]") { @@ -294,7 +132,16 @@ TEST_CASE("CPUMiner - DebugCreateBlockTemplate and DebugShouldRegenerateTemplate TestChainstateManager csm(*params); REQUIRE(csm.Initialize(params->GenesisBlock())); - mining::CPUMiner miner(*params, csm); + std::filesystem::path test_dir = std::filesystem::temp_directory_path() / "unicity_miner_test_XXXXXX"; + char dir_template[256]; + std::strncpy(dir_template, test_dir.string().c_str(), sizeof(dir_template)); + if (mkdtemp(dir_template)) { + test_dir = dir_template; + } + + LocalTrustBaseManager tbm(test_dir, std::make_shared()); + TokenManager token_manager(test_dir, csm); + CPUMiner miner(*params, csm, tbm, token_manager); SECTION("Template reflects tip and MTP constraint") { auto tmpl1 = miner.DebugCreateBlockTemplate(); @@ -305,7 +152,15 @@ TEST_CASE("CPUMiner - DebugCreateBlockTemplate and DebugShouldRegenerateTemplate CBlockHeader h; h.nVersion = 1; h.hashPrevBlock = params->GenesisBlock().GetHash(); - h.minerAddress = uint160(); + + uint256 token_id; + token_id.SetHex("0000000000000000000000000000000000000000000000000000000000000001"); + uint256 token_hash = Hash(std::span(token_id.begin(), token_id.size())); + uint256 leaf_0 = token_hash; + uint256 leaf_1 = uint256::ZERO; + h.payloadRoot = CBlockHeader::ComputePayloadRoot(leaf_0, leaf_1); + h.vPayload.assign(token_hash.begin(), token_hash.end()); + h.nTime = tmpl1.header.nTime + 120; h.nBits = tmpl1.nBits; h.nNonce = 0; @@ -326,4 +181,8 @@ TEST_CASE("CPUMiner - DebugCreateBlockTemplate and DebugShouldRegenerateTemplate REQUIRE(miner.DebugShouldRegenerateTemplate(tmpl.hashPrevBlock)); REQUIRE_FALSE(miner.DebugShouldRegenerateTemplate(tmpl.hashPrevBlock)); } + + if (std::filesystem::exists(test_dir)) { + std::filesystem::remove_all(test_dir); + } } diff --git a/test/unit/chain/notifications_tests.cpp b/test/unit/chain/notifications_tests.cpp index 51f4d3b..08f625a 100644 --- a/test/unit/chain/notifications_tests.cpp +++ b/test/unit/chain/notifications_tests.cpp @@ -1,753 +1,764 @@ -// Copyright (c) 2025 The Unicity Foundation -// Distributed under the MIT software license -// Tests for blockchain notification system - -#include "catch_amalgamated.hpp" -#include "chain/validation.hpp" -#include "common/test_chainstate_manager.hpp" -#include "chain/chainstate_manager.hpp" -#include "chain/chainparams.hpp" -#include "chain/chain.hpp" -#include "chain/block_index.hpp" -#include "chain/block.hpp" -#include "chain/notifications.hpp" -#include "chain/miner.hpp" -#include "util/logging.hpp" -#include "util/time.hpp" -#include -#include -#include -#include - -using namespace unicity; -using namespace unicity::test; -using namespace unicity::chain; -using unicity::validation::ValidationState; - -// Test helper: Create a block header with specified parent and time -static CBlockHeader CreateTestHeader(const uint256& hashPrevBlock, - uint32_t nTime, - uint32_t nBits = 0x207fffff) { - CBlockHeader header; - header.nVersion = 1; - header.hashPrevBlock = hashPrevBlock; - header.minerAddress.SetNull(); - header.nTime = nTime; - header.nBits = nBits; - header.nNonce = 0; - header.hashRandomX.SetNull(); // Valid PoW placeholder (test bypasses validation) - return header; -} - -// Test helper: Build a chain of N blocks from a parent -static std::vector BuildChain(const uint256& parent_hash, - uint32_t start_time, - int count, - uint32_t nBits = 0x207fffff) { - std::vector chain; - uint256 prev_hash = parent_hash; - uint32_t time = start_time; - - for (int i = 0; i < count; i++) { - auto header = CreateTestHeader(prev_hash, time, nBits); - chain.push_back(header); - prev_hash = header.GetHash(); - time += 120; // 2-minute blocks - } - - return chain; -} - -TEST_CASE("Notifications - FatalError notification emitted on deep reorg", "[notifications][reorg]") { - // Test that NotifyFatalError is called when reorg exceeds threshold - // This ensures the notification system properly alerts subscribers - - auto params = ChainParams::CreateRegTest(); - // Set suspicious_reorg_depth=7 (allow up to depth 6, reject depth 7+) - params->SetSuspiciousReorgDepth(7); - TestChainstateManager chainstate(*params); - chainstate.Initialize(params->GenesisBlock()); - - const auto& genesis = params->GenesisBlock(); - validation::ValidationState state; - - // Track notification - bool notification_received = false; - std::string debug_msg; - std::string user_msg; - - // Subscribe to fatal error notifications - auto sub = Notifications().SubscribeFatalError( - [&](const std::string& debug_message, const std::string& user_message) { - notification_received = true; - debug_msg = debug_message; - user_msg = user_message; - }); - - // Build initial chain: Genesis -> [7 blocks] - auto chainMain = BuildChain(genesis.GetHash(), util::GetTime(), 7); - chain::CBlockIndex* mainTip = nullptr; - - for (const auto& header : chainMain) { -mainTip = chainstate.AcceptBlockHeader(header, state); - if (mainTip) chainstate.TryAddBlockIndexCandidate(mainTip); - REQUIRE(mainTip != nullptr); - } - - chainstate.ActivateBestChain(); - REQUIRE(chainstate.GetTip() == mainTip); - REQUIRE(chainstate.GetTip()->nHeight == 7); - - // Build competing fork: Genesis -> [8 blocks] (more work, but requires depth-7 reorg) - auto chainFork = BuildChain(genesis.GetHash(), util::GetTime() + 1000, 8); - chain::CBlockIndex* forkTip = nullptr; - - for (const auto& header : chainFork) { -forkTip = chainstate.AcceptBlockHeader(header, state); - if (forkTip) chainstate.TryAddBlockIndexCandidate(forkTip); - REQUIRE(forkTip != nullptr); - } - - chainstate.ActivateBestChain(); - - // Verify notification was emitted - REQUIRE(notification_received); - REQUIRE(debug_msg.find("7 blocks") != std::string::npos); - REQUIRE(user_msg.find("suspicious-reorg-depth") != std::string::npos); - - // Should REJECT reorg (depth 7 >= suspicious_reorg_depth=7) - REQUIRE(chainstate.GetTip() == mainTip); - REQUIRE(chainstate.GetTip()->nHeight == 7); -} - -TEST_CASE("Notifications - FatalError not emitted on allowed reorg", "[notifications][reorg]") { - // Test that notification is NOT emitted for reorgs within threshold - // This ensures we don't spam notifications for normal reorgs - - auto params = ChainParams::CreateRegTest(); - // Set suspicious_reorg_depth=7 (allow up to depth 6, reject depth 7+) - params->SetSuspiciousReorgDepth(7); - TestChainstateManager chainstate(*params); - chainstate.Initialize(params->GenesisBlock()); - - const auto& genesis = params->GenesisBlock(); - validation::ValidationState state; - - // Track notification - bool notification_received = false; - - // Subscribe to fatal error notifications - auto sub = Notifications().SubscribeFatalError( - [&](const std::string&, const std::string&) { - notification_received = true; - }); - - // Build initial chain: Genesis -> [5 blocks] - auto chainMain = BuildChain(genesis.GetHash(), util::GetTime(), 5); - chain::CBlockIndex* mainTip = nullptr; - - for (const auto& header : chainMain) { - mainTip = chainstate.AcceptBlockHeader(header, state); - if (mainTip) chainstate.TryAddBlockIndexCandidate(mainTip); - REQUIRE(mainTip != nullptr); - } - - chainstate.ActivateBestChain(); - REQUIRE(chainstate.GetTip() == mainTip); - - // Build competing fork: Genesis -> [6 blocks] (requires depth-5 reorg, which is allowed) - auto chainFork = BuildChain(genesis.GetHash(), util::GetTime() + 1000, 6); - chain::CBlockIndex* forkTip = nullptr; - - for (const auto& header : chainFork) { - forkTip = chainstate.AcceptBlockHeader(header, state); - if (forkTip) chainstate.TryAddBlockIndexCandidate(forkTip); - REQUIRE(forkTip != nullptr); - } - - chainstate.ActivateBestChain(); - - // Verify notification was NOT emitted (reorg depth 5 < 7) - REQUIRE_FALSE(notification_received); - - // Should ACCEPT reorg (depth 5 < suspicious_reorg_depth=7) - REQUIRE(chainstate.GetTip() == forkTip); - REQUIRE(chainstate.GetTip()->nHeight == 6); -} - -TEST_CASE("Notifications - Multiple subscribers receive SuspiciousReorg notification", "[notifications][reorg]") { - // Test that all subscribers receive the notification - // This ensures the notification system properly broadcasts to all listeners - - auto params = ChainParams::CreateRegTest(); - params->SetSuspiciousReorgDepth(5); - TestChainstateManager chainstate(*params); - chainstate.Initialize(params->GenesisBlock()); - - const auto& genesis = params->GenesisBlock(); - validation::ValidationState state; - - // Track notifications for multiple subscribers - bool sub1_received = false; - bool sub2_received = false; - bool sub3_received = false; - - // Subscribe multiple listeners - auto sub1 = Notifications().SubscribeFatalError([&](const std::string&, const std::string&) { sub1_received = true; }); - auto sub2 = Notifications().SubscribeFatalError([&](const std::string&, const std::string&) { sub2_received = true; }); - auto sub3 = Notifications().SubscribeFatalError([&](const std::string&, const std::string&) { sub3_received = true; }); - - // Build initial chain: Genesis -> [5 blocks] - auto chainMain = BuildChain(genesis.GetHash(), util::GetTime(), 5); - chain::CBlockIndex* mainTip = nullptr; - - for (const auto& header : chainMain) { - mainTip = chainstate.AcceptBlockHeader(header, state); - if (mainTip) chainstate.TryAddBlockIndexCandidate(mainTip); - REQUIRE(mainTip != nullptr); - } - - chainstate.ActivateBestChain(); - - // Build competing fork that triggers suspicious reorg - auto chainFork = BuildChain(genesis.GetHash(), util::GetTime() + 1000, 6); - chain::CBlockIndex* forkTip = nullptr; - - for (const auto& header : chainFork) { - forkTip = chainstate.AcceptBlockHeader(header, state); - if (forkTip) chainstate.TryAddBlockIndexCandidate(forkTip); - REQUIRE(forkTip != nullptr); - } - - chainstate.ActivateBestChain(); - - // Verify all subscribers received notification - REQUIRE(sub1_received); - REQUIRE(sub2_received); - REQUIRE(sub3_received); -} - -TEST_CASE("Notifications - ChainTip notification emitted on tip change", "[notifications][chain]") { - // Test that NotifyChainTip is called when the chain tip changes - // This is critical for miner template invalidation - - auto params = ChainParams::CreateRegTest(); - TestChainstateManager chainstate(*params); - chainstate.Initialize(params->GenesisBlock()); - - const auto& genesis = params->GenesisBlock(); - validation::ValidationState state; - - // Track notifications - int tip_change_count = 0; - uint256 last_tip_hash; - int last_height = -1; - - // Subscribe to chain tip notifications - auto sub = Notifications().SubscribeChainTip( - [&](const ChainTipEvent& event) { - tip_change_count++; - last_tip_hash = event.hash; - last_height = event.height; - }); - - // Add first block: Genesis -> A - auto headerA = CreateTestHeader(genesis.GetHash(), util::GetTime()); - chain::CBlockIndex* pindexA = chainstate.AcceptBlockHeader(headerA, state); - chainstate.TryAddBlockIndexCandidate(pindexA); - REQUIRE(pindexA != nullptr); - - chainstate.ActivateBestChain(); - - // Verify first tip change notification - REQUIRE(tip_change_count == 1); - REQUIRE(last_tip_hash == pindexA->GetBlockHash()); - REQUIRE(last_height == 1); - - // Add second block: A -> B - auto headerB = CreateTestHeader(headerA.GetHash(), util::GetTime() + 120); - chain::CBlockIndex* pindexB = chainstate.AcceptBlockHeader(headerB, state); - chainstate.TryAddBlockIndexCandidate(pindexB); - REQUIRE(pindexB != nullptr); - - chainstate.ActivateBestChain(); - - // Verify second tip change notification - REQUIRE(tip_change_count == 2); - REQUIRE(last_tip_hash == pindexB->GetBlockHash()); - REQUIRE(last_height == 2); -} - -TEST_CASE("Notifications - ChainTip notification during reorg", "[notifications][chain][reorg]") { - // Test that ChainTip notifications are emitted during reorganization - // This ensures miners are notified of all tip changes, including during reorgs - - auto params = ChainParams::CreateRegTest(); - TestChainstateManager chainstate(*params); - chainstate.Initialize(params->GenesisBlock()); - - const auto& genesis = params->GenesisBlock(); - validation::ValidationState state; - - // Track all tip changes - std::vector tip_heights; - - auto sub = Notifications().SubscribeChainTip( - [&](const ChainTipEvent& event) { - tip_heights.push_back(event.height); - }); - - // Build initial chain: Genesis -> A -> B - auto headerA = CreateTestHeader(genesis.GetHash(), util::GetTime()); - chain::CBlockIndex* pindexA = chainstate.AcceptBlockHeader(headerA, state); - chainstate.TryAddBlockIndexCandidate(pindexA); - chainstate.ActivateBestChain(); // Activate A - - auto headerB = CreateTestHeader(headerA.GetHash(), util::GetTime() + 120); - chain::CBlockIndex* pindexB = chainstate.AcceptBlockHeader(headerB, state); - chainstate.TryAddBlockIndexCandidate(pindexB); - chainstate.ActivateBestChain(); // Activate B - - // Should have 2 tip changes (A, then B) - REQUIRE(tip_heights.size() == 2); - - // Build competing fork: Genesis -> X -> Y -> Z (more work) - auto headerX = CreateTestHeader(genesis.GetHash(), util::GetTime() + 1000); - chain::CBlockIndex* pindexX = chainstate.AcceptBlockHeader(headerX, state); - chainstate.TryAddBlockIndexCandidate(pindexX); - - auto headerY = CreateTestHeader(headerX.GetHash(), util::GetTime() + 1120); - chain::CBlockIndex* pindexY = chainstate.AcceptBlockHeader(headerY, state); - chainstate.TryAddBlockIndexCandidate(pindexY); - - auto headerZ = CreateTestHeader(headerY.GetHash(), util::GetTime() + 1240); - chain::CBlockIndex* pindexZ = chainstate.AcceptBlockHeader(headerZ, state); - chainstate.TryAddBlockIndexCandidate(pindexZ); - - size_t before_reorg = tip_heights.size(); - chainstate.ActivateBestChain(); - - // Should have additional tip changes during reorg - // (disconnect B, disconnect A, connect X, connect Y, connect Z) - REQUIRE(tip_heights.size() > before_reorg); - - // Final tip should be at height 3 - REQUIRE(chainstate.GetTip()->nHeight == 3); -} - -TEST_CASE("Notifications - Miner template invalidation on tip change", "[notifications][miner]") { - // Test that miner template is invalidated when chain tip changes - // This is the critical integration test for the miner notification feature - - auto params = ChainParams::CreateRegTest(); - TestChainstateManager chainstate(*params); - chainstate.Initialize(params->GenesisBlock()); - - const auto& genesis = params->GenesisBlock(); - validation::ValidationState state; - - // Create a miner - mining::CPUMiner miner(*params, chainstate); - - // Simulate miner generating template (sets internal state) - // In real code, GetBlockTemplate() would be called by mining loop - // For testing, we just need to verify InvalidateTemplate() sets the flag - - // Subscribe to chain tip changes and invalidate miner template - auto sub = Notifications().SubscribeChainTip( - [&](const ChainTipEvent& event) { - (void)event; - miner.InvalidateTemplate(); - }); - - // Build and activate first block - auto headerA = CreateTestHeader(genesis.GetHash(), util::GetTime()); - chain::CBlockIndex* pindexA = chainstate.AcceptBlockHeader(headerA, state); - chainstate.TryAddBlockIndexCandidate(pindexA); - chainstate.ActivateBestChain(); - - // Verify miner detects template should be regenerated - // (the atomic flag should be set by InvalidateTemplate()) - // We can't directly test the private atomic flag, but we can test - // that the miner's internal logic would detect the tip change - REQUIRE(chainstate.GetTip() == pindexA); -} - -TEST_CASE("Notifications - Subscription RAII cleanup", "[notifications]") { - // Test that subscriptions are properly cleaned up when destroyed - // This ensures no memory leaks or dangling callbacks - - auto params = ChainParams::CreateRegTest(); - params->SetSuspiciousReorgDepth(5); - TestChainstateManager chainstate(*params); - chainstate.Initialize(params->GenesisBlock()); - - const auto& genesis = params->GenesisBlock(); - validation::ValidationState state; - - int callback_count = 0; - - { - // Create subscription in inner scope - auto sub = Notifications().SubscribeFatalError( - [&](const std::string&, const std::string&) { callback_count++; }); - - // Build chain that triggers notification - auto chainMain = BuildChain(genesis.GetHash(), util::GetTime(), 5); - for (const auto& header : chainMain) { - auto pindex = chainstate.AcceptBlockHeader(header, state); - if (pindex) chainstate.TryAddBlockIndexCandidate(pindex); - } - chainstate.ActivateBestChain(); - - auto chainFork = BuildChain(genesis.GetHash(), util::GetTime() + 1000, 6); - for (const auto& header : chainFork) { - auto pindex = chainstate.AcceptBlockHeader(header, state); - if (pindex) chainstate.TryAddBlockIndexCandidate(pindex); - } - chainstate.ActivateBestChain(); - - REQUIRE(callback_count == 1); - // Subscription goes out of scope here - } - - // Create new chainstate for second test - params->SetSuspiciousReorgDepth(5); - TestChainstateManager chainstate2(*params); - chainstate2.Initialize(params->GenesisBlock()); - - // Build chain that would trigger notification again - auto chainMain2 = BuildChain(genesis.GetHash(), util::GetTime() + 10000, 5); - for (const auto& header : chainMain2) { - auto pindex = chainstate2.AcceptBlockHeader(header, state); - if (pindex) chainstate2.TryAddBlockIndexCandidate(pindex); - } - chainstate2.ActivateBestChain(); - - auto chainFork2 = BuildChain(genesis.GetHash(), util::GetTime() + 20000, 6); - for (const auto& header : chainFork2) { - auto pindex = chainstate2.AcceptBlockHeader(header, state); - if (pindex) chainstate2.TryAddBlockIndexCandidate(pindex); - } - chainstate2.ActivateBestChain(); - - // Callback should NOT be called again (subscription was destroyed) - REQUIRE(callback_count == 1); -} - -TEST_CASE("Notifications - BlockConnected notification", "[notifications][block]") { - // Test that BlockConnected notification is emitted when blocks are added - // This is used by network layer to relay new blocks to peers - - auto params = ChainParams::CreateRegTest(); - TestChainstateManager chainstate(*params); - chainstate.Initialize(params->GenesisBlock()); - - const auto& genesis = params->GenesisBlock(); - validation::ValidationState state; - - // Track block connected notifications - int blocks_connected = 0; - std::vector connected_hashes; - - auto sub = Notifications().SubscribeBlockConnected( - [&](const BlockConnectedEvent& event) { - blocks_connected++; - connected_hashes.push_back(event.hash); - }); - - // Build chain: Genesis -> A -> B -> C - auto chainMain = BuildChain(genesis.GetHash(), util::GetTime(), 3); - std::vector indices; - - for (const auto& header : chainMain) { - auto pindex = chainstate.AcceptBlockHeader(header, state); - chainstate.TryAddBlockIndexCandidate(pindex); - indices.push_back(pindex); - REQUIRE(pindex != nullptr); - } - - chainstate.ActivateBestChain(); - - // Verify all blocks triggered notification - REQUIRE(blocks_connected == 3); - REQUIRE(connected_hashes.size() == 3); - - // Verify hashes match - for (size_t i = 0; i < indices.size(); i++) { - REQUIRE(connected_hashes[i] == indices[i]->GetBlockHash()); - } -} - -// ============================================================================= -// Section: Subscription Move Semantics -// ============================================================================= - -TEST_CASE("Notifications - Subscription move constructor", "[notifications][subscription]") { - // Test that Subscription can be move-constructed - // The original subscription should be invalidated after move - - int callback_count = 0; - - // Create subscription and move it - auto sub1 = Notifications().SubscribeFatalError( - [&](const std::string&, const std::string&) { callback_count++; }); - - // Move construct sub2 from sub1 - auto sub2 = std::move(sub1); - - // Trigger notification - Notifications().NotifyFatalError("test", "test"); - - // Callback should be called (sub2 is now the owner) - REQUIRE(callback_count == 1); - - // Move sub2 out of scope explicitly to unsubscribe - { - auto sub3 = std::move(sub2); - // sub3 goes out of scope, unsubscribes - } - - // Trigger notification again - Notifications().NotifyFatalError("test2", "test2"); - - // Callback should NOT be called (subscription was moved and destroyed) - REQUIRE(callback_count == 1); -} - -TEST_CASE("Notifications - Subscription move assignment", "[notifications][subscription]") { - // Test that Subscription can be move-assigned - // The original subscription should be invalidated after move - - int callback1_count = 0; - int callback2_count = 0; - - // Create two subscriptions - auto sub1 = Notifications().SubscribeFatalError( - [&](const std::string&, const std::string&) { callback1_count++; }); - - auto sub2 = Notifications().SubscribeFatalError( - [&](const std::string&, const std::string&) { callback2_count++; }); - - // Trigger notification - both should fire - Notifications().NotifyFatalError("test", "test"); - REQUIRE(callback1_count == 1); - REQUIRE(callback2_count == 1); - - // Move-assign sub1 to sub2 (sub2's original subscription should be unsubscribed) - sub2 = std::move(sub1); - - // Trigger notification again - Notifications().NotifyFatalError("test2", "test2"); - - // callback1 should fire (now owned by sub2) - // callback2 should NOT fire (was unsubscribed when sub2 was reassigned) - REQUIRE(callback1_count == 2); - REQUIRE(callback2_count == 1); -} - -TEST_CASE("Notifications - Subscription self-assignment is safe", "[notifications][subscription]") { - // Test that self-move-assignment is handled safely - - int callback_count = 0; - - auto sub = Notifications().SubscribeFatalError( - [&](const std::string&, const std::string&) { callback_count++; }); - - // Self-assignment (should be a no-op) - sub = std::move(sub); - - // Trigger notification - callback should still work - Notifications().NotifyFatalError("test", "test"); - REQUIRE(callback_count == 1); -} - -TEST_CASE("Notifications - Explicit Unsubscribe", "[notifications][subscription]") { - // Test that Unsubscribe() can be called explicitly before destruction - - int callback_count = 0; - - auto sub = Notifications().SubscribeFatalError( - [&](const std::string&, const std::string&) { callback_count++; }); - - // Trigger notification - should fire - Notifications().NotifyFatalError("test", "test"); - REQUIRE(callback_count == 1); - - // Explicitly unsubscribe - sub.Unsubscribe(); - - // Trigger notification again - should NOT fire - Notifications().NotifyFatalError("test2", "test2"); - REQUIRE(callback_count == 1); - - // Double unsubscribe should be safe (no-op) - sub.Unsubscribe(); - - // Trigger notification again - still should NOT fire - Notifications().NotifyFatalError("test3", "test3"); - REQUIRE(callback_count == 1); -} - -TEST_CASE("Notifications - ChainTip with empty callbacks", "[notifications][chain]") { - // Test that NotifyChainTip handles case with no subscribers gracefully - - // No subscribers - should not crash - ChainTipEvent event; - event.hash.SetNull(); - event.height = 0; - - // This should be a no-op, not crash - Notifications().NotifyChainTip(event); -} - -TEST_CASE("Notifications - BlockConnected with empty callbacks", "[notifications][block]") { - // Test that NotifyBlockConnected handles case with no subscribers gracefully - - BlockConnectedEvent event; - event.hash.SetNull(); - event.height = 0; - - // This should be a no-op, not crash - Notifications().NotifyBlockConnected(event); -} - -// ============================================================================= -// Section: IBD State Consistency -// ============================================================================= - -TEST_CASE("Notifications - IBD state consistent across batch", "[notifications][ibd]") { - // Test that all blocks connected in a single ActivateBestChain() batch - // receive the same is_initial_download value, even if IBD would end mid-batch. - // - // This tests the fix for an edge case where: - // - Node is in IBD (tip is stale) - // - Multiple blocks are connected in one batch - // - Mid-batch, the tip becomes non-stale (IBD would end) - // - // Without the fix: blocks before IBD ends get is_initial_download=true, - // blocks after get is_initial_download=false - // With the fix: ALL blocks get the same value (captured at batch start) - - // IBD_STALE_TIP_SECONDS = 5 days = 432000 seconds - constexpr int64_t IBD_STALE_TIP_SECONDS = 5 * 24 * 3600; - - auto params = ChainParams::CreateRegTest(); - TestChainstateManager chainstate(*params); - chainstate.Initialize(params->GenesisBlock()); - - const auto& genesis = params->GenesisBlock(); - validation::ValidationState state; - - // Set mock time to a known value - const int64_t mock_time = 1700000000; // Some arbitrary timestamp - util::SetMockTime(mock_time); - - // Track all BlockConnectedEvent notifications - std::vector ibd_values; - std::vector heights; - - auto sub = Notifications().SubscribeBlockConnected( - [&](const BlockConnectedEvent& event) { - ibd_values.push_back(event.is_initial_download); - heights.push_back(event.height); - }); - - // Phase 1: Build initial chain with STALE timestamps (IBD=true) - // These blocks have timestamps older than mock_time - 5 days - const uint32_t stale_time = static_cast(mock_time - IBD_STALE_TIP_SECONDS - 3600); // 5 days + 1 hour ago - - auto staleChain = BuildChain(genesis.GetHash(), stale_time, 3); - chain::CBlockIndex* staleTip = nullptr; - - for (const auto& header : staleChain) { - staleTip = chainstate.AcceptBlockHeader(header, state); - chainstate.TryAddBlockIndexCandidate(staleTip); - REQUIRE(staleTip != nullptr); - } - - chainstate.ActivateBestChain(); - REQUIRE(chainstate.GetTip() == staleTip); - REQUIRE(chainstate.GetTip()->nHeight == 3); - - // Verify we're in IBD (tip is stale) - REQUIRE(chainstate.IsInitialBlockDownload() == true); - - // Clear recorded events from phase 1 - ibd_values.clear(); - heights.clear(); - - // Phase 2: Build continuation chain where IBD would end mid-batch - // - Blocks 4-6: stale timestamps (IBD would still be true if checked per-block) - // - Blocks 7-10: recent timestamps (IBD would become false if checked per-block) - // - // Key: We add ALL blocks before calling ActivateBestChain(), so they're - // connected in a single batch. The fix captures IBD state once at batch start. - - std::vector batchChain; - uint256 prev_hash = staleTip->GetBlockHash(); - - // Blocks 4-6: still stale (IBD=true if checked here) - for (int i = 0; i < 3; i++) { - auto header = CreateTestHeader(prev_hash, stale_time + (i + 1) * 120); - batchChain.push_back(header); - prev_hash = header.GetHash(); - } - - // Blocks 7-10: recent timestamps (IBD=false if checked here) - // These are within 5 days of mock_time - const uint32_t recent_time = static_cast(mock_time - 3600); // 1 hour ago - - for (int i = 0; i < 4; i++) { - auto header = CreateTestHeader(prev_hash, recent_time + i * 120); - batchChain.push_back(header); - prev_hash = header.GetHash(); - } - - // Add ALL headers before activating (to form a single batch) - chain::CBlockIndex* batchTip = nullptr; - for (const auto& header : batchChain) { - batchTip = chainstate.AcceptBlockHeader(header, state); - chainstate.TryAddBlockIndexCandidate(batchTip); - REQUIRE(batchTip != nullptr); - } - - // At this point: tip is still block 3 (stale), IBD=true - // The batch will connect blocks 4-10 - REQUIRE(chainstate.IsInitialBlockDownload() == true); - - // Connect all blocks in ONE ActivateBestChain() call - chainstate.ActivateBestChain(); - - // Verify final state - REQUIRE(chainstate.GetTip() == batchTip); - REQUIRE(chainstate.GetTip()->nHeight == 10); - - // After batch: tip is block 10 (recent timestamp), IBD=false - REQUIRE(chainstate.IsInitialBlockDownload() == false); - - // THE CRITICAL CHECK: All events should have the SAME is_initial_download value - // (captured at batch start when tip was block 3, which was stale → IBD=true) - REQUIRE(ibd_values.size() == 7); // Blocks 4-10 - REQUIRE(heights.size() == 7); - - // Verify heights are correct - for (size_t i = 0; i < heights.size(); i++) { - REQUIRE(heights[i] == static_cast(4 + i)); - } - - // THE FIX: All blocks in batch get the SAME is_initial_download value - // Without the fix, blocks 7-10 would have is_initial_download=false - bool first_value = ibd_values[0]; - for (size_t i = 0; i < ibd_values.size(); i++) { - INFO("Block " << heights[i] << " is_initial_download=" << ibd_values[i]); - REQUIRE(ibd_values[i] == first_value); - } - - // The value should be true (IBD at batch start) - REQUIRE(first_value == true); - - // Cleanup mock time - util::SetMockTime(0); -} - +// Copyright (c) 2025 The Unicity Foundation +// Distributed under the MIT software license +// Tests for blockchain notification system + +#include "catch_amalgamated.hpp" +#include "chain/validation.hpp" +#include "common/test_chainstate_manager.hpp" +#include "chain/chainstate_manager.hpp" +#include "chain/chainparams.hpp" +#include "chain/chain.hpp" +#include "chain/block_index.hpp" +#include "chain/miner.hpp" +#include "chain/token_manager.hpp" +#include "chain/notifications.hpp" + +#include "chain/miner.hpp" +#include "common/mock_bft_client.hpp" +#include "util/logging.hpp" +#include "util/time.hpp" +#include +#include +#include +#include + +using namespace unicity; +using namespace unicity::test; +using namespace unicity::chain; +using unicity::validation::ValidationState; + +// Test helper: Create a block header with specified parent and time +static CBlockHeader CreateTestHeader(const uint256& hashPrevBlock, + uint32_t nTime, + uint32_t nBits = 0x207fffff) { + CBlockHeader header; + header.nVersion = 1; + header.hashPrevBlock = hashPrevBlock; + header.payloadRoot.SetNull(); + header.nTime = nTime; + header.nBits = nBits; + header.nNonce = 0; + header.hashRandomX.SetNull(); // Valid PoW placeholder (test bypasses validation) + return header; +} + +// Test helper: Build a chain of N blocks from a parent +static std::vector BuildChain(const uint256& parent_hash, + uint32_t start_time, + int count, + uint32_t nBits = 0x207fffff) { + std::vector chain; + uint256 prev_hash = parent_hash; + uint32_t time = start_time; + + for (int i = 0; i < count; i++) { + auto header = CreateTestHeader(prev_hash, time, nBits); + chain.push_back(header); + prev_hash = header.GetHash(); + time += 120; // 2-minute blocks + } + + return chain; +} + +TEST_CASE("Notifications - FatalError notification emitted on deep reorg", "[notifications][reorg]") { + // Test that NotifyFatalError is called when reorg exceeds threshold + // This ensures the notification system properly alerts subscribers + + auto params = ChainParams::CreateRegTest(); + // Set suspicious_reorg_depth=7 (allow up to depth 6, reject depth 7+) + params->SetSuspiciousReorgDepth(7); + TestChainstateManager chainstate(*params); + chainstate.Initialize(params->GenesisBlock()); + + const auto& genesis = params->GenesisBlock(); + validation::ValidationState state; + + // Track notification + bool notification_received = false; + std::string debug_msg; + std::string user_msg; + + // Subscribe to fatal error notifications + auto sub = Notifications().SubscribeFatalError( + [&](const std::string& debug_message, const std::string& user_message) { + notification_received = true; + debug_msg = debug_message; + user_msg = user_message; + }); + + // Build initial chain: Genesis -> [7 blocks] + auto chainMain = BuildChain(genesis.GetHash(), util::GetTime(), 7); + chain::CBlockIndex* mainTip = nullptr; + + for (const auto& header : chainMain) { +mainTip = chainstate.AcceptBlockHeader(header, state); + if (mainTip) chainstate.TryAddBlockIndexCandidate(mainTip); + REQUIRE(mainTip != nullptr); + } + + chainstate.ActivateBestChain(); + REQUIRE(chainstate.GetTip() == mainTip); + REQUIRE(chainstate.GetTip()->nHeight == 7); + + // Build competing fork: Genesis -> [8 blocks] (more work, but requires depth-7 reorg) + auto chainFork = BuildChain(genesis.GetHash(), util::GetTime() + 1000, 8); + chain::CBlockIndex* forkTip = nullptr; + + for (const auto& header : chainFork) { +forkTip = chainstate.AcceptBlockHeader(header, state); + if (forkTip) chainstate.TryAddBlockIndexCandidate(forkTip); + REQUIRE(forkTip != nullptr); + } + + chainstate.ActivateBestChain(); + + // Verify notification was emitted + REQUIRE(notification_received); + REQUIRE(debug_msg.find("7 blocks") != std::string::npos); + REQUIRE(user_msg.find("suspicious-reorg-depth") != std::string::npos); + + // Should REJECT reorg (depth 7 >= suspicious_reorg_depth=7) + REQUIRE(chainstate.GetTip() == mainTip); + REQUIRE(chainstate.GetTip()->nHeight == 7); +} + +TEST_CASE("Notifications - FatalError not emitted on allowed reorg", "[notifications][reorg]") { + // Test that notification is NOT emitted for reorgs within threshold + // This ensures we don't spam notifications for normal reorgs + + auto params = ChainParams::CreateRegTest(); + // Set suspicious_reorg_depth=7 (allow up to depth 6, reject depth 7+) + params->SetSuspiciousReorgDepth(7); + TestChainstateManager chainstate(*params); + chainstate.Initialize(params->GenesisBlock()); + + const auto& genesis = params->GenesisBlock(); + validation::ValidationState state; + + // Track notification + bool notification_received = false; + + // Subscribe to fatal error notifications + auto sub = Notifications().SubscribeFatalError( + [&](const std::string&, const std::string&) { + notification_received = true; + }); + + // Build initial chain: Genesis -> [5 blocks] + auto chainMain = BuildChain(genesis.GetHash(), util::GetTime(), 5); + chain::CBlockIndex* mainTip = nullptr; + + for (const auto& header : chainMain) { + mainTip = chainstate.AcceptBlockHeader(header, state); + if (mainTip) chainstate.TryAddBlockIndexCandidate(mainTip); + REQUIRE(mainTip != nullptr); + } + + chainstate.ActivateBestChain(); + REQUIRE(chainstate.GetTip() == mainTip); + + // Build competing fork: Genesis -> [6 blocks] (requires depth-5 reorg, which is allowed) + auto chainFork = BuildChain(genesis.GetHash(), util::GetTime() + 1000, 6); + chain::CBlockIndex* forkTip = nullptr; + + for (const auto& header : chainFork) { + forkTip = chainstate.AcceptBlockHeader(header, state); + if (forkTip) chainstate.TryAddBlockIndexCandidate(forkTip); + REQUIRE(forkTip != nullptr); + } + + chainstate.ActivateBestChain(); + + // Verify notification was NOT emitted (reorg depth 5 < 7) + REQUIRE_FALSE(notification_received); + + // Should ACCEPT reorg (depth 5 < suspicious_reorg_depth=7) + REQUIRE(chainstate.GetTip() == forkTip); + REQUIRE(chainstate.GetTip()->nHeight == 6); +} + +TEST_CASE("Notifications - Multiple subscribers receive SuspiciousReorg notification", "[notifications][reorg]") { + // Test that all subscribers receive the notification + // This ensures the notification system properly broadcasts to all listeners + + auto params = ChainParams::CreateRegTest(); + params->SetSuspiciousReorgDepth(5); + TestChainstateManager chainstate(*params); + chainstate.Initialize(params->GenesisBlock()); + + const auto& genesis = params->GenesisBlock(); + validation::ValidationState state; + + // Track notifications for multiple subscribers + bool sub1_received = false; + bool sub2_received = false; + bool sub3_received = false; + + // Subscribe multiple listeners + auto sub1 = Notifications().SubscribeFatalError([&](const std::string&, const std::string&) { sub1_received = true; }); + auto sub2 = Notifications().SubscribeFatalError([&](const std::string&, const std::string&) { sub2_received = true; }); + auto sub3 = Notifications().SubscribeFatalError([&](const std::string&, const std::string&) { sub3_received = true; }); + + // Build initial chain: Genesis -> [5 blocks] + auto chainMain = BuildChain(genesis.GetHash(), util::GetTime(), 5); + chain::CBlockIndex* mainTip = nullptr; + + for (const auto& header : chainMain) { + mainTip = chainstate.AcceptBlockHeader(header, state); + if (mainTip) chainstate.TryAddBlockIndexCandidate(mainTip); + REQUIRE(mainTip != nullptr); + } + + chainstate.ActivateBestChain(); + + // Build competing fork that triggers suspicious reorg + auto chainFork = BuildChain(genesis.GetHash(), util::GetTime() + 1000, 6); + chain::CBlockIndex* forkTip = nullptr; + + for (const auto& header : chainFork) { + forkTip = chainstate.AcceptBlockHeader(header, state); + if (forkTip) chainstate.TryAddBlockIndexCandidate(forkTip); + REQUIRE(forkTip != nullptr); + } + + chainstate.ActivateBestChain(); + + // Verify all subscribers received notification + REQUIRE(sub1_received); + REQUIRE(sub2_received); + REQUIRE(sub3_received); +} + +TEST_CASE("Notifications - ChainTip notification emitted on tip change", "[notifications][chain]") { + // Test that NotifyChainTip is called when the chain tip changes + // This is critical for miner template invalidation + + auto params = ChainParams::CreateRegTest(); + TestChainstateManager chainstate(*params); + chainstate.Initialize(params->GenesisBlock()); + + const auto& genesis = params->GenesisBlock(); + validation::ValidationState state; + + // Track notifications + int tip_change_count = 0; + uint256 last_tip_hash; + int last_height = -1; + + // Subscribe to chain tip notifications + auto sub = Notifications().SubscribeChainTip( + [&](const ChainTipEvent& event) { + tip_change_count++; + last_tip_hash = event.hash; + last_height = event.height; + }); + + // Add first block: Genesis -> A + auto headerA = CreateTestHeader(genesis.GetHash(), util::GetTime()); + chain::CBlockIndex* pindexA = chainstate.AcceptBlockHeader(headerA, state); + chainstate.TryAddBlockIndexCandidate(pindexA); + REQUIRE(pindexA != nullptr); + + chainstate.ActivateBestChain(); + + // Verify first tip change notification + REQUIRE(tip_change_count == 1); + REQUIRE(last_tip_hash == pindexA->GetBlockHash()); + REQUIRE(last_height == 1); + + // Add second block: A -> B + auto headerB = CreateTestHeader(headerA.GetHash(), util::GetTime() + 120); + chain::CBlockIndex* pindexB = chainstate.AcceptBlockHeader(headerB, state); + chainstate.TryAddBlockIndexCandidate(pindexB); + REQUIRE(pindexB != nullptr); + + chainstate.ActivateBestChain(); + + // Verify second tip change notification + REQUIRE(tip_change_count == 2); + REQUIRE(last_tip_hash == pindexB->GetBlockHash()); + REQUIRE(last_height == 2); +} + +TEST_CASE("Notifications - ChainTip notification during reorg", "[notifications][chain][reorg]") { + // Test that ChainTip notifications are emitted during reorganization + // This ensures miners are notified of all tip changes, including during reorgs + + auto params = ChainParams::CreateRegTest(); + TestChainstateManager chainstate(*params); + chainstate.Initialize(params->GenesisBlock()); + + const auto& genesis = params->GenesisBlock(); + validation::ValidationState state; + + // Track all tip changes + std::vector tip_heights; + + auto sub = Notifications().SubscribeChainTip( + [&](const ChainTipEvent& event) { + tip_heights.push_back(event.height); + }); + + // Build initial chain: Genesis -> A -> B + auto headerA = CreateTestHeader(genesis.GetHash(), util::GetTime()); + chain::CBlockIndex* pindexA = chainstate.AcceptBlockHeader(headerA, state); + chainstate.TryAddBlockIndexCandidate(pindexA); + chainstate.ActivateBestChain(); // Activate A + + auto headerB = CreateTestHeader(headerA.GetHash(), util::GetTime() + 120); + chain::CBlockIndex* pindexB = chainstate.AcceptBlockHeader(headerB, state); + chainstate.TryAddBlockIndexCandidate(pindexB); + chainstate.ActivateBestChain(); // Activate B + + // Should have 2 tip changes (A, then B) + REQUIRE(tip_heights.size() == 2); + + // Build competing fork: Genesis -> X -> Y -> Z (more work) + auto headerX = CreateTestHeader(genesis.GetHash(), util::GetTime() + 1000); + chain::CBlockIndex* pindexX = chainstate.AcceptBlockHeader(headerX, state); + chainstate.TryAddBlockIndexCandidate(pindexX); + + auto headerY = CreateTestHeader(headerX.GetHash(), util::GetTime() + 1120); + chain::CBlockIndex* pindexY = chainstate.AcceptBlockHeader(headerY, state); + chainstate.TryAddBlockIndexCandidate(pindexY); + + auto headerZ = CreateTestHeader(headerY.GetHash(), util::GetTime() + 1240); + chain::CBlockIndex* pindexZ = chainstate.AcceptBlockHeader(headerZ, state); + chainstate.TryAddBlockIndexCandidate(pindexZ); + + size_t before_reorg = tip_heights.size(); + chainstate.ActivateBestChain(); + + // Should have additional tip changes during reorg + // (disconnect B, disconnect A, connect X, connect Y, connect Z) + REQUIRE(tip_heights.size() > before_reorg); + + // Final tip should be at height 3 + REQUIRE(chainstate.GetTip()->nHeight == 3); +} + +TEST_CASE("Notifications - Miner template invalidation on tip change", "[notifications][miner]") { + // Test that miner template is invalidated when chain tip changes + // This is the critical integration test for the miner notification feature + + auto params = ChainParams::CreateRegTest(); + TestChainstateManager chainstate(*params); + chainstate.Initialize(params->GenesisBlock()); + + const auto& genesis = params->GenesisBlock(); + validation::ValidationState state; + + // Create a miner + std::filesystem::path test_dir = std::filesystem::temp_directory_path() / "unicity_notif_test_XXXXXX"; + char dir_template[256]; + std::strncpy(dir_template, test_dir.string().c_str(), sizeof(dir_template)); + if (mkdtemp(dir_template)) { + test_dir = dir_template; + } + LocalTrustBaseManager tbm(test_dir, std::make_shared()); + mining::TokenManager token_manager(test_dir, chainstate); + mining::CPUMiner miner(*params, chainstate, tbm, token_manager); + + // Simulate miner generating template (sets internal state) + // In real code, GetBlockTemplate() would be called by mining loop + // For testing, we just need to verify InvalidateTemplate() sets the flag + + // Subscribe to chain tip changes and invalidate miner template + auto sub = Notifications().SubscribeChainTip( + [&](const ChainTipEvent& event) { + (void)event; + miner.InvalidateTemplate(); + }); + + // Build and activate first block + auto headerA = CreateTestHeader(genesis.GetHash(), util::GetTime()); + chain::CBlockIndex* pindexA = chainstate.AcceptBlockHeader(headerA, state); + chainstate.TryAddBlockIndexCandidate(pindexA); + chainstate.ActivateBestChain(); + + // Verify miner detects template should be regenerated + // (the atomic flag should be set by InvalidateTemplate()) + // We can't directly test the private atomic flag, but we can test + // that the miner's internal logic would detect the tip change + REQUIRE(chainstate.GetTip() == pindexA); +} + +TEST_CASE("Notifications - Subscription RAII cleanup", "[notifications]") { + // Test that subscriptions are properly cleaned up when destroyed + // This ensures no memory leaks or dangling callbacks + + auto params = ChainParams::CreateRegTest(); + params->SetSuspiciousReorgDepth(5); + TestChainstateManager chainstate(*params); + chainstate.Initialize(params->GenesisBlock()); + + const auto& genesis = params->GenesisBlock(); + validation::ValidationState state; + + int callback_count = 0; + + { + // Create subscription in inner scope + auto sub = Notifications().SubscribeFatalError( + [&](const std::string&, const std::string&) { callback_count++; }); + + // Build chain that triggers notification + auto chainMain = BuildChain(genesis.GetHash(), util::GetTime(), 5); + for (const auto& header : chainMain) { + auto pindex = chainstate.AcceptBlockHeader(header, state); + if (pindex) chainstate.TryAddBlockIndexCandidate(pindex); + } + chainstate.ActivateBestChain(); + + auto chainFork = BuildChain(genesis.GetHash(), util::GetTime() + 1000, 6); + for (const auto& header : chainFork) { + auto pindex = chainstate.AcceptBlockHeader(header, state); + if (pindex) chainstate.TryAddBlockIndexCandidate(pindex); + } + chainstate.ActivateBestChain(); + + REQUIRE(callback_count == 1); + // Subscription goes out of scope here + } + + // Create new chainstate for second test + params->SetSuspiciousReorgDepth(5); + TestChainstateManager chainstate2(*params); + chainstate2.Initialize(params->GenesisBlock()); + + // Build chain that would trigger notification again + auto chainMain2 = BuildChain(genesis.GetHash(), util::GetTime() + 10000, 5); + for (const auto& header : chainMain2) { + auto pindex = chainstate2.AcceptBlockHeader(header, state); + if (pindex) chainstate2.TryAddBlockIndexCandidate(pindex); + } + chainstate2.ActivateBestChain(); + + auto chainFork2 = BuildChain(genesis.GetHash(), util::GetTime() + 20000, 6); + for (const auto& header : chainFork2) { + auto pindex = chainstate2.AcceptBlockHeader(header, state); + if (pindex) chainstate2.TryAddBlockIndexCandidate(pindex); + } + chainstate2.ActivateBestChain(); + + // Callback should NOT be called again (subscription was destroyed) + REQUIRE(callback_count == 1); +} + +TEST_CASE("Notifications - BlockConnected notification", "[notifications][block]") { + // Test that BlockConnected notification is emitted when blocks are added + // This is used by network layer to relay new blocks to peers + + auto params = ChainParams::CreateRegTest(); + TestChainstateManager chainstate(*params); + chainstate.Initialize(params->GenesisBlock()); + + const auto& genesis = params->GenesisBlock(); + validation::ValidationState state; + + // Track block connected notifications + int blocks_connected = 0; + std::vector connected_hashes; + + auto sub = Notifications().SubscribeBlockConnected( + [&](const BlockConnectedEvent& event) { + blocks_connected++; + connected_hashes.push_back(event.hash); + }); + + // Build chain: Genesis -> A -> B -> C + auto chainMain = BuildChain(genesis.GetHash(), util::GetTime(), 3); + std::vector indices; + + for (const auto& header : chainMain) { + auto pindex = chainstate.AcceptBlockHeader(header, state); + chainstate.TryAddBlockIndexCandidate(pindex); + indices.push_back(pindex); + REQUIRE(pindex != nullptr); + } + + chainstate.ActivateBestChain(); + + // Verify all blocks triggered notification + REQUIRE(blocks_connected == 3); + REQUIRE(connected_hashes.size() == 3); + + // Verify hashes match + for (size_t i = 0; i < indices.size(); i++) { + REQUIRE(connected_hashes[i] == indices[i]->GetBlockHash()); + } +} + +// ============================================================================= +// Section: Subscription Move Semantics +// ============================================================================= + +TEST_CASE("Notifications - Subscription move constructor", "[notifications][subscription]") { + // Test that Subscription can be move-constructed + // The original subscription should be invalidated after move + + int callback_count = 0; + + // Create subscription and move it + auto sub1 = Notifications().SubscribeFatalError( + [&](const std::string&, const std::string&) { callback_count++; }); + + // Move construct sub2 from sub1 + auto sub2 = std::move(sub1); + + // Trigger notification + Notifications().NotifyFatalError("test", "test"); + + // Callback should be called (sub2 is now the owner) + REQUIRE(callback_count == 1); + + // Move sub2 out of scope explicitly to unsubscribe + { + auto sub3 = std::move(sub2); + // sub3 goes out of scope, unsubscribes + } + + // Trigger notification again + Notifications().NotifyFatalError("test2", "test2"); + + // Callback should NOT be called (subscription was moved and destroyed) + REQUIRE(callback_count == 1); +} + +TEST_CASE("Notifications - Subscription move assignment", "[notifications][subscription]") { + // Test that Subscription can be move-assigned + // The original subscription should be invalidated after move + + int callback1_count = 0; + int callback2_count = 0; + + // Create two subscriptions + auto sub1 = Notifications().SubscribeFatalError( + [&](const std::string&, const std::string&) { callback1_count++; }); + + auto sub2 = Notifications().SubscribeFatalError( + [&](const std::string&, const std::string&) { callback2_count++; }); + + // Trigger notification - both should fire + Notifications().NotifyFatalError("test", "test"); + REQUIRE(callback1_count == 1); + REQUIRE(callback2_count == 1); + + // Move-assign sub1 to sub2 (sub2's original subscription should be unsubscribed) + sub2 = std::move(sub1); + + // Trigger notification again + Notifications().NotifyFatalError("test2", "test2"); + + // callback1 should fire (now owned by sub2) + // callback2 should NOT fire (was unsubscribed when sub2 was reassigned) + REQUIRE(callback1_count == 2); + REQUIRE(callback2_count == 1); +} + +TEST_CASE("Notifications - Subscription self-assignment is safe", "[notifications][subscription]") { + // Test that self-move-assignment is handled safely + + int callback_count = 0; + + auto sub = Notifications().SubscribeFatalError( + [&](const std::string&, const std::string&) { callback_count++; }); + + // Self-assignment (should be a no-op) + sub = std::move(sub); + + // Trigger notification - callback should still work + Notifications().NotifyFatalError("test", "test"); + REQUIRE(callback_count == 1); +} + +TEST_CASE("Notifications - Explicit Unsubscribe", "[notifications][subscription]") { + // Test that Unsubscribe() can be called explicitly before destruction + + int callback_count = 0; + + auto sub = Notifications().SubscribeFatalError( + [&](const std::string&, const std::string&) { callback_count++; }); + + // Trigger notification - should fire + Notifications().NotifyFatalError("test", "test"); + REQUIRE(callback_count == 1); + + // Explicitly unsubscribe + sub.Unsubscribe(); + + // Trigger notification again - should NOT fire + Notifications().NotifyFatalError("test2", "test2"); + REQUIRE(callback_count == 1); + + // Double unsubscribe should be safe (no-op) + sub.Unsubscribe(); + + // Trigger notification again - still should NOT fire + Notifications().NotifyFatalError("test3", "test3"); + REQUIRE(callback_count == 1); +} + +TEST_CASE("Notifications - ChainTip with empty callbacks", "[notifications][chain]") { + // Test that NotifyChainTip handles case with no subscribers gracefully + + // No subscribers - should not crash + ChainTipEvent event; + event.hash.SetNull(); + event.height = 0; + + // This should be a no-op, not crash + Notifications().NotifyChainTip(event); +} + +TEST_CASE("Notifications - BlockConnected with empty callbacks", "[notifications][block]") { + // Test that NotifyBlockConnected handles case with no subscribers gracefully + + BlockConnectedEvent event; + event.hash.SetNull(); + event.height = 0; + + // This should be a no-op, not crash + Notifications().NotifyBlockConnected(event); +} + +// ============================================================================= +// Section: IBD State Consistency +// ============================================================================= + +TEST_CASE("Notifications - IBD state consistent across batch", "[notifications][ibd]") { + // Test that all blocks connected in a single ActivateBestChain() batch + // receive the same is_initial_download value, even if IBD would end mid-batch. + // + // This tests the fix for an edge case where: + // - Node is in IBD (tip is stale) + // - Multiple blocks are connected in one batch + // - Mid-batch, the tip becomes non-stale (IBD would end) + // + // Without the fix: blocks before IBD ends get is_initial_download=true, + // blocks after get is_initial_download=false + // With the fix: ALL blocks get the same value (captured at batch start) + + // IBD_STALE_TIP_SECONDS = 5 days = 432000 seconds + constexpr int64_t IBD_STALE_TIP_SECONDS = 5 * 24 * 3600; + + auto params = ChainParams::CreateRegTest(); + TestChainstateManager chainstate(*params); + chainstate.Initialize(params->GenesisBlock()); + + const auto& genesis = params->GenesisBlock(); + validation::ValidationState state; + + // Set mock time to a known value + const int64_t mock_time = 1700000000; // Some arbitrary timestamp + util::SetMockTime(mock_time); + + // Track all BlockConnectedEvent notifications + std::vector ibd_values; + std::vector heights; + + auto sub = Notifications().SubscribeBlockConnected( + [&](const BlockConnectedEvent& event) { + ibd_values.push_back(event.is_initial_download); + heights.push_back(event.height); + }); + + // Phase 1: Build initial chain with STALE timestamps (IBD=true) + // These blocks have timestamps older than mock_time - 5 days + const uint32_t stale_time = static_cast(mock_time - IBD_STALE_TIP_SECONDS - 3600); // 5 days + 1 hour ago + + auto staleChain = BuildChain(genesis.GetHash(), stale_time, 3); + chain::CBlockIndex* staleTip = nullptr; + + for (const auto& header : staleChain) { + staleTip = chainstate.AcceptBlockHeader(header, state); + chainstate.TryAddBlockIndexCandidate(staleTip); + REQUIRE(staleTip != nullptr); + } + + chainstate.ActivateBestChain(); + REQUIRE(chainstate.GetTip() == staleTip); + REQUIRE(chainstate.GetTip()->nHeight == 3); + + // Verify we're in IBD (tip is stale) + REQUIRE(chainstate.IsInitialBlockDownload() == true); + + // Clear recorded events from phase 1 + ibd_values.clear(); + heights.clear(); + + // Phase 2: Build continuation chain where IBD would end mid-batch + // - Blocks 4-6: stale timestamps (IBD would still be true if checked per-block) + // - Blocks 7-10: recent timestamps (IBD would become false if checked per-block) + // + // Key: We add ALL blocks before calling ActivateBestChain(), so they're + // connected in a single batch. The fix captures IBD state once at batch start. + + std::vector batchChain; + uint256 prev_hash = staleTip->GetBlockHash(); + + // Blocks 4-6: still stale (IBD=true if checked here) + for (int i = 0; i < 3; i++) { + auto header = CreateTestHeader(prev_hash, stale_time + (i + 1) * 120); + batchChain.push_back(header); + prev_hash = header.GetHash(); + } + + // Blocks 7-10: recent timestamps (IBD=false if checked here) + // These are within 5 days of mock_time + const uint32_t recent_time = static_cast(mock_time - 3600); // 1 hour ago + + for (int i = 0; i < 4; i++) { + auto header = CreateTestHeader(prev_hash, recent_time + i * 120); + batchChain.push_back(header); + prev_hash = header.GetHash(); + } + + // Add ALL headers before activating (to form a single batch) + chain::CBlockIndex* batchTip = nullptr; + for (const auto& header : batchChain) { + batchTip = chainstate.AcceptBlockHeader(header, state); + chainstate.TryAddBlockIndexCandidate(batchTip); + REQUIRE(batchTip != nullptr); + } + + // At this point: tip is still block 3 (stale), IBD=true + // The batch will connect blocks 4-10 + REQUIRE(chainstate.IsInitialBlockDownload() == true); + + // Connect all blocks in ONE ActivateBestChain() call + chainstate.ActivateBestChain(); + + // Verify final state + REQUIRE(chainstate.GetTip() == batchTip); + REQUIRE(chainstate.GetTip()->nHeight == 10); + + // After batch: tip is block 10 (recent timestamp), IBD=false + REQUIRE(chainstate.IsInitialBlockDownload() == false); + + // THE CRITICAL CHECK: All events should have the SAME is_initial_download value + // (captured at batch start when tip was block 3, which was stale → IBD=true) + REQUIRE(ibd_values.size() == 7); // Blocks 4-10 + REQUIRE(heights.size() == 7); + + // Verify heights are correct + for (size_t i = 0; i < heights.size(); i++) { + REQUIRE(heights[i] == static_cast(4 + i)); + } + + // THE FIX: All blocks in batch get the SAME is_initial_download value + // Without the fix, blocks 7-10 would have is_initial_download=false + bool first_value = ibd_values[0]; + for (size_t i = 0; i < ibd_values.size(); i++) { + INFO("Block " << heights[i] << " is_initial_download=" << ibd_values[i]); + REQUIRE(ibd_values[i] == first_value); + } + + // The value should be true (IBD at batch start) + REQUIRE(first_value == true); + + // Cleanup mock time + util::SetMockTime(0); +} + diff --git a/test/unit/chain/pow_tests.cpp b/test/unit/chain/pow_tests.cpp index 41c9be4..3f3b0bb 100644 --- a/test/unit/chain/pow_tests.cpp +++ b/test/unit/chain/pow_tests.cpp @@ -1271,7 +1271,7 @@ TEST_CASE("CheckProofOfWork - Zero epoch duration protection", "[pow][security][ CBlockHeader header; header.nVersion = 1; header.hashPrevBlock = uint256(); - header.minerAddress = uint160(); + header.payloadRoot = uint256(); header.nTime = 1000000; header.nBits = 0x207fffff; // powLimit for regtest header.nNonce = 0; diff --git a/test/unit/chain/randomx_pow_tests.cpp b/test/unit/chain/randomx_pow_tests.cpp index d188d11..9c27a1c 100644 --- a/test/unit/chain/randomx_pow_tests.cpp +++ b/test/unit/chain/randomx_pow_tests.cpp @@ -240,7 +240,7 @@ TEST_CASE("RandomX commitment calculation", "[randomx][security][commitment]") { CBlockHeader header; header.nVersion = 1; header.hashPrevBlock = uint256(); - header.minerAddress = uint160(); + header.payloadRoot = uint256(); header.nTime = 1000000; header.nBits = 0x207fffff; header.nNonce = 0; @@ -267,7 +267,7 @@ TEST_CASE("RandomX commitment calculation", "[randomx][security][commitment]") { CBlockHeader header; header.nVersion = 1; header.hashPrevBlock = uint256(); - header.minerAddress = uint160(); + header.payloadRoot = uint256(); header.nTime = 1000000; header.nBits = 0x207fffff; header.nNonce = 0; @@ -417,7 +417,7 @@ TEST_CASE("RandomX hash computation correctness", "[randomx][security][hash]") { CBlockHeader header; header.nVersion = 1; header.hashPrevBlock.SetNull(); - header.minerAddress.SetNull(); + header.payloadRoot.SetNull(); header.nTime = 1000000; header.nBits = params.GenesisBlock().nBits; header.hashRandomX.SetNull(); @@ -441,7 +441,7 @@ TEST_CASE("RandomX hash computation correctness", "[randomx][security][hash]") { CBlockHeader header; header.nVersion = 1; header.hashPrevBlock.SetNull(); - header.minerAddress.SetNull(); + header.payloadRoot.SetNull(); header.nTime = 1000000; header.nBits = params.GenesisBlock().nBits; header.hashRandomX.SetNull(); @@ -465,8 +465,8 @@ TEST_CASE("RandomX hash computation correctness", "[randomx][security][hash]") { header1.nVersion = header2.nVersion = 1; header1.hashPrevBlock.SetNull(); header2.hashPrevBlock.SetNull(); - header1.minerAddress.SetNull(); - header2.minerAddress.SetNull(); + header1.payloadRoot.SetNull(); + header2.payloadRoot.SetNull(); header1.nBits = header2.nBits = params.GenesisBlock().nBits; header1.hashRandomX.SetNull(); header2.hashRandomX.SetNull(); @@ -486,7 +486,7 @@ TEST_CASE("RandomX hash computation correctness", "[randomx][security][hash]") { CBlockHeader header; header.nVersion = 1; header.hashPrevBlock.SetNull(); - header.minerAddress.SetNull(); + header.payloadRoot.SetNull(); header.nTime = 1000000; header.nBits = params.GenesisBlock().nBits; header.hashRandomX.SetNull(); @@ -673,7 +673,7 @@ TEST_CASE("RandomX commitment with inHash parameter", "[randomx][security][commi CBlockHeader header; header.nVersion = 1; header.hashPrevBlock.SetNull(); - header.minerAddress.SetNull(); + header.payloadRoot.SetNull(); header.nTime = 1000000; header.nBits = params.GenesisBlock().nBits; header.nNonce = 42; @@ -703,7 +703,7 @@ TEST_CASE("RandomX commitment with inHash parameter", "[randomx][security][commi CBlockHeader header; header.nVersion = 1; header.hashPrevBlock.SetNull(); - header.minerAddress.SetNull(); + header.payloadRoot.SetNull(); header.nTime = 1000000; header.nBits = params.GenesisBlock().nBits; header.nNonce = 42; diff --git a/test/unit/chain/randomx_threading_stress_tests.cpp b/test/unit/chain/randomx_threading_stress_tests.cpp index 6ac71a0..c5b1369 100644 --- a/test/unit/chain/randomx_threading_stress_tests.cpp +++ b/test/unit/chain/randomx_threading_stress_tests.cpp @@ -289,7 +289,7 @@ TEST_CASE("RandomX - CheckProofOfWork concurrent stress test", "[randomx][stress CBlockHeader header; header.nVersion = 1; header.hashPrevBlock = uint256(); - header.minerAddress = uint160(); + header.payloadRoot = uint256(); header.nTime = 1000000; header.nBits = 0x207fffff; header.nNonce = 0; @@ -331,7 +331,7 @@ TEST_CASE("RandomX - CheckProofOfWork concurrent stress test", "[randomx][stress CBlockHeader header; header.nVersion = 1; header.hashPrevBlock = uint256(); - header.minerAddress = uint160(); + header.payloadRoot = uint256(); header.nTime = 1000000 + (i * 100000); // Different epoch per thread header.nBits = 0x207fffff; header.nNonce = 0; diff --git a/test/unit/chain/reorg_tests.cpp b/test/unit/chain/reorg_tests.cpp index 814dc43..7ccc3d7 100644 --- a/test/unit/chain/reorg_tests.cpp +++ b/test/unit/chain/reorg_tests.cpp @@ -29,7 +29,7 @@ static CBlockHeader CreateTestHeader(const uint256& hashPrevBlock, CBlockHeader header; header.nVersion = 1; header.hashPrevBlock = hashPrevBlock; - header.minerAddress.SetNull(); + header.payloadRoot.SetNull(); header.nTime = nTime; header.nBits = nBits; header.nNonce = 0; diff --git a/test/unit/chain/token_generator_test.cpp b/test/unit/chain/token_generator_test.cpp new file mode 100644 index 0000000..3c5cef9 --- /dev/null +++ b/test/unit/chain/token_generator_test.cpp @@ -0,0 +1,91 @@ +// Copyright (c) 2025 The Unicity Foundation +// Distributed under the MIT software license + +#include "chain/token_generator.hpp" +#include "util/endian.hpp" +#include "util/files.hpp" +#include "util/sha256.hpp" + +#include "catch_amalgamated.hpp" +#include "common/test_util.hpp" + +#include +#include + +#include + +using namespace unicity; +using namespace unicity::mining; + +namespace { + +nlohmann::json readStateJSON(const std::filesystem::path& state_file) { + auto data = util::read_file(state_file); + return nlohmann::json::parse(std::string(data.begin(), data.end())); +} + +} // namespace + +TEST_CASE("TokenGenerator basic operations", "[mining][token]") { + test::TempDir temp_dir("token_gen_test"); + const auto& test_dir = temp_dir.path; + auto state_file = test_dir / "miner_state.json"; + + SECTION("Initial creation generates seed and saves it") { + TokenGenerator gen(test_dir); + auto state = gen.GetState(); + REQUIRE_FALSE(state.seed.IsNull()); + REQUIRE(state.counter == 0); + REQUIRE(gen.GetCounter() == 0); + REQUIRE(std::filesystem::exists(state_file)); + + auto data = util::read_file(state_file); + auto json = nlohmann::json::parse(std::string(data.begin(), data.end())); + REQUIRE(json["seed"].get() == state.seed.GetHex()); + REQUIRE(json["counter"].get() == 0); + } + + SECTION("GenerateNextTokenId increments counter and persists") { + TokenGenerator gen(test_dir); + uint256 id1 = gen.GenerateNextTokenId(); + + REQUIRE(gen.GetCounter() == 1); + REQUIRE(gen.GetState().counter == 1); + + auto json = readStateJSON(state_file); + REQUIRE(json["counter"].get() == 1); + + auto id2 = gen.GenerateNextTokenId(); + REQUIRE(gen.GetCounter() == 2); + REQUIRE(id1 != id2); + } + + SECTION("Loading existing state works") { + uint256 manual_seed; + manual_seed.SetHex("deadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef"); + uint64_t manual_counter = 42; + + { + nlohmann::json json; + json["seed"] = manual_seed.GetHex(); + json["counter"] = manual_counter; + std::ofstream f(test_dir / "miner_state.json"); + f << json.dump() << std::endl; + } + + TokenGenerator gen(test_dir); + REQUIRE(gen.GetCounter() == 42); + auto state = gen.GetState(); + REQUIRE(state.seed == manual_seed); + REQUIRE(state.counter == 42); + + auto id = gen.GenerateNextTokenId(); + REQUIRE(gen.GetCounter() == 43); + + uint8_t counter_le[8]; + endian::WriteLE64(counter_le, 43); + uint256 expected_id; + CSHA256().Write(manual_seed.begin(), manual_seed.size()).Write(counter_le, 8).Finalize(expected_id.begin()); + REQUIRE(id == expected_id); + } +} diff --git a/test/unit/chain/trust_base_manager_tests.cpp b/test/unit/chain/trust_base_manager_tests.cpp new file mode 100644 index 0000000..2f9b67a --- /dev/null +++ b/test/unit/chain/trust_base_manager_tests.cpp @@ -0,0 +1,70 @@ +#include "chain/trust_base_manager.hpp" +#include "util/string_parsing.hpp" + +#include "catch_amalgamated.hpp" +#include "common/mock_bft_client.hpp" +#include "common/test_trust_base_data.hpp" +#include "common/test_util.hpp" + +#include + +using namespace unicity; +using namespace unicity::chain; +using namespace unicity::test; + +namespace { + +RootTrustBaseV1 ParseHex(const std::string_view hex) { + return RootTrustBaseV1::FromCBOR(util::ParseHex(hex)); +} + +} // namespace + +TEST_CASE("TrustBaseManager tests", "[chain][trustbase]") { + TempDir temp_dir("trustbase_manager_test"); + const auto& test_dir = temp_dir.path; + + RootTrustBaseV1 tb1 = ParseHex(epoch1_cbor); + RootTrustBaseV1 tb2 = ParseHex(epoch2_cbor); + + SECTION("Process_HigherEpoch_UpdatesLatest") { + LocalTrustBaseManager manager(test_dir, std::make_shared()); + REQUIRE(manager.ProcessTrustBase(tb1).has_value()); + + REQUIRE(manager.ProcessTrustBase(tb2).has_value()); + auto latest = manager.GetLatestTrustBase(); + REQUIRE(latest.has_value()); + REQUIRE(latest->epoch == 2); + } + + SECTION("Process_LowerEpoch_Ignored") { + LocalTrustBaseManager manager(test_dir, std::make_shared()); + REQUIRE(manager.ProcessTrustBase(tb1).has_value()); + REQUIRE(manager.ProcessTrustBase(tb2).has_value()); + REQUIRE_FALSE(manager.ProcessTrustBase(tb1).has_value()); + + auto latest = manager.GetLatestTrustBase(); + REQUIRE(latest->epoch == 2); + } + + SECTION("Load_MultipleFiles_SetsCorrectLatest") { + { + // Create a manager, process two epochs to save them to disk + LocalTrustBaseManager manager(test_dir, std::make_shared()); + REQUIRE(manager.ProcessTrustBase(tb1).has_value()); + REQUIRE(manager.ProcessTrustBase(tb2).has_value()); + } + + // Create a new manager instance and load from the same directory + LocalTrustBaseManager manager2(test_dir, std::make_shared()); + REQUIRE_NOTHROW(manager2.Load()); + + auto latest = manager2.GetLatestTrustBase(); + REQUIRE(latest.has_value()); + REQUIRE(latest->epoch == 2); + + auto e1 = manager2.GetTrustBase(1); + REQUIRE(e1.has_value()); + REQUIRE(e1->epoch == 1); + } +} diff --git a/test/unit/chain/trust_base_tests.cpp b/test/unit/chain/trust_base_tests.cpp new file mode 100644 index 0000000..b4201df --- /dev/null +++ b/test/unit/chain/trust_base_tests.cpp @@ -0,0 +1,97 @@ +#include "chain/trust_base.hpp" +#include "util/sha256.hpp" +#include "util/string_parsing.hpp" + +#include "catch_amalgamated.hpp" +#include "common/test_trust_base_data.hpp" + +#include +#include + +using namespace unicity; +using namespace unicity::chain; +using namespace unicity::test; + +namespace { +RootTrustBaseV1 ParseTB(std::string_view hex) { + std::vector data = util::ParseHex(hex); + const nlohmann::json j = nlohmann::json::from_cbor(data, true, true, nlohmann::json::cbor_tag_handler_t::ignore); + RootTrustBaseV1 tb; + from_json(j, tb); + return tb; +} +} // namespace + +TEST_CASE("Trust Base Tests", "[chain][trustbase]") { + SECTION("Verify Epoch 1") { + RootTrustBaseV1 tb = ParseTB(epoch1_cbor); + REQUIRE(tb.epoch == 1); + REQUIRE(util::ToHex(tb.Hash()) == epoch1_hash); + REQUIRE(util::ToHex(tb.ToCBOR()) == epoch1_cbor); + REQUIRE(tb.Verify(std::nullopt)); + } + + SECTION("Verify Epoch 2") { + RootTrustBaseV1 tb1 = ParseTB(epoch1_cbor); + RootTrustBaseV1 tb2 = ParseTB(epoch2_cbor); + REQUIRE(tb2.epoch == 2); + REQUIRE(util::ToHex(tb2.Hash()) == epoch2_hash); + REQUIRE(util::ToHex(tb2.ToCBOR()) == epoch2_cbor); + REQUIRE(tb2.Verify(tb1)); + } + + SECTION("Verify Epoch 3") { + RootTrustBaseV1 tb2 = ParseTB(epoch2_cbor); + RootTrustBaseV1 tb3 = ParseTB(epoch3_cbor); + REQUIRE(tb3.epoch == 3); + REQUIRE(util::ToHex(tb3.Hash()) == epoch3_hash); + REQUIRE(util::ToHex(tb3.ToCBOR()) == epoch3_cbor); + REQUIRE(tb3.Verify(tb2)); + } + + SECTION("Verify Epoch 3 Invalid (Insufficent signatures)") { + RootTrustBaseV1 tb2 = ParseTB(epoch2_cbor); + RootTrustBaseV1 tb3_inv = ParseTB(epoch3_invalid_cbor); + REQUIRE(tb3_inv.epoch == 3); + REQUIRE(util::ToHex(tb3_inv.Hash()) == epoch3_invalid_hash); + REQUIRE(util::ToHex(tb3_inv.ToCBOR()) == epoch3_invalid_cbor); + // Should fail because it only has 1 signature but threshold is 4 + REQUIRE_FALSE(tb3_inv.Verify(tb2)); + } + + SECTION("IsValid checks") { + RootTrustBaseV1 tb = ParseTB(epoch1_cbor); + + SECTION("Reject zero quorum threshold") { + tb.quorum_threshold = 0; + REQUIRE_FALSE(tb.IsValid(std::nullopt)); + } + + SECTION("Reject empty root nodes") { + tb.root_nodes.clear(); + REQUIRE_FALSE(tb.IsValid(std::nullopt)); + } + + SECTION("Reject stake overflow") { + tb.root_nodes.push_back({"huge", {}, std::numeric_limits::max()}); + tb.root_nodes.push_back({"more", {}, 1}); + REQUIRE_FALSE(tb.IsValid(std::nullopt)); + } + + SECTION("Reject impossible quorum threshold") { + uint64_t total = 0; + for (const auto& n : tb.root_nodes) total += n.stake; + tb.quorum_threshold = total + 1; + REQUIRE_FALSE(tb.IsValid(std::nullopt)); + } + + SECTION("Reject epoch mismatch for genesis") { + tb.epoch = 2; + REQUIRE_FALSE(tb.IsValid(std::nullopt)); + } + + SECTION("Valid genesis") { + REQUIRE(tb.IsValid(std::nullopt)); + } + } +} diff --git a/test/unit/chain/validation_tests.cpp b/test/unit/chain/validation_tests.cpp index 17a6653..f8d9355 100644 --- a/test/unit/chain/validation_tests.cpp +++ b/test/unit/chain/validation_tests.cpp @@ -20,8 +20,11 @@ #include "chain/pow.hpp" #include "chain/randomx_pow.hpp" #include "network/protocol.hpp" +#include "util/hash.hpp" #include "util/time.hpp" +#include "util/string_parsing.hpp" #include "common/test_chainstate_manager.hpp" +#include "common/test_trust_base_data.hpp" #include using namespace unicity; @@ -38,7 +41,14 @@ static CBlockHeader CreateTestHeader(uint32_t nTime = 1234567890, uint32_t nBits CBlockHeader header; header.nVersion = 1; header.hashPrevBlock.SetNull(); - header.minerAddress.SetNull(); + + uint256 token_id; + token_id.SetHex("0000000000000000000000000000000000000000000000000000000000000001"); + uint256 leaf_0 = SingleHash(token_id); + uint256 leaf_1 = uint256::ZERO; + header.payloadRoot = CBlockHeader::ComputePayloadRoot(leaf_0, leaf_1); + header.vPayload.assign(leaf_0.begin(), leaf_0.end()); + header.nTime = nTime; header.nBits = nBits; header.nNonce = nNonce; @@ -46,6 +56,18 @@ static CBlockHeader CreateTestHeader(uint32_t nTime = 1234567890, uint32_t nBits return header; } +static bool MineBlockHeader(CBlockHeader& h, const chain::ChainParams& params) { + uint256 out_hash; + for (uint32_t nonce = 0; nonce < 1000000; ++nonce) { + h.nNonce = nonce; + if (consensus::CheckProofOfWork(h, h.nBits, params, crypto::POWVerifyMode::MINING, &out_hash)) { + h.hashRandomX = out_hash; + return true; + } + } + return false; +} + // ============================================================================= // Section 1: ValidationState // ============================================================================= @@ -327,16 +349,79 @@ TEST_CASE("CBlockIndex::GetMedianTimePast - median time calculation", "[validati // Section 6: Block Header Validation // ============================================================================= +TEST_CASE("CheckBlockHeader - payload validation", "[validation][payload]") { + crypto::InitRandomX(); + auto params = ChainParams::CreateRegTest(); + ValidationState state; + MockTrustBaseManager tbm; + + SECTION("Accepts payload of exactly 32 bytes") { + CBlockHeader h = CreateTestHeader(); + h.vPayload.assign(32, 0x42); + + uint256 leaf_0; + std::memcpy(leaf_0.begin(), h.vPayload.data(), 32); + h.payloadRoot = CBlockHeader::ComputePayloadRoot(leaf_0, uint256::ZERO); + + REQUIRE(MineBlockHeader(h, *params)); + bool result = CheckBlockHeader(h, *params, state, tbm); + CAPTURE(state.GetRejectReason()); + REQUIRE(result); + } + + SECTION("Accepts payload containing valid UTB") { + CBlockHeader h = CreateTestHeader(); + + // Sample UTB from epoch 1 in test_trust_base_data.hpp + std::vector utb_bytes = util::ParseHex(unicity::test::epoch1_cbor); + + // Payload = 32 bytes Token ID + UTB CBOR + h.vPayload.assign(32, 0x42); + h.vPayload.insert(h.vPayload.end(), utb_bytes.begin(), utb_bytes.end()); + + uint256 leaf_0; + std::memcpy(leaf_0.begin(), h.vPayload.data(), 32); + uint256 leaf_1 = SingleHash(std::span(h.vPayload.data() + 32, h.vPayload.size() - 32)); + h.payloadRoot = CBlockHeader::ComputePayloadRoot(leaf_0, leaf_1); + + REQUIRE(MineBlockHeader(h, *params)); + bool result = CheckBlockHeader(h, *params, state, tbm); + CAPTURE(state.GetRejectReason()); + REQUIRE(result); + } + + SECTION("Rejects payload < 32 bytes") { + CBlockHeader h = CreateTestHeader(); + h.vPayload.assign(31, 0x42); + + bool result = CheckBlockHeader(h, *params, state, tbm); + REQUIRE_FALSE(result); + REQUIRE(state.GetRejectReason() == "bad-payload-size"); + REQUIRE(state.GetDebugMessage().find("missing Token ID hash") != std::string::npos); + } + + SECTION("Rejects payload > MAX_PAYLOAD_SIZE") { + CBlockHeader h = CreateTestHeader(); + h.vPayload.assign(CBlockHeader::MAX_PAYLOAD_SIZE + 1, 0x42); + + bool result = CheckBlockHeader(h, *params, state, tbm); + REQUIRE_FALSE(result); + REQUIRE(state.GetRejectReason() == "bad-payload-size"); + REQUIRE(state.GetDebugMessage().find("exceeds maximum size") != std::string::npos); + } +} + TEST_CASE("CheckBlockHeader - version validation", "[validation][version]") { auto params = ChainParams::CreateRegTest(); ValidationState state; + MockTrustBaseManager mock_tbm; SECTION("Accepts version >= MIN_BLOCK_VERSION (1)") { CBlockHeader h = CreateTestHeader(); h.nVersion = 1; h.hashRandomX = uint256S("0x1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef"); - bool result = CheckBlockHeader(h, *params, state); + bool result = CheckBlockHeader(h, *params, state, mock_tbm); if (!result) { REQUIRE(state.GetRejectReason() != "bad-version"); } @@ -347,7 +432,7 @@ TEST_CASE("CheckBlockHeader - version validation", "[validation][version]") { h.nVersion = 2; h.hashRandomX = uint256S("0x1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef"); - bool result = CheckBlockHeader(h, *params, state); + bool result = CheckBlockHeader(h, *params, state, mock_tbm); if (!result) { REQUIRE(state.GetRejectReason() != "bad-version"); } @@ -357,7 +442,7 @@ TEST_CASE("CheckBlockHeader - version validation", "[validation][version]") { CBlockHeader h = CreateTestHeader(); h.nVersion = 0; - bool result = CheckBlockHeader(h, *params, state); + bool result = CheckBlockHeader(h, *params, state, mock_tbm); REQUIRE_FALSE(result); REQUIRE(state.GetRejectReason() == "bad-version"); REQUIRE(state.GetDebugMessage().find("version too old") != std::string::npos); @@ -367,7 +452,7 @@ TEST_CASE("CheckBlockHeader - version validation", "[validation][version]") { CBlockHeader h = CreateTestHeader(); h.nVersion = -1; - bool result = CheckBlockHeader(h, *params, state); + bool result = CheckBlockHeader(h, *params, state, mock_tbm); REQUIRE_FALSE(result); REQUIRE(state.GetRejectReason() == "bad-version"); } @@ -376,13 +461,14 @@ TEST_CASE("CheckBlockHeader - version validation", "[validation][version]") { TEST_CASE("CheckBlockHeader - null hashRandomX validation", "[validation][pow]") { auto params = ChainParams::CreateRegTest(); ValidationState state; + MockTrustBaseManager mock_tbm; SECTION("Rejects header with null hashRandomX") { CBlockHeader h = CreateTestHeader(); h.nVersion = 1; h.hashRandomX.SetNull(); - bool result = CheckBlockHeader(h, *params, state); + bool result = CheckBlockHeader(h, *params, state, mock_tbm); REQUIRE_FALSE(result); REQUIRE(state.GetRejectReason() == "bad-randomx-hash"); REQUIRE(state.GetDebugMessage().find("missing RandomX hash") != std::string::npos); @@ -393,7 +479,7 @@ TEST_CASE("CheckBlockHeader - null hashRandomX validation", "[validation][pow]") h.nVersion = 1; h.hashRandomX = uint256S("0x1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef"); - bool result = CheckBlockHeader(h, *params, state); + bool result = CheckBlockHeader(h, *params, state, mock_tbm); if (!result) { REQUIRE(state.GetRejectReason() != "bad-randomx-hash"); } @@ -554,7 +640,14 @@ TEST_CASE("CheckHeadersPoW - Direct validation function tests", "[validation][po CBlockHeader header; header.nVersion = 1; header.hashPrevBlock.SetNull(); - header.minerAddress.SetNull(); + + uint256 token_id; + token_id.SetHex("0000000000000000000000000000000000000000000000000000000000000001"); + uint256 leaf_0 = SingleHash(token_id); + uint256 leaf_1 = uint256::ZERO; + header.payloadRoot = CBlockHeader::ComputePayloadRoot(leaf_0, leaf_1); + header.vPayload.assign(leaf_0.begin(), leaf_0.end()); + header.nTime = 1234567890; header.nBits = 0x207fffff; header.nNonce = 0; @@ -580,7 +673,14 @@ TEST_CASE("CheckHeadersPoW - Direct validation function tests", "[validation][po CBlockHeader header; header.nVersion = 1; header.hashPrevBlock.SetNull(); - header.minerAddress.SetNull(); + + uint256 token_id; + token_id.SetHex("0000000000000000000000000000000000000000000000000000000000000001"); + uint256 leaf_0 = SingleHash(token_id); + uint256 leaf_1 = uint256::ZERO; + header.payloadRoot = CBlockHeader::ComputePayloadRoot(leaf_0, leaf_1); + header.vPayload.assign(leaf_0.begin(), leaf_0.end()); + header.nTime = 1234567890; header.nBits = 0x207fffff; header.nNonce = 0; @@ -611,7 +711,7 @@ TEST_CASE("Validation - integration test", "[validation]") { CBlockHeader header = CreateTestHeader(); auto serialized = header.Serialize(); - REQUIRE(serialized.size() == 100); + REQUIRE(serialized.size() == 112); CBlockHeader header2; REQUIRE(header2.Deserialize(serialized.data(), serialized.size())); diff --git a/test/unit/network/message_tests.cpp b/test/unit/network/message_tests.cpp index 4b464e2..7c7ac3c 100644 --- a/test/unit/network/message_tests.cpp +++ b/test/unit/network/message_tests.cpp @@ -1167,3 +1167,70 @@ TEST_CASE("VarInt - Non-Canonical Encoding Rejection (CVE-2018-17144 class)", "[ } } } + +// ============================================================================ +// HEADERS Message Tests +// ============================================================================ + +TEST_CASE("HeadersMessage - Serialization Round Trip", "[network][message][headers][unit]") { + HeadersMessage msg; + + // Create a mock header with a 32-byte payload (Token ID hash) + CBlockHeader h1; + h1.nVersion = 1; + h1.vPayload.assign(32, 0xAA); + msg.headers.push_back(h1); + + // Create a mock header with a larger payload (Token ID + UTB) + CBlockHeader h2; + h2.nVersion = 2; + h2.vPayload.assign(100, 0xBB); + msg.headers.push_back(h2); + + auto data = msg.serialize(); + + HeadersMessage msg2; + REQUIRE(msg2.deserialize(data.data(), data.size())); + REQUIRE(msg2.headers.size() == 2); + + REQUIRE(msg2.headers[0].nVersion == 1); + REQUIRE(msg2.headers[0].vPayload.size() == 32); + REQUIRE(msg2.headers[0].vPayload[0] == 0xAA); + + REQUIRE(msg2.headers[1].nVersion == 2); + REQUIRE(msg2.headers[1].vPayload.size() == 100); + REQUIRE(msg2.headers[1].vPayload[0] == 0xBB); +} + +TEST_CASE("HeadersMessage - Empty", "[network][message][headers][unit]") { + HeadersMessage msg; + auto data = msg.serialize(); + REQUIRE(data.size() == 1); // Just varint 0 + + HeadersMessage msg2; + REQUIRE(msg2.deserialize(data.data(), data.size())); + REQUIRE(msg2.headers.empty()); +} + +TEST_CASE("HeadersMessage - Security Limits", "[network][message][headers][dos][unit]") { + SECTION("Block size too small (< 112)") { + MessageSerializer s; + s.write_varint(1); // 1 header + s.write_varint(100); // Invalid size (too small) + std::vector dummy(100, 0); + s.write_bytes(dummy); + + HeadersMessage msg; + REQUIRE_FALSE(msg.deserialize(s.data().data(), s.data().size())); + } + + SECTION("Block size too large") { + MessageSerializer s; + s.write_varint(1); + s.write_varint(MAX_PROTOCOL_MESSAGE_LENGTH + 1); + + HeadersMessage msg; + REQUIRE_FALSE(msg.deserialize(s.data().data(), s.data().size())); + } +} + diff --git a/test/unit/util/header_serialization_tests.cpp b/test/unit/util/header_serialization_tests.cpp index 9b956de..e0ffcda 100644 --- a/test/unit/util/header_serialization_tests.cpp +++ b/test/unit/util/header_serialization_tests.cpp @@ -7,7 +7,7 @@ #include #include -TEST_CASE("CBlockHeader fuzz: random 100-byte round-trip preserves bytes", "[block][serialize][fuzz]") { +TEST_CASE("CBlockHeader fuzz: random 112-byte round-trip preserves bytes", "[block][serialize][fuzz]") { std::mt19937 rng(0xC01DBA5Eu); std::uniform_int_distribution dist(0, 255); @@ -42,12 +42,13 @@ TEST_CASE("CBlockHeader deserialization strict size checks (truncated/oversized) REQUIRE(h.Deserialize(v.data(), v.size())); } - // Oversized buffers must be rejected regardless of content - const size_t oversizes[] = {101, 128, 256, 1000}; + // Oversized buffers are now accepted (they populate vPayload) + const size_t oversizes[] = {113, 128, 256, 1000}; for (size_t sz : oversizes) { std::vector v(sz, 0xBB); CBlockHeader h; - REQUIRE_FALSE(h.Deserialize(v.data(), v.size())); + REQUIRE(h.Deserialize(v.data(), v.size())); + REQUIRE(h.vPayload.size() == (sz - CBlockHeader::HEADER_SIZE)); } } @@ -60,9 +61,7 @@ static CBlockHeader MakeBaselineHeader() { for (int i = 0; i < 32; ++i) { base.hashPrevBlock.begin()[i] = static_cast(i); base.hashRandomX.begin()[i] = static_cast(255 - i); - } - for (int i = 0; i < 20; ++i) { - base.minerAddress.begin()[i] = static_cast(0xAA + i); + base.payloadRoot.begin()[i] = static_cast(0xAA + i); } return base; } @@ -101,9 +100,9 @@ TEST_CASE("CBlockHeader corrupted-field cases flip single byte in each field", " REQUIRE(h2.hashPrevBlock != base.hashPrevBlock); }); - // minerAddress (first byte) - mutate_and_check(CBlockHeader::OFF_MINER, "minerAddress", [&](const CBlockHeader& h2){ - REQUIRE(h2.minerAddress != base.minerAddress); + // payloadRoot (first byte) + mutate_and_check(CBlockHeader::OFF_PAYLOAD_ROOT, "payloadRoot", [&](const CBlockHeader& h2){ + REQUIRE(h2.payloadRoot != base.payloadRoot); }); // nTime @@ -147,7 +146,7 @@ TEST_CASE("CBlockHeader extreme/invalid scalar values (nVersion/nBits) round-tri h.nBits = bits; h.nNonce = 0xAABBCCDDu; h.hashPrevBlock.SetNull(); - h.minerAddress.SetNull(); + h.payloadRoot.SetNull(); h.hashRandomX.SetNull(); auto bytes = h.Serialize(); diff --git a/test/unit/util/persistence_tests.cpp b/test/unit/util/persistence_tests.cpp index 77fa3ae..ae8ca75 100644 --- a/test/unit/util/persistence_tests.cpp +++ b/test/unit/util/persistence_tests.cpp @@ -64,7 +64,7 @@ TEST_CASE("BlockManager persistence", "[persistence][chain]") { CBlockHeader header; header.nVersion = 1; header.hashPrevBlock = prev_header.GetHash(); - header.minerAddress.SetHex("0000000000000000000000000000000000000001"); + header.payloadRoot.SetHex("0000000000000000000000000000000000000001"); header.nTime = genesis.nTime + i * 600; // 10 minutes apart header.nBits = genesis.nBits; header.nNonce = i; @@ -151,7 +151,7 @@ TEST_CASE("BlockManager persistence", "[persistence][chain]") { CBlockHeader header; header.nVersion = 1; header.hashPrevBlock = genesis.GetHash(); - header.minerAddress.SetHex("0000000000000000000000000000000000000001"); + header.payloadRoot.SetHex("0000000000000000000000000000000000000001"); header.nTime = genesis.nTime + 600; header.nBits = genesis.nBits; header.nNonce = 1; diff --git a/test/unit/util/threading_tests.cpp b/test/unit/util/threading_tests.cpp index 93a1bdf..c466886 100644 --- a/test/unit/util/threading_tests.cpp +++ b/test/unit/util/threading_tests.cpp @@ -9,11 +9,14 @@ #include "chain/randomx_pow.hpp" #include "chain/pow.hpp" #include "util/arith_uint256.hpp" +#include "util/hash.hpp" #include #include #include #include +#include "mock_trust_base_manager.hpp" + using namespace unicity; TEST_CASE("ChainstateManager thread safety", "[validation][threading]") { @@ -22,7 +25,8 @@ TEST_CASE("ChainstateManager thread safety", "[validation][threading]") { // Create test environment auto params = chain::ChainParams::CreateRegTest(); - validation::ChainstateManager chainstate(*params); + test::MockTrustBaseManager mock_tbm; + validation::ChainstateManager chainstate(*params, mock_tbm); // Initialize with genesis CBlockHeader genesis = params->GenesisBlock(); @@ -53,6 +57,14 @@ TEST_CASE("ChainstateManager thread safety", "[validation][threading]") { // Create a block extending current tip CBlockHeader header; header.nVersion = 1; + + uint256 token_id; + token_id.SetHex("0000000000000000000000000000000000000000000000000000000000000001"); + uint256 leaf_0 = SingleHash(token_id); + uint256 leaf_1 = uint256::ZERO; + header.payloadRoot = CBlockHeader::ComputePayloadRoot(leaf_0, leaf_1); + header.vPayload.assign(leaf_0.begin(), leaf_0.end()); + header.nTime = static_cast(std::time(nullptr)) + thread_id * 100 + i; header.nBits = params->GenesisBlock().nBits; header.nNonce = 0; @@ -71,7 +83,8 @@ TEST_CASE("ChainstateManager thread safety", "[validation][threading]") { while (true) { CBlockHeader tmp(header); tmp.hashRandomX.SetNull(); - randomx_calculate_hash(vmWrapper->vm, &tmp, sizeof(tmp), rx_hash); + auto serialized = tmp.Serialize(false); + randomx_calculate_hash(vmWrapper->vm, serialized.data(), serialized.size(), rx_hash); randomx_hash = uint256(std::vector(rx_hash, rx_hash + RANDOMX_HASH_SIZE)); if (UintToArith256(crypto::GetRandomXCommitment(header, &randomx_hash)) <= @@ -144,6 +157,14 @@ chain::CBlockIndex* pindex = chainstate.AcceptBlockHeader(header, state); for (int i = 0; i < 3; i++) { // Reduced from 10 to 3 for faster test with interpreter mode CBlockHeader header; header.nVersion = 1; + + uint256 token_id; + token_id.SetHex("0000000000000000000000000000000000000000000000000000000000000001"); + uint256 leaf_0 = SingleHash(token_id); + uint256 leaf_1 = uint256::ZERO; + header.payloadRoot = CBlockHeader::ComputePayloadRoot(leaf_0, leaf_1); + header.vPayload.assign(leaf_0.begin(), leaf_0.end()); + header.nTime = static_cast(std::time(nullptr)) + i; header.nBits = params->GenesisBlock().nBits; header.nNonce = 0; @@ -195,6 +216,14 @@ chain::CBlockIndex* pindex = chainstate.AcceptBlockHeader(header, state); for (int i = 0; i < 2; i++) { // Reduced from 20 to 2 for faster test with interpreter mode CBlockHeader header; header.nVersion = 1; + + uint256 token_id; + token_id.SetHex("0000000000000000000000000000000000000000000000000000000000000001"); + uint256 leaf_0 = SingleHash(token_id); + uint256 leaf_1 = uint256::ZERO; + header.payloadRoot = CBlockHeader::ComputePayloadRoot(leaf_0, leaf_1); + header.vPayload.assign(leaf_0.begin(), leaf_0.end()); + header.nTime = static_cast(std::time(nullptr)) + i; header.nBits = params->GenesisBlock().nBits; header.nNonce = 0; diff --git a/test/wire/node_simulator.cpp b/test/wire/node_simulator.cpp index e4450eb..2f51d87 100644 --- a/test/wire/node_simulator.cpp +++ b/test/wire/node_simulator.cpp @@ -125,7 +125,7 @@ class NodeSimulator { CBlockHeader header; header.nVersion = 1; header.hashPrevBlock = prev_hash; - header.minerAddress.SetNull(); + header.payloadRoot.SetNull(); header.nTime = std::time(nullptr); header.nBits = 0x00000001; // Impossible difficulty header.nNonce = 0; @@ -156,7 +156,7 @@ class NodeSimulator { CBlockHeader header; header.nVersion = 1; header.hashPrevBlock.SetNull(); - header.minerAddress.SetNull(); + header.payloadRoot.SetNull(); header.nTime = std::time(nullptr); header.nBits = 0x207fffff; header.nNonce = 0; @@ -190,7 +190,7 @@ class NodeSimulator { CBlockHeader header1; header1.nVersion = 1; header1.hashPrevBlock = prev_hash; - header1.minerAddress.SetNull(); + header1.payloadRoot.SetNull(); header1.nTime = std::time(nullptr); header1.nBits = 0x207fffff; header1.nNonce = 1; @@ -199,7 +199,7 @@ class NodeSimulator { CBlockHeader header2; header2.nVersion = 1; header2.hashPrevBlock.SetNull(); // Wrong! Doesn't connect to header1 - header2.minerAddress.SetNull(); + header2.payloadRoot.SetNull(); header2.nTime = std::time(nullptr); header2.nBits = 0x207fffff; header2.nNonce = 2; @@ -329,8 +329,8 @@ class NodeSimulator { std::cout << "\n=== TEST: Length Less Than Actual ===" << std::endl; std::cout << "Sending message with declared length < actual payload..." << std::endl; - // Build a 100-byte payload but declare only 10 bytes - std::vector payload(100, 0x41); // 'A' bytes + // Build a 112-byte payload but declare only 10 bytes + std::vector payload(112, 0x41); // 'A' bytes std::vector hdr_bytes(protocol::MESSAGE_HEADER_SIZE); uint32_t magic = protocol::magic::REGTEST; @@ -339,7 +339,7 @@ class NodeSimulator { const char* cmd = "headers"; std::memset(hdr_bytes.data() + 4, 0, 12); std::memcpy(hdr_bytes.data() + 4, cmd, strlen(cmd)); - // Declare only 10 bytes (less than actual 100) + // Declare only 10 bytes (less than actual 112) uint32_t len = 10; std::memcpy(hdr_bytes.data() + 16, &len, 4); // Checksum of the DECLARED portion only @@ -349,7 +349,7 @@ class NodeSimulator { std::vector full; full.insert(full.end(), hdr_bytes.begin(), hdr_bytes.end()); - full.insert(full.end(), payload.begin(), payload.end()); // Send full 100 bytes + full.insert(full.end(), payload.begin(), payload.end()); // Send full 112 bytes asio::write(socket_, asio::buffer(full)); std::cout << "Expected: Node reads declared length, extra bytes interpreted as next message" << std::endl; @@ -403,7 +403,7 @@ class NodeSimulator { CBlockHeader header; header.nVersion = 1; header.hashPrevBlock.SetNull(); - header.minerAddress.SetNull(); + header.payloadRoot.SetNull(); header.nTime = std::time(nullptr); header.nBits = 0x207fffff; header.nNonce = 0; @@ -505,14 +505,14 @@ class NodeSimulator { std::cout << "\n=== TEST: Partial VERSION ===" << std::endl; std::cout << "Sending incomplete VERSION message then closing..." << std::endl; - // Build a partial VERSION - just first 20 bytes of what should be ~100 bytes - std::vector partial_payload(20, 0x00); + // Build a partial VERSION - just first 32 bytes of what should be ~112 bytes + std::vector partial_payload(32, 0x00); // Set version field int32_t version = protocol::PROTOCOL_VERSION; std::memcpy(partial_payload.data(), &version, 4); // Build header with correct length for full message but only send partial - protocol::MessageHeader hdr(protocol::magic::REGTEST, protocol::commands::VERSION, 100); // Claim 100 bytes + protocol::MessageHeader hdr(protocol::magic::REGTEST, protocol::commands::VERSION, 112); // Claim 112 bytes uint256 hash = Hash(partial_payload); std::memcpy(hdr.checksum.data(), hash.begin(), 4); auto hdr_bytes = message::serialize_header(hdr); @@ -704,7 +704,7 @@ class NodeSimulator { CBlockHeader header; header.nVersion = 1; header.hashPrevBlock.SetNull(); - header.minerAddress.SetNull(); + header.payloadRoot.SetNull(); header.nTime = std::time(nullptr) + 3600; // 1 hour in future (exceeds 10 min limit) header.nBits = 0x207fffff; // Regtest difficulty header.nNonce = 0; @@ -727,7 +727,7 @@ class NodeSimulator { CBlockHeader header; header.nVersion = 1; header.hashPrevBlock.SetNull(); - header.minerAddress.SetNull(); + header.payloadRoot.SetNull(); header.nTime = 0; // Invalid - timestamp zero header.nBits = 0x207fffff; header.nNonce = 0; @@ -750,7 +750,7 @@ class NodeSimulator { CBlockHeader header; header.nVersion = 1; header.hashPrevBlock.SetNull(); - header.minerAddress.SetNull(); + header.payloadRoot.SetNull(); header.nTime = std::time(nullptr); header.nBits = 0; // Invalid - impossible difficulty header.nNonce = 0; @@ -773,7 +773,7 @@ class NodeSimulator { CBlockHeader header; header.nVersion = 1; header.hashPrevBlock.SetNull(); - header.minerAddress.SetNull(); + header.payloadRoot.SetNull(); header.nTime = std::time(nullptr); header.nBits = 0xFFFFFFFF; // Invalid - trivial difficulty header.nNonce = 0; @@ -797,7 +797,7 @@ class NodeSimulator { CBlockHeader header; header.nVersion = 1; header.hashPrevBlock.SetNull(); - header.minerAddress.SetNull(); + header.payloadRoot.SetNull(); header.nTime = std::time(nullptr); header.nBits = 0x207fffff; header.nNonce = 12345; @@ -828,7 +828,7 @@ class NodeSimulator { // Create header A pointing to a fake "B" hash CBlockHeader headerA; headerA.nVersion = 1; - headerA.minerAddress.SetNull(); + headerA.payloadRoot.SetNull(); headerA.nTime = std::time(nullptr); headerA.nBits = 0x207fffff; headerA.nNonce = 1; @@ -837,7 +837,7 @@ class NodeSimulator { // Create header B CBlockHeader headerB; headerB.nVersion = 1; - headerB.minerAddress.SetNull(); + headerB.payloadRoot.SetNull(); headerB.nTime = std::time(nullptr); headerB.nBits = 0x207fffff; headerB.nNonce = 2; @@ -870,7 +870,7 @@ class NodeSimulator { CBlockHeader header; header.nVersion = 0; // Invalid version header.hashPrevBlock.SetNull(); - header.minerAddress.SetNull(); + header.payloadRoot.SetNull(); header.nTime = std::time(nullptr); header.nBits = 0x207fffff; header.nNonce = 0; @@ -893,7 +893,7 @@ class NodeSimulator { CBlockHeader header; header.nVersion = -1; // Negative version (will be interpreted as large unsigned) header.hashPrevBlock.SetNull(); - header.minerAddress.SetNull(); + header.payloadRoot.SetNull(); header.nTime = std::time(nullptr); header.nBits = 0x207fffff; header.nNonce = 0; @@ -927,7 +927,7 @@ class NodeSimulator { std::memcpy(random_prev.begin() + j * 8, &r, 8); } header.hashPrevBlock = random_prev; - header.minerAddress.SetNull(); + header.payloadRoot.SetNull(); header.nTime = std::time(nullptr); header.nBits = 0x207fffff; header.nNonce = static_cast(i); @@ -1239,9 +1239,9 @@ class NodeSimulator { // Attack: PING with oversized payload void test_ping_oversized() { std::cout << "\n=== TEST: PING Oversized ===" << std::endl; - std::cout << "Sending PING with 100-byte payload (should be 8)..." << std::endl; + std::cout << "Sending PING with 112-byte payload (should be 8)..." << std::endl; - std::vector payload(100, 0x42); // 100 bytes instead of 8 + std::vector payload(112, 0x42); // 112 bytes instead of 8 send_raw_message(protocol::commands::PING, payload); std::cout << "Expected: Node should reject or ignore" << std::endl; diff --git a/tools/genesis_miner/CMakeLists.txt b/tools/genesis_miner/CMakeLists.txt index f4805af..2190346 100644 --- a/tools/genesis_miner/CMakeLists.txt +++ b/tools/genesis_miner/CMakeLists.txt @@ -52,12 +52,6 @@ include_directories( # Create genesis miner executable add_executable(genesis_miner genesis_miner.cpp - ${UNICITY_ROOT}/src/util/sha256.cpp - ${UNICITY_ROOT}/src/util/uint.cpp - ${UNICITY_ROOT}/src/util/arith_uint256.cpp - ${UNICITY_ROOT}/src/chain/randomx_pow.cpp - ${UNICITY_ROOT}/src/chain/block.cpp - ${UNICITY_ROOT}/src/util/logging.cpp ) # Add randomx include directory @@ -69,6 +63,8 @@ endif() target_compile_definitions(genesis_miner PRIVATE DISABLE_OPTIMIZED_SHA256) target_link_libraries(genesis_miner PRIVATE + chain + util Threads::Threads randomx spdlog::spdlog diff --git a/tools/genesis_miner/genesis_miner.cpp b/tools/genesis_miner/genesis_miner.cpp index 00707bc..4bf47b6 100644 --- a/tools/genesis_miner/genesis_miner.cpp +++ b/tools/genesis_miner/genesis_miner.cpp @@ -2,8 +2,10 @@ // Genesis Block Miner - Finds nonce for genesis block using RandomX #include "chain/block.hpp" +#include "chain/chainparams.hpp" #include "util/sha256.hpp" #include "util/arith_uint256.hpp" +#include "util/string_parsing.hpp" #include "randomx.h" #include #include @@ -11,6 +13,9 @@ #include #include #include +#include + +using namespace unicity; // Mining statistics struct MiningStats { @@ -46,20 +51,20 @@ void MineWorker(randomx_vm* vm, CBlockHeader header, uint32_t start_nonce, uint3 MiningStats& stats, const arith_uint256& target) { uint32_t nonce = start_nonce; + header.hashRandomX.SetNull(); + CBlockHeader::HeaderBytes serialized; + while (!stats.found.load(std::memory_order_acquire)) { header.nNonce = nonce; - // Set hashRandomX to null for hashing - CBlockHeader tmp(header); - tmp.hashRandomX.SetNull(); - - // Calculate RandomX hash + // Calculate RandomX hash using only the 112-byte static header + header.SerializeInto(serialized.data(), serialized.size(), false); char rx_hash[RANDOMX_HASH_SIZE]; - randomx_calculate_hash(vm, &tmp, sizeof(tmp), rx_hash); + randomx_calculate_hash(vm, serialized.data(), serialized.size(), rx_hash); // Calculate commitment: BLAKE2b(block_header || rx_hash) char rx_cm[RANDOMX_HASH_SIZE]; - randomx_calculate_commitment(&tmp, sizeof(tmp), rx_hash, rx_cm); + randomx_calculate_commitment(serialized.data(), serialized.size(), rx_hash, rx_cm); // Convert to uint256 and check against target uint256 commitment = uint256(std::vector(rx_cm, rx_cm + RANDOMX_HASH_SIZE)); @@ -122,6 +127,11 @@ int main(int argc, char* argv[]) { uint32_t nBits = TARGET_BITS; uint32_t nEpochDuration = 7200; // Default: 2 hours (like Unicity) int num_threads = std::thread::hardware_concurrency(); + + // Default to MainNet UTB bytes from parameters + auto mainnet_params = chain::ChainParams::CreateMainNet(); + auto default_utb = mainnet_params->GenesisBlock().GetUTB(); + std::vector utb_cbor(default_utb.begin(), default_utb.end()); for (int i = 1; i < argc; i++) { std::string arg = argv[i]; @@ -133,6 +143,8 @@ int main(int argc, char* argv[]) { nEpochDuration = std::stoul(argv[++i]); } else if (arg == "--threads" && i + 1 < argc) { num_threads = std::stoi(argv[++i]); + } else if (arg == "--utb" && i + 1 < argc) { + utb_cbor = util::ParseHex(argv[++i]); } else if (arg == "--help") { std::cout << "Usage: " << argv[0] << " [options]\n\n"; std::cout << "Options:\n"; @@ -140,6 +152,7 @@ int main(int argc, char* argv[]) { std::cout << " --bits Target difficulty in hex (default: 0x1d00ffff)\n"; std::cout << " --epoch-duration Epoch duration in seconds (default: 7200)\n"; std::cout << " --threads Number of threads (default: " << num_threads << ")\n"; + std::cout << " --utb Custom UTB CBOR record in hex\n"; std::cout << " --help Show this help message\n"; return 0; } @@ -171,12 +184,7 @@ int main(int argc, char* argv[]) { std::cout << "RandomX cache initialized\n"; // Create genesis block header - CBlockHeader genesis; - genesis.nVersion = 1; - genesis.hashPrevBlock.SetNull(); // Genesis has no previous block - genesis.nTime = nTime; - genesis.nBits = nBits; - genesis.nNonce = 0; + CBlockHeader genesis = chain::CreateGenesisBlock(nTime, 0, nBits, utb_cbor, 1); // Calculate target arith_uint256 target = GetTargetFromBits(nBits);