From 0d521686be4c41b3bccf7a830ca8125449261910 Mon Sep 17 00:00:00 2001 From: Vladimir Babin Date: Fri, 1 May 2026 08:07:38 +0300 Subject: [PATCH 01/17] test(docker): add Dockerfile-dev for native-Linux dev shell Adds a toolchain-only Docker image (same base + apt packages as the production builder stage, plus gcovr and gdb) where the worktree is mounted at /workspace. No source is baked in. Includes a README with build/usage/ccache/cleanup instructions. Co-Authored-By: Claude Sonnet 4.6 --- share/vizd/docker/Dockerfile-dev | 72 ++++++++++++++++++++++++++++++ share/vizd/docker/README-dev.md | 75 ++++++++++++++++++++++++++++++++ 2 files changed, 147 insertions(+) create mode 100644 share/vizd/docker/Dockerfile-dev create mode 100644 share/vizd/docker/README-dev.md diff --git a/share/vizd/docker/Dockerfile-dev b/share/vizd/docker/Dockerfile-dev new file mode 100644 index 0000000000..3f0ba931de --- /dev/null +++ b/share/vizd/docker/Dockerfile-dev @@ -0,0 +1,72 @@ +# Dockerfile-dev — developer toolchain image for VIZ blockchain +# +# PURPOSE: Provides a fully-equipped Linux build environment that mirrors the +# CI builder stage (same base image and apt packages as Dockerfile-production) +# WITHOUT baking any source code in. The host worktree is mounted at /workspace +# so you can iterate on the source without rebuilding this image. +# +# BUILD: +# docker build -f share/vizd/docker/Dockerfile-dev -t viz-dev . +# +# ENTER A SHELL: +# docker run --rm -it -v $(pwd):/workspace viz-dev +# +# ONE-SHOT COMMAND: +# docker run --rm -v $(pwd):/workspace viz-dev bash -c "cd /workspace/build && make -j$(nproc) vizd" +# +# ccache is stored in /workspace/.ccache (mounted from the host worktree) so it +# persists across container runs. Set CCACHE_DIR=/workspace/.ccache inside the +# container or rely on the ENV below. + +FROM phusion/baseimage:noble-1.0.3 + +ENV LANG=en_US.UTF-8 + +# ccache configuration — cache directory is mounted from the host worktree so +# it survives container restarts without polluting the image layers. +ENV CCACHE_DIR=/workspace/.ccache +ENV CCACHE_MAXSIZE=2G +ENV CCACHE_COMPRESS=1 + +# Install build dependencies (mirrors Dockerfile-production builder stage exactly, +# plus gcovr for coverage reports and gdb for debugging failing scenarios). +RUN apt-get update && \ + apt-get install -y --no-install-recommends \ + autoconf \ + automake \ + autotools-dev \ + binutils \ + bsdmainutils \ + build-essential \ + cmake \ + git \ + ccache \ + gdb \ + gcovr \ + libboost-chrono-dev \ + libboost-context-dev \ + libboost-coroutine-dev \ + libboost-date-time-dev \ + libboost-filesystem-dev \ + libboost-iostreams-dev \ + libboost-locale-dev \ + libboost-program-options-dev \ + libboost-serialization-dev \ + libboost-system-dev \ + libboost-test-dev \ + libboost-thread-dev \ + libbz2-dev \ + liblzma-dev \ + libzstd-dev \ + libreadline-dev \ + libssl-dev \ + libtool \ + ncurses-dev \ + pbzip2 \ + pkg-config \ + zlib1g-dev && \ + rm -rf /var/lib/apt/lists/* + +WORKDIR /workspace + +CMD ["/bin/bash"] diff --git a/share/vizd/docker/README-dev.md b/share/vizd/docker/README-dev.md new file mode 100644 index 0000000000..5e798571ff --- /dev/null +++ b/share/vizd/docker/README-dev.md @@ -0,0 +1,75 @@ +# VIZ Developer Docker Image + +`Dockerfile-dev` builds a long-lived Linux toolchain image that mirrors the CI +builder stage (same base image and packages as `Dockerfile-production`) without +baking any source code in. Mount the worktree as a volume and compile/test +inside the container against a real Ubuntu Noble environment. + +## Prerequisites + +- Docker Desktop (or Docker Engine) running locally. +- Git submodules initialized on the host **before** using the container: + + ```bash + git submodule update --init --recursive + ``` + +## Build the image + +Run once from the repository root (or worktree root): + +```bash +docker build -f share/vizd/docker/Dockerfile-dev -t viz-dev . +``` + +Typical build time: 3–8 minutes on first run (pulls base image + ~30 apt +packages). Subsequent builds are fast thanks to Docker layer cache. + +## Enter an interactive shell + +```bash +docker run --rm -it -v $(pwd):/workspace viz-dev +``` + +Inside the container your worktree is at `/workspace`. Run cmake, make, ctest, +gcovr, or gdb from there. + +## Run a one-shot command + +```bash +docker run --rm -v $(pwd):/workspace viz-dev bash -c " + mkdir -p build-docker && cd build-docker + cmake -DCMAKE_BUILD_TYPE=Release -DLOW_MEMORY_NODE=OFF .. + make -j$(nproc) vizd +" +``` + +## Configure only (no build) + +```bash +docker run --rm -v $(pwd):/workspace viz-dev bash -c " + mkdir -p build-docker && cd build-docker + cmake -DCMAKE_BUILD_TYPE=Release -DLOW_MEMORY_NODE=OFF .. 2>&1 | tail -30 +" +``` + +## ccache + +The image sets `CCACHE_DIR=/workspace/.ccache`. When you mount the worktree +with `-v $(pwd):/workspace`, ccache writes to `/.ccache/` on the +host and persists across container runs. The `.ccache/` directory is listed in +`.git/info/exclude` so it won't appear in `git status`. + +## Clean up + +Remove the build directory created inside the worktree: + +```bash +rm -rf build-docker/ +``` + +Remove the image: + +```bash +docker rmi viz-dev +``` From 5a72216f0210d5f7b9ef414be3bdd1e13b598a26 Mon Sep 17 00:00:00 2001 From: Vladimir Babin Date: Fri, 1 May 2026 08:43:46 +0300 Subject: [PATCH 02/17] test(consensus_sim): audit chain API and clock dependencies --- tests/consensus_sim/AUDIT.md | 214 +++++++++++++++++++++++++++++++++++ 1 file changed, 214 insertions(+) create mode 100644 tests/consensus_sim/AUDIT.md diff --git a/tests/consensus_sim/AUDIT.md b/tests/consensus_sim/AUDIT.md new file mode 100644 index 0000000000..b53fc64003 --- /dev/null +++ b/tests/consensus_sim/AUDIT.md @@ -0,0 +1,214 @@ +# Consensus Harness API Audit + +Audited commit: worktree `test/consensus-harness` (mirrors master at `36b0aa8`) +Audit date: 2026-05-01 + +--- + +## TL;DR + +**Issue found: wall-clock read inside transaction evaluator writes consensus state; +revise Task 3 (harness design) to either avoid `account_buy_operation` in test +fixtures or patch/skip the evaluator for deterministic testing.** + +All other plan-writer claims verified correct. Proceed with the plan subject to +the mitigation note in the "wall-clock reads" section below. + +--- + +## database lifecycle + +Source: `libraries/chain/include/graphene/chain/database.hpp` + +### Constructor / destructor + +```cpp +database(); // line 39 — default constructor, no arguments +~database(); // line 41 +``` + +### `open` + +```cpp +// line 106 +void open( + const fc::path &data_dir, + const fc::path &shared_mem_dir, + uint64_t initial_supply = CHAIN_INIT_SUPPLY, // 50 000 000 000 (3-decimal tokens) + uint64_t shared_file_size = 0, + uint32_t chainbase_flags = 0 +); +``` + +Variant: `open_from_snapshot(data_dir, shared_mem_dir, initial_supply, shared_file_size, chainbase_flags)` — no defaults, used for DLT/snapshot restore. + +### `close` / `wipe` + +```cpp +void close(bool rewind = true); // line 176 +void wipe(const fc::path &data_dir, const fc::path &shared_mem_dir, bool include_blocks); // line 174 +``` + +### Block production / application + +```cpp +// line 263 +bool push_block(const signed_block &b, uint32_t skip = skip_nothing); + +// line 271 +bool _push_block(const signed_block &b, uint32_t skip); + +// lines 281-286 +signed_block generate_block( + const fc::time_point_sec when, + const account_name_type &witness_owner, + const fc::ecc::private_key &block_signing_private_key, + uint32_t skip +); + +// lines 288-293 +signed_block _generate_block( + const fc::time_point_sec when, + const account_name_type &witness_owner, + const fc::ecc::private_key &block_signing_private_key, + uint32_t skip +); +``` + +Note: `generate_block` has **no default for `skip`** in the declaration (plan-writer +implied defaults exist — they do not; caller must supply the value explicitly). + +### Accessors + +```cpp +time_point_sec head_block_time() const; // line 463 — returns dgp.time (state-derived) +uint32_t head_block_num() const; // line 465 +block_id_type head_block_id() const; // line 467 +uint32_t last_non_undoable_block_num() const; // line 469 — wraps dgp.last_irreversible_block_num +fork_database& get_fork_db(); // line 517 +const fork_database& get_fork_db() const; // line 521 +``` + +**Naming drift vs. plan-writer claim 1:** The plan references +`last_irreversible_block_num()` as a public method. No such method exists. The +public accessor is named `last_non_undoable_block_num()` (line 469) and delegates +to `get_dynamic_global_properties().last_irreversible_block_num`. Use +`last_non_undoable_block_num()` in harness code. + +--- + +## init constants + +Source: `libraries/protocol/include/graphene/protocol/config.hpp` + +| Macro | Value | Notes | +|---|---|---| +| `CHAIN_INIT_SUPPLY` | `int64_t(50000000000)` | 50 000 000.000 VIZ (3 decimals) | +| `CHAIN_INITIATOR_NAME` | `"viz"` | genesis account name (plan called it `CHAIN_INIT_MINER_NAME`) | +| `CHAIN_NUM_INITIATORS` | `0` | no extra genesis miners besides the initiator | +| `CHAIN_INITIATOR_PUBLIC_KEY_STR` | `"VIZ6MyX5QiXAXRZk7SYCiqpi6Mtm8UbHWDFSV8HPpt7FJyahCnc2T"` | canonical genesis key | +| `CHAIN_BLOCK_INTERVAL` | `3` (seconds) | slot duration | + +**Naming drift vs. plan-writer claims 2 & 3:** +- Plan used `CHAIN_INIT_MINER_NAME` — actual macro is `CHAIN_INITIATOR_NAME`. +- Plan used `CHAIN_NUM_INIT_MINERS` — actual macro is `CHAIN_NUM_INITIATORS` (value `0`). +- Plan used `CHAIN_INIT_PRIVATE_KEY` — no such macro exists. The public key is + exposed as `CHAIN_INITIATOR_PUBLIC_KEY_STR`; the matching private key + (`5JabcrvaLnBTCkCVFX5r4rmeGGfuJuVp4NAKRNLTey6pxhRQmf4`) is only in a comment + at line 35. The harness must hard-code or derive the private key directly. + +--- + +## wall-clock reads + +Grep output (`grep -rn "fc::time_point::now()"` on `libraries/chain` and +`libraries/protocol`): + +### Non-consensus (safe for harness) + +| File:line | Context | Classification | +|---|---|---| +| `database.cpp:208,316` | `_node_startup_time = fc::time_point::now()` in `open()` / `open_from_snapshot()` | non-consensus: used only for emergency-consensus startup-delay guard | +| `database.cpp:209,218,222,296,317,347,363,392,432,448,508,541` | Timing of `open()`, `reindex()`, and `reindex_from_dlt()` operations | non-consensus: elapsed-time logging only | +| `database.cpp:1193` | Rate-limit on `_maybe_warn_multiple_production` log warning | non-consensus: uses static vars, affects only log output | +| `database.cpp:4258` | Flush-block RNG seed (`_next_flush_block`) | non-consensus: only controls when `chainbase::flush()` is called; does not affect block content or chain state | +| `database.cpp:4689` | Emergency-consensus startup-delay check | non-consensus: reads `_node_startup_time` (set in `open()`) to guard against premature emergency activation; never alters block content | +| `db_with.hpp:38,43,64` | `CHAIN_PENDING_TRANSACTION_EXECUTION_LIMIT` enforcement in `pending_transactions_restorer` | non-consensus: only limits how many pending txs are re-applied after a pop; affects _which_ transactions end up in the mempool, not in a finalized block | + +### **CONSENSUS — P0 FLAG** + +| File:line | Context | Risk | +|---|---|---| +| `chain_evaluator.cpp:2156` | Inside `account_buy_operation` evaluator (HF11+ auction path) — `time_point_sec expand_start_time = fc::time_point::now() + CHAIN_ACCOUNT_AUCTION_EXTENSION_TIME;` then `a.account_on_sale_start_time = std::max(a.account_on_sale_start_time, expand_start_time);` | **CONSENSUS**: this wall-clock timestamp is written into `account_object::account_on_sale_start_time`, which is persistent chain state. Two nodes applying the same bid transaction at different wall-clock times produce different `account_on_sale_start_time` values, making their state diverge. | + +**Risk 1 has materialized** — but only in a narrow, avoidable path. The harness +can avoid it by never including `account_buy_operation` (auction bids) in test +transactions, or by patching the evaluator to use `head_block_time()` instead of +`fc::time_point::now()` (which would be the correct fix). This does not block +the harness for most consensus scenarios. + +> Note: `database.cpp:3958` (`time_point_sec genesis_time = fc::time_point::now()`) +> appears in `init_genesis()` and also writes to `dynamic_global_property_object` +> and `hardfork_property_object`. However it is **immediately overwritten** when +> block 1 is applied (`_apply_block` lines 4296-4304: `genesis_time = +> next_block.timestamp - CHAIN_BLOCK_INTERVAL`). So the wall-clock value is +> ephemeral and does not affect post-genesis chain state. + +--- + +## `_generate_block` witness-key behavior (Risk 3 check) + +Source: `libraries/chain/database.cpp` lines 1539–1727. + +1. **Looks up witness by `witness_owner` parameter** (line 1564: `get_witness(witness_owner)`) — does not reference any globally-configured key. +2. **Signs with `block_signing_private_key` parameter** (line 1714-1715, inside `if (!(skip & skip_witness_signature))`). +3. **Key-match assertion** at lines 1585-1587: + ```cpp + if (!(skip & skip_witness_signature)) + FC_ASSERT(witness_obj.signing_key == block_signing_private_key.get_public_key()); + ``` + This assertion fires only if `skip_witness_signature` is NOT set. + **Implication for harness**: when calling `_generate_block` (or `generate_block`), + either (a) register the witness on-chain with the same public key as the + private key you pass, or (b) pass `skip | skip_witness_signature` to bypass + the check. Option (a) is strongly preferred for realistic consensus simulation. + +**Risk 3 has NOT materialized.** `_generate_block` is fully parameterized on +witness identity and signing key. + +--- + +## `chainbase` multi-instance isolation (Risk 2 check) + +Source: `thirdparty/chainbase/include/chainbase/chainbase.hpp` lines 837–1327. + +Chainbase `database` is a plain C++ class with no static data members. All state +lives in per-instance members: +- `_segment` — `boost::interprocess::unique_ptr` (per-path) +- `_mutex`, `_flock`, `_index_list`, `_index_map`, `_index_types` — all instance members +- No singletons, no process-wide registrations found + +The file-lock `_flock` is per-path (it locks the mmap file), so two `database` +instances pointing to different directories coexist without contention. + +**Risk 2 has NOT materialized.** N independent `database` instances in one +process are safe as long as each has a distinct `shared_mem_dir`. + +--- + +## Drift summary vs. plan-writer pre-grounded facts + +| Claim | Status | Detail | +|---|---|---| +| 1. `open()` signature at hpp:106 | **Correct** | Signature matches exactly | +| 2. `push_block()` at line 263 | **Correct** | Matches | +| 3. `generate_block()` at line 281 | **Correct** | Matches (note: no default for `skip`) | +| 4. `head_block_time()` is state-derived | **Correct** | Returns `dgp.time` | +| 5. All `fc::time_point::now()` non-consensus | **INCORRECT** | `chain_evaluator.cpp:2156` is consensus-critical | +| 6. No `genesis_state`; genesis via `init_genesis()` in `open()` | **Correct** | Confirmed | + +Additional naming drifts (not in the 6 claims but relevant to downstream tasks): +- `CHAIN_INIT_MINER_NAME` → actual: `CHAIN_INITIATOR_NAME` +- `CHAIN_NUM_INIT_MINERS` → actual: `CHAIN_NUM_INITIATORS` (value `0`) +- `CHAIN_INIT_PRIVATE_KEY` → does not exist as a macro; private key is comment-only +- `last_irreversible_block_num()` public method → actual: `last_non_undoable_block_num()` From 8c5ea5434fa7beb2769368d7fa820c807d7c90b1 Mon Sep 17 00:00:00 2001 From: Vladimir Babin Date: Sat, 2 May 2026 09:46:20 +0300 Subject: [PATCH 03/17] test(consensus_sim): scaffold opt-in CMake target with sanitizers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Wire BUILD_CONSENSUS_TESTS option (default OFF) and WITH_COVERAGE into the top-level CMakeLists. When enabled, build a consensus_sim_harness static library plus consensus_sim_tests Boost.Test executable with ASan, UBSan, and -fno-omit-frame-pointer baked in. Harness sources and scenario files are stubs to be filled in by Tasks 4-15; the placeholder test_main proves the build wires correctly end-to-end. Two collateral fixes needed to build inside a container that mounts the worktree (whose .git gitfile points to a host-only path): - libraries/utilities/CMakeLists.txt now sanitizes invalid GRAPHENE_GIT_REVISION_UNIX_TIMESTAMP/SHA values to 0/empty when get_git_unix_timestamp() returns "HEAD-HASH-NOTFOUND". - The same fix lives in fc/CMakeLists.txt (saved as a recovery patch alongside the existing ARM64 portability patches in tests/consensus_sim/*.patch). Boost link variant: target consensus_sim_tests links Boost via the imported target Boost::unit_test_framework and explicitly does NOT define BOOST_TEST_DYN_LINK, since the dev image ships static libboost_unit_test_framework.a — DYN_LINK would expand to the old unit_test_main(bool(*)(),int,char**) signature absent from that archive. Verified: - BUILD_CONSENSUS_TESTS=ON builds + executes "harness_compiles_and_links". - Default (no flag) configure produces no consensus_sim_tests rule. Co-Authored-By: Claude Opus 4.7 --- CMakeLists.txt | 8 ++ libraries/utilities/CMakeLists.txt | 8 ++ tests/consensus_sim/.gitignore | 3 + tests/consensus_sim/CMakeLists.txt | 66 ++++++++++ tests/consensus_sim/failures/.gitkeep | 0 .../consensus_sim/fc-arm64-portability.patch | 124 ++++++++++++++++++ .../fc-git-revision-fallback.patch | 19 +++ tests/consensus_sim/harness/failure_log.cpp | 1 + .../consensus_sim/harness/fault_injector.cpp | 1 + .../consensus_sim/harness/genesis_factory.cpp | 1 + tests/consensus_sim/harness/invariants.cpp | 1 + tests/consensus_sim/harness/message_bus.cpp | 1 + .../consensus_sim/harness/scenario_driver.cpp | 1 + .../consensus_sim/harness/simulated_node.cpp | 1 + tests/consensus_sim/harness/virtual_clock.cpp | 1 + .../scenarios/test_determinism_replay.cpp | 1 + .../scenarios/test_equivocation.cpp | 1 + .../scenarios/test_genesis_factory.cpp | 1 + tests/consensus_sim/scenarios/test_main.cpp | 7 + .../scenarios/test_message_bus.cpp | 1 + .../scenarios/test_simulated_node_smoke.cpp | 1 + .../scenarios/test_smoke_no_faults.cpp | 1 + .../scenarios/test_virtual_clock.cpp | 1 + 23 files changed, 250 insertions(+) create mode 100644 tests/consensus_sim/.gitignore create mode 100644 tests/consensus_sim/CMakeLists.txt create mode 100644 tests/consensus_sim/failures/.gitkeep create mode 100644 tests/consensus_sim/fc-arm64-portability.patch create mode 100644 tests/consensus_sim/fc-git-revision-fallback.patch create mode 100644 tests/consensus_sim/harness/failure_log.cpp create mode 100644 tests/consensus_sim/harness/fault_injector.cpp create mode 100644 tests/consensus_sim/harness/genesis_factory.cpp create mode 100644 tests/consensus_sim/harness/invariants.cpp create mode 100644 tests/consensus_sim/harness/message_bus.cpp create mode 100644 tests/consensus_sim/harness/scenario_driver.cpp create mode 100644 tests/consensus_sim/harness/simulated_node.cpp create mode 100644 tests/consensus_sim/harness/virtual_clock.cpp create mode 100644 tests/consensus_sim/scenarios/test_determinism_replay.cpp create mode 100644 tests/consensus_sim/scenarios/test_equivocation.cpp create mode 100644 tests/consensus_sim/scenarios/test_genesis_factory.cpp create mode 100644 tests/consensus_sim/scenarios/test_main.cpp create mode 100644 tests/consensus_sim/scenarios/test_message_bus.cpp create mode 100644 tests/consensus_sim/scenarios/test_simulated_node_smoke.cpp create mode 100644 tests/consensus_sim/scenarios/test_smoke_no_faults.cpp create mode 100644 tests/consensus_sim/scenarios/test_virtual_clock.cpp diff --git a/CMakeLists.txt b/CMakeLists.txt index 9ce94bad86..a80d768810 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -79,6 +79,9 @@ if(CHAINBASE_CHECK_LOCKING) set(CMAKE_C_FLAGS "${CMAKE_C_FLAGS} -DCHAINBASE_CHECK_LOCKING") endif() +option(BUILD_CONSENSUS_TESTS "Build consensus simulation test harness" OFF) +option(WITH_COVERAGE "Enable gcov coverage instrumentation for consensus tests" OFF) + if(WIN32) set(BOOST_ROOT $ENV{BOOST_ROOT}) set(Boost_USE_MULTITHREADED ON) @@ -200,6 +203,11 @@ add_subdirectory(libraries) add_subdirectory(plugins) add_subdirectory(programs) +if(BUILD_CONSENSUS_TESTS) + enable_testing() + add_subdirectory(tests/consensus_sim) +endif() + if(ENABLE_INSTALLER) set(VERSION_MAJOR 0) diff --git a/libraries/utilities/CMakeLists.txt b/libraries/utilities/CMakeLists.txt index b9b14db64c..65d6d9515f 100644 --- a/libraries/utilities/CMakeLists.txt +++ b/libraries/utilities/CMakeLists.txt @@ -3,6 +3,14 @@ include(GetGitRevisionDescription) get_git_head_revision(GIT_REFSPEC GRAPHENE_GIT_REVISION_SHA) get_git_unix_timestamp(GRAPHENE_GIT_REVISION_UNIX_TIMESTAMP) git_describe(GRAPHENE_GIT_REVISION_DESCRIPTION --tags) +# Fallbacks when git lookup fails (e.g. building inside a container that can't +# resolve a worktree gitfile pointing to a host path). +if(NOT GRAPHENE_GIT_REVISION_UNIX_TIMESTAMP MATCHES "^[0-9]+$") + set(GRAPHENE_GIT_REVISION_UNIX_TIMESTAMP 0) +endif() +if(NOT GRAPHENE_GIT_REVISION_SHA MATCHES "^[a-f0-9]+$") + set(GRAPHENE_GIT_REVISION_SHA "") +endif() if(NOT GRAPHENE_GIT_REVISION_DESCRIPTION) set(GRAPHENE_GIT_REVISION_DESCRIPTION "unknown") endif(NOT GRAPHENE_GIT_REVISION_DESCRIPTION) diff --git a/tests/consensus_sim/.gitignore b/tests/consensus_sim/.gitignore new file mode 100644 index 0000000000..56709cb5bb --- /dev/null +++ b/tests/consensus_sim/.gitignore @@ -0,0 +1,3 @@ +build.log +failures/* +!failures/.gitkeep diff --git a/tests/consensus_sim/CMakeLists.txt b/tests/consensus_sim/CMakeLists.txt new file mode 100644 index 0000000000..facc3fc90a --- /dev/null +++ b/tests/consensus_sim/CMakeLists.txt @@ -0,0 +1,66 @@ +# Consensus simulation harness - opt-in test target + +set(HARNESS_SOURCES + harness/virtual_clock.cpp + harness/genesis_factory.cpp + harness/simulated_node.cpp + harness/message_bus.cpp + harness/fault_injector.cpp + harness/scenario_driver.cpp + harness/invariants.cpp + harness/failure_log.cpp +) + +add_library(consensus_sim_harness STATIC ${HARNESS_SOURCES}) + +target_include_directories(consensus_sim_harness + PUBLIC ${CMAKE_CURRENT_SOURCE_DIR}/harness +) + +target_link_libraries(consensus_sim_harness + PUBLIC graphene_chain graphene_protocol fc +) + +# Sanitizers on by default for the harness +target_compile_options(consensus_sim_harness PUBLIC + -fsanitize=address + -fsanitize=undefined + -fno-omit-frame-pointer + -O1 + -g +) +target_link_options(consensus_sim_harness PUBLIC + -fsanitize=address + -fsanitize=undefined +) + +if(WITH_COVERAGE) + target_compile_options(consensus_sim_harness PUBLIC --coverage) + target_link_options(consensus_sim_harness PUBLIC --coverage) +endif() + +set(SCENARIO_SOURCES + scenarios/test_main.cpp + scenarios/test_virtual_clock.cpp + scenarios/test_message_bus.cpp + scenarios/test_genesis_factory.cpp + scenarios/test_simulated_node_smoke.cpp + scenarios/test_smoke_no_faults.cpp + scenarios/test_determinism_replay.cpp + scenarios/test_equivocation.cpp +) + +add_executable(consensus_sim_tests ${SCENARIO_SOURCES}) + +target_link_libraries(consensus_sim_tests + PRIVATE consensus_sim_harness + Boost::unit_test_framework +) + +# We link against the static libboost_unit_test_framework.a in the viz-dev +# image, so do NOT define BOOST_TEST_DYN_LINK — that would expand to the +# old-signature unit_test_main() which is absent from the static archive. + +add_test(NAME consensus_sim COMMAND consensus_sim_tests) + +file(MAKE_DIRECTORY ${CMAKE_CURRENT_SOURCE_DIR}/failures) diff --git a/tests/consensus_sim/failures/.gitkeep b/tests/consensus_sim/failures/.gitkeep new file mode 100644 index 0000000000..e69de29bb2 diff --git a/tests/consensus_sim/fc-arm64-portability.patch b/tests/consensus_sim/fc-arm64-portability.patch new file mode 100644 index 0000000000..724e8294eb --- /dev/null +++ b/tests/consensus_sim/fc-arm64-portability.patch @@ -0,0 +1,124 @@ +diff --git a/vendor/equihash/include/equihash/blake2-config.h b/vendor/equihash/include/equihash/blake2-config.h +index 70d61f1..469bde7 100644 +--- a/vendor/equihash/include/equihash/blake2-config.h ++++ b/vendor/equihash/include/equihash/blake2-config.h +@@ -64,9 +64,8 @@ + #define HAVE_SSE2 + #endif + +-#if !defined(HAVE_SSE2) +-#error "This code requires at least SSE2." +-#endif ++/* Non-x86 architectures (e.g. ARM64) fall back to the portable scalar path ++ compiled into blake2b.c when HAVE_SSE2 is not defined. */ + + #endif + +diff --git a/vendor/equihash/src/blake2b.c b/vendor/equihash/src/blake2b.c +index 429dc3a..21498e1 100644 +--- a/vendor/equihash/src/blake2b.c ++++ b/vendor/equihash/src/blake2b.c +@@ -20,7 +20,7 @@ + + #include + +- ++#if defined(HAVE_SSE2) + #include + #if defined(HAVE_SSSE3) + #include +@@ -34,8 +34,8 @@ + #if defined(HAVE_XOP) + #include + #endif +- + #include ++#endif /* HAVE_SSE2 */ + + ALIGN( 64 ) static const uint64_t blake2b_IV[8] = + { +@@ -244,6 +244,7 @@ int blake2b_init_key( blake2b_state *S, const uint8_t outlen, const void *key, c + return 0; + } + ++#if defined(HAVE_SSE2) + static inline int blake2b_compress( blake2b_state *S, const uint8_t block[BLAKE2B_BLOCKBYTES] ) + { + __m128i row1l, row1h; +@@ -313,6 +314,52 @@ static inline int blake2b_compress( blake2b_state *S, const uint8_t block[BLAKE2 + STOREU( &S->h[6], _mm_xor_si128( LOADU( &S->h[6] ), row2h ) ); + return 0; + } ++#else /* !HAVE_SSE2 — portable scalar fallback for non-x86 (e.g. ARM64) */ ++#define ROTR64(x, n) (((x) >> (n)) | ((x) << (64 - (n)))) ++#define B2B_G(a, b, c, d, x, y) \ ++ v[a] = v[a] + v[b] + (x); \ ++ v[d] = ROTR64(v[d] ^ v[a], 32); \ ++ v[c] = v[c] + v[d]; \ ++ v[b] = ROTR64(v[b] ^ v[c], 24); \ ++ v[a] = v[a] + v[b] + (y); \ ++ v[d] = ROTR64(v[d] ^ v[a], 16); \ ++ v[c] = v[c] + v[d]; \ ++ v[b] = ROTR64(v[b] ^ v[c], 63); ++static inline int blake2b_compress( blake2b_state *S, const uint8_t block[BLAKE2B_BLOCKBYTES] ) ++{ ++ uint64_t m[16]; ++ uint64_t v[16]; ++ int i; ++ for( i = 0; i < 16; ++i ) ++ memcpy( &m[i], block + i * 8, 8 ); ++ for( i = 0; i < 8; ++i ) ++ v[i] = S->h[i]; ++ v[ 8] = blake2b_IV[0]; ++ v[ 9] = blake2b_IV[1]; ++ v[10] = blake2b_IV[2]; ++ v[11] = blake2b_IV[3]; ++ v[12] = blake2b_IV[4] ^ S->t[0]; ++ v[13] = blake2b_IV[5] ^ S->t[1]; ++ v[14] = blake2b_IV[6] ^ S->f[0]; ++ v[15] = blake2b_IV[7] ^ S->f[1]; ++ /* 12 rounds */ ++ { const uint8_t *s = blake2b_sigma[0]; B2B_G(0,4, 8,12,m[s[ 0]],m[s[ 1]]); B2B_G(1,5, 9,13,m[s[ 2]],m[s[ 3]]); B2B_G(2,6,10,14,m[s[ 4]],m[s[ 5]]); B2B_G(3,7,11,15,m[s[ 6]],m[s[ 7]]); B2B_G(0,5,10,15,m[s[ 8]],m[s[ 9]]); B2B_G(1,6,11,12,m[s[10]],m[s[11]]); B2B_G(2,7, 8,13,m[s[12]],m[s[13]]); B2B_G(3,4, 9,14,m[s[14]],m[s[15]]); } ++ { const uint8_t *s = blake2b_sigma[1]; B2B_G(0,4, 8,12,m[s[ 0]],m[s[ 1]]); B2B_G(1,5, 9,13,m[s[ 2]],m[s[ 3]]); B2B_G(2,6,10,14,m[s[ 4]],m[s[ 5]]); B2B_G(3,7,11,15,m[s[ 6]],m[s[ 7]]); B2B_G(0,5,10,15,m[s[ 8]],m[s[ 9]]); B2B_G(1,6,11,12,m[s[10]],m[s[11]]); B2B_G(2,7, 8,13,m[s[12]],m[s[13]]); B2B_G(3,4, 9,14,m[s[14]],m[s[15]]); } ++ { const uint8_t *s = blake2b_sigma[2]; B2B_G(0,4, 8,12,m[s[ 0]],m[s[ 1]]); B2B_G(1,5, 9,13,m[s[ 2]],m[s[ 3]]); B2B_G(2,6,10,14,m[s[ 4]],m[s[ 5]]); B2B_G(3,7,11,15,m[s[ 6]],m[s[ 7]]); B2B_G(0,5,10,15,m[s[ 8]],m[s[ 9]]); B2B_G(1,6,11,12,m[s[10]],m[s[11]]); B2B_G(2,7, 8,13,m[s[12]],m[s[13]]); B2B_G(3,4, 9,14,m[s[14]],m[s[15]]); } ++ { const uint8_t *s = blake2b_sigma[3]; B2B_G(0,4, 8,12,m[s[ 0]],m[s[ 1]]); B2B_G(1,5, 9,13,m[s[ 2]],m[s[ 3]]); B2B_G(2,6,10,14,m[s[ 4]],m[s[ 5]]); B2B_G(3,7,11,15,m[s[ 6]],m[s[ 7]]); B2B_G(0,5,10,15,m[s[ 8]],m[s[ 9]]); B2B_G(1,6,11,12,m[s[10]],m[s[11]]); B2B_G(2,7, 8,13,m[s[12]],m[s[13]]); B2B_G(3,4, 9,14,m[s[14]],m[s[15]]); } ++ { const uint8_t *s = blake2b_sigma[4]; B2B_G(0,4, 8,12,m[s[ 0]],m[s[ 1]]); B2B_G(1,5, 9,13,m[s[ 2]],m[s[ 3]]); B2B_G(2,6,10,14,m[s[ 4]],m[s[ 5]]); B2B_G(3,7,11,15,m[s[ 6]],m[s[ 7]]); B2B_G(0,5,10,15,m[s[ 8]],m[s[ 9]]); B2B_G(1,6,11,12,m[s[10]],m[s[11]]); B2B_G(2,7, 8,13,m[s[12]],m[s[13]]); B2B_G(3,4, 9,14,m[s[14]],m[s[15]]); } ++ { const uint8_t *s = blake2b_sigma[5]; B2B_G(0,4, 8,12,m[s[ 0]],m[s[ 1]]); B2B_G(1,5, 9,13,m[s[ 2]],m[s[ 3]]); B2B_G(2,6,10,14,m[s[ 4]],m[s[ 5]]); B2B_G(3,7,11,15,m[s[ 6]],m[s[ 7]]); B2B_G(0,5,10,15,m[s[ 8]],m[s[ 9]]); B2B_G(1,6,11,12,m[s[10]],m[s[11]]); B2B_G(2,7, 8,13,m[s[12]],m[s[13]]); B2B_G(3,4, 9,14,m[s[14]],m[s[15]]); } ++ { const uint8_t *s = blake2b_sigma[6]; B2B_G(0,4, 8,12,m[s[ 0]],m[s[ 1]]); B2B_G(1,5, 9,13,m[s[ 2]],m[s[ 3]]); B2B_G(2,6,10,14,m[s[ 4]],m[s[ 5]]); B2B_G(3,7,11,15,m[s[ 6]],m[s[ 7]]); B2B_G(0,5,10,15,m[s[ 8]],m[s[ 9]]); B2B_G(1,6,11,12,m[s[10]],m[s[11]]); B2B_G(2,7, 8,13,m[s[12]],m[s[13]]); B2B_G(3,4, 9,14,m[s[14]],m[s[15]]); } ++ { const uint8_t *s = blake2b_sigma[7]; B2B_G(0,4, 8,12,m[s[ 0]],m[s[ 1]]); B2B_G(1,5, 9,13,m[s[ 2]],m[s[ 3]]); B2B_G(2,6,10,14,m[s[ 4]],m[s[ 5]]); B2B_G(3,7,11,15,m[s[ 6]],m[s[ 7]]); B2B_G(0,5,10,15,m[s[ 8]],m[s[ 9]]); B2B_G(1,6,11,12,m[s[10]],m[s[11]]); B2B_G(2,7, 8,13,m[s[12]],m[s[13]]); B2B_G(3,4, 9,14,m[s[14]],m[s[15]]); } ++ { const uint8_t *s = blake2b_sigma[8]; B2B_G(0,4, 8,12,m[s[ 0]],m[s[ 1]]); B2B_G(1,5, 9,13,m[s[ 2]],m[s[ 3]]); B2B_G(2,6,10,14,m[s[ 4]],m[s[ 5]]); B2B_G(3,7,11,15,m[s[ 6]],m[s[ 7]]); B2B_G(0,5,10,15,m[s[ 8]],m[s[ 9]]); B2B_G(1,6,11,12,m[s[10]],m[s[11]]); B2B_G(2,7, 8,13,m[s[12]],m[s[13]]); B2B_G(3,4, 9,14,m[s[14]],m[s[15]]); } ++ { const uint8_t *s = blake2b_sigma[9]; B2B_G(0,4, 8,12,m[s[ 0]],m[s[ 1]]); B2B_G(1,5, 9,13,m[s[ 2]],m[s[ 3]]); B2B_G(2,6,10,14,m[s[ 4]],m[s[ 5]]); B2B_G(3,7,11,15,m[s[ 6]],m[s[ 7]]); B2B_G(0,5,10,15,m[s[ 8]],m[s[ 9]]); B2B_G(1,6,11,12,m[s[10]],m[s[11]]); B2B_G(2,7, 8,13,m[s[12]],m[s[13]]); B2B_G(3,4, 9,14,m[s[14]],m[s[15]]); } ++ { const uint8_t *s = blake2b_sigma[10]; B2B_G(0,4, 8,12,m[s[ 0]],m[s[ 1]]); B2B_G(1,5, 9,13,m[s[ 2]],m[s[ 3]]); B2B_G(2,6,10,14,m[s[ 4]],m[s[ 5]]); B2B_G(3,7,11,15,m[s[ 6]],m[s[ 7]]); B2B_G(0,5,10,15,m[s[ 8]],m[s[ 9]]); B2B_G(1,6,11,12,m[s[10]],m[s[11]]); B2B_G(2,7, 8,13,m[s[12]],m[s[13]]); B2B_G(3,4, 9,14,m[s[14]],m[s[15]]); } ++ { const uint8_t *s = blake2b_sigma[11]; B2B_G(0,4, 8,12,m[s[ 0]],m[s[ 1]]); B2B_G(1,5, 9,13,m[s[ 2]],m[s[ 3]]); B2B_G(2,6,10,14,m[s[ 4]],m[s[ 5]]); B2B_G(3,7,11,15,m[s[ 6]],m[s[ 7]]); B2B_G(0,5,10,15,m[s[ 8]],m[s[ 9]]); B2B_G(1,6,11,12,m[s[10]],m[s[11]]); B2B_G(2,7, 8,13,m[s[12]],m[s[13]]); B2B_G(3,4, 9,14,m[s[14]],m[s[15]]); } ++ for( i = 0; i < 8; ++i ) ++ S->h[i] ^= v[i] ^ v[i + 8]; ++ return 0; ++} ++#endif /* HAVE_SSE2 */ + + + int blake2b_update( blake2b_state *S, const uint8_t *in, uint64_t inlen ) +diff --git a/vendor/equihash/src/pow.cpp b/vendor/equihash/src/pow.cpp +index 95d6bfb..0f1ecc3 100644 +--- a/vendor/equihash/src/pow.cpp ++++ b/vendor/equihash/src/pow.cpp +@@ -30,8 +30,18 @@ static uint64_t rdtsc(void) { + uint64_t rax; + __asm__ __volatile__("rdtsc" : "=A"(rax) : : ); + return rax; ++#elif defined(__aarch64__) ++ /* ARM64: read virtual counter — used only for timing/profiling in equihash, ++ not part of any consensus-critical computation. */ ++ uint64_t val; ++ __asm__ __volatile__("mrs %0, cntvct_el0" : "=r"(val)); ++ return val; + #else +-#error "Not implemented!" ++ /* Generic fallback: use a simple incrementing counter. ++ This value is only used for internal timing in the equihash solver, ++ not for any hash output or consensus state. */ ++ static uint64_t counter = 0; ++ return ++counter; + #endif + #endif + } diff --git a/tests/consensus_sim/fc-git-revision-fallback.patch b/tests/consensus_sim/fc-git-revision-fallback.patch new file mode 100644 index 0000000000..d614d57514 --- /dev/null +++ b/tests/consensus_sim/fc-git-revision-fallback.patch @@ -0,0 +1,19 @@ +diff --git a/CMakeLists.txt b/CMakeLists.txt +index d3f276f..4af4e76 100644 +--- a/CMakeLists.txt ++++ b/CMakeLists.txt +@@ -18,6 +18,14 @@ include(GetGitRevisionDescription) + + get_git_head_revision(GIT_REFSPEC FC_GIT_REVISION_SHA) + get_git_unix_timestamp(FC_GIT_REVISION_UNIX_TIMESTAMP) ++# Fallbacks when git lookup fails (e.g. building inside a container that can't ++# resolve a worktree gitfile pointing to a host path). ++if(NOT FC_GIT_REVISION_UNIX_TIMESTAMP MATCHES "^[0-9]+$") ++ set(FC_GIT_REVISION_UNIX_TIMESTAMP 0) ++endif() ++if(NOT FC_GIT_REVISION_SHA MATCHES "^[a-f0-9]+$") ++ set(FC_GIT_REVISION_SHA "") ++endif() + + set(DEFAULT_HEADER_INSTALL_DIR include/\${target}) + set(DEFAULT_LIBRARY_INSTALL_DIR lib/) diff --git a/tests/consensus_sim/harness/failure_log.cpp b/tests/consensus_sim/harness/failure_log.cpp new file mode 100644 index 0000000000..33a41b14dc --- /dev/null +++ b/tests/consensus_sim/harness/failure_log.cpp @@ -0,0 +1 @@ +// stub - implemented in later task diff --git a/tests/consensus_sim/harness/fault_injector.cpp b/tests/consensus_sim/harness/fault_injector.cpp new file mode 100644 index 0000000000..33a41b14dc --- /dev/null +++ b/tests/consensus_sim/harness/fault_injector.cpp @@ -0,0 +1 @@ +// stub - implemented in later task diff --git a/tests/consensus_sim/harness/genesis_factory.cpp b/tests/consensus_sim/harness/genesis_factory.cpp new file mode 100644 index 0000000000..33a41b14dc --- /dev/null +++ b/tests/consensus_sim/harness/genesis_factory.cpp @@ -0,0 +1 @@ +// stub - implemented in later task diff --git a/tests/consensus_sim/harness/invariants.cpp b/tests/consensus_sim/harness/invariants.cpp new file mode 100644 index 0000000000..33a41b14dc --- /dev/null +++ b/tests/consensus_sim/harness/invariants.cpp @@ -0,0 +1 @@ +// stub - implemented in later task diff --git a/tests/consensus_sim/harness/message_bus.cpp b/tests/consensus_sim/harness/message_bus.cpp new file mode 100644 index 0000000000..33a41b14dc --- /dev/null +++ b/tests/consensus_sim/harness/message_bus.cpp @@ -0,0 +1 @@ +// stub - implemented in later task diff --git a/tests/consensus_sim/harness/scenario_driver.cpp b/tests/consensus_sim/harness/scenario_driver.cpp new file mode 100644 index 0000000000..33a41b14dc --- /dev/null +++ b/tests/consensus_sim/harness/scenario_driver.cpp @@ -0,0 +1 @@ +// stub - implemented in later task diff --git a/tests/consensus_sim/harness/simulated_node.cpp b/tests/consensus_sim/harness/simulated_node.cpp new file mode 100644 index 0000000000..33a41b14dc --- /dev/null +++ b/tests/consensus_sim/harness/simulated_node.cpp @@ -0,0 +1 @@ +// stub - implemented in later task diff --git a/tests/consensus_sim/harness/virtual_clock.cpp b/tests/consensus_sim/harness/virtual_clock.cpp new file mode 100644 index 0000000000..33a41b14dc --- /dev/null +++ b/tests/consensus_sim/harness/virtual_clock.cpp @@ -0,0 +1 @@ +// stub - implemented in later task diff --git a/tests/consensus_sim/scenarios/test_determinism_replay.cpp b/tests/consensus_sim/scenarios/test_determinism_replay.cpp new file mode 100644 index 0000000000..33a41b14dc --- /dev/null +++ b/tests/consensus_sim/scenarios/test_determinism_replay.cpp @@ -0,0 +1 @@ +// stub - implemented in later task diff --git a/tests/consensus_sim/scenarios/test_equivocation.cpp b/tests/consensus_sim/scenarios/test_equivocation.cpp new file mode 100644 index 0000000000..33a41b14dc --- /dev/null +++ b/tests/consensus_sim/scenarios/test_equivocation.cpp @@ -0,0 +1 @@ +// stub - implemented in later task diff --git a/tests/consensus_sim/scenarios/test_genesis_factory.cpp b/tests/consensus_sim/scenarios/test_genesis_factory.cpp new file mode 100644 index 0000000000..33a41b14dc --- /dev/null +++ b/tests/consensus_sim/scenarios/test_genesis_factory.cpp @@ -0,0 +1 @@ +// stub - implemented in later task diff --git a/tests/consensus_sim/scenarios/test_main.cpp b/tests/consensus_sim/scenarios/test_main.cpp new file mode 100644 index 0000000000..47548a8c32 --- /dev/null +++ b/tests/consensus_sim/scenarios/test_main.cpp @@ -0,0 +1,7 @@ +#define BOOST_TEST_MODULE consensus_sim +#include + +// Sanity test - must always pass +BOOST_AUTO_TEST_CASE(harness_compiles_and_links) { + BOOST_CHECK_EQUAL(1 + 1, 2); +} diff --git a/tests/consensus_sim/scenarios/test_message_bus.cpp b/tests/consensus_sim/scenarios/test_message_bus.cpp new file mode 100644 index 0000000000..33a41b14dc --- /dev/null +++ b/tests/consensus_sim/scenarios/test_message_bus.cpp @@ -0,0 +1 @@ +// stub - implemented in later task diff --git a/tests/consensus_sim/scenarios/test_simulated_node_smoke.cpp b/tests/consensus_sim/scenarios/test_simulated_node_smoke.cpp new file mode 100644 index 0000000000..33a41b14dc --- /dev/null +++ b/tests/consensus_sim/scenarios/test_simulated_node_smoke.cpp @@ -0,0 +1 @@ +// stub - implemented in later task diff --git a/tests/consensus_sim/scenarios/test_smoke_no_faults.cpp b/tests/consensus_sim/scenarios/test_smoke_no_faults.cpp new file mode 100644 index 0000000000..33a41b14dc --- /dev/null +++ b/tests/consensus_sim/scenarios/test_smoke_no_faults.cpp @@ -0,0 +1 @@ +// stub - implemented in later task diff --git a/tests/consensus_sim/scenarios/test_virtual_clock.cpp b/tests/consensus_sim/scenarios/test_virtual_clock.cpp new file mode 100644 index 0000000000..33a41b14dc --- /dev/null +++ b/tests/consensus_sim/scenarios/test_virtual_clock.cpp @@ -0,0 +1 @@ +// stub - implemented in later task From 5b8e215a48cedd8a72203b5d3972572047724c2b Mon Sep 17 00:00:00 2001 From: Vladimir Babin Date: Wed, 20 May 2026 01:41:10 +0800 Subject: [PATCH 04/17] test(consensus_sim): add virtual_clock with monotonic advance MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit First concrete primitive of the harness. virtual_clock owns the simulated "now" that every node and the scenario_driver will read, and rejects any attempt to go backward — guaranteeing deterministic ordering when the scenario driver later replays a recorded event stream. API surface: - ctor takes an explicit fc::time_point_sec start (no implicit "now") - now() is noexcept - advance_to(t) is monotonic non-decreasing; throws std::logic_error on t < now(); t == now() is a no-op Test divergence from plan: the plan example wrote fc::time_point_sec t0("2026-01-01T00:00:00"); but fc::time_point_sec only has explicit(uint32_t) and explicit(const time_point&) ctors. Use fc::time_point_sec::from_iso_string(...) instead, hoisted into a kEpoch constant shared by all four cases. Verified inside viz-dev image with BUILD_CONSENSUS_TESTS=ON: ./tests/consensus_sim/consensus_sim_tests --run_test=virtual_clock_suite → 4 cases pass, no errors detected --- tests/consensus_sim/harness/virtual_clock.cpp | 17 +++++++- tests/consensus_sim/harness/virtual_clock.hpp | 21 ++++++++++ .../scenarios/test_virtual_clock.cpp | 42 ++++++++++++++++++- 3 files changed, 78 insertions(+), 2 deletions(-) create mode 100644 tests/consensus_sim/harness/virtual_clock.hpp diff --git a/tests/consensus_sim/harness/virtual_clock.cpp b/tests/consensus_sim/harness/virtual_clock.cpp index 33a41b14dc..599248d5be 100644 --- a/tests/consensus_sim/harness/virtual_clock.cpp +++ b/tests/consensus_sim/harness/virtual_clock.cpp @@ -1 +1,16 @@ -// stub - implemented in later task +#include "virtual_clock.hpp" + +#include + +namespace consensus_sim { + +virtual_clock::virtual_clock(fc::time_point_sec start) : now_(start) {} + +void virtual_clock::advance_to(fc::time_point_sec t) { + if (t < now_) { + throw std::logic_error("virtual_clock::advance_to went backward"); + } + now_ = t; +} + +} // namespace consensus_sim diff --git a/tests/consensus_sim/harness/virtual_clock.hpp b/tests/consensus_sim/harness/virtual_clock.hpp new file mode 100644 index 0000000000..bdf7a0da00 --- /dev/null +++ b/tests/consensus_sim/harness/virtual_clock.hpp @@ -0,0 +1,21 @@ +#pragma once + +#include + +namespace consensus_sim { + +class virtual_clock { +public: + explicit virtual_clock(fc::time_point_sec start); + + fc::time_point_sec now() const noexcept { return now_; } + + /// Advance virtual time to t. t must be >= now(); throws std::logic_error + /// otherwise. t == now() is a no-op. + void advance_to(fc::time_point_sec t); + +private: + fc::time_point_sec now_; +}; + +} // namespace consensus_sim diff --git a/tests/consensus_sim/scenarios/test_virtual_clock.cpp b/tests/consensus_sim/scenarios/test_virtual_clock.cpp index 33a41b14dc..e932dc8b2c 100644 --- a/tests/consensus_sim/scenarios/test_virtual_clock.cpp +++ b/tests/consensus_sim/scenarios/test_virtual_clock.cpp @@ -1 +1,41 @@ -// stub - implemented in later task +#include +#include "virtual_clock.hpp" +#include + +using namespace consensus_sim; + +BOOST_AUTO_TEST_SUITE(virtual_clock_suite) + +namespace { +constexpr const char* kEpoch = "2026-01-01T00:00:00"; +} + +BOOST_AUTO_TEST_CASE(starts_at_explicit_epoch) { + auto t0 = fc::time_point_sec::from_iso_string(kEpoch); + virtual_clock c(t0); + BOOST_CHECK(c.now() == t0); +} + +BOOST_AUTO_TEST_CASE(advance_to_moves_forward) { + auto t0 = fc::time_point_sec::from_iso_string(kEpoch); + virtual_clock c(t0); + c.advance_to(t0 + 60); + BOOST_CHECK(c.now() == t0 + fc::seconds(60)); +} + +BOOST_AUTO_TEST_CASE(advance_to_rejects_backward) { + auto t0 = fc::time_point_sec::from_iso_string(kEpoch); + virtual_clock c(t0); + c.advance_to(t0 + 60); + BOOST_CHECK_THROW(c.advance_to(t0 + 30), std::logic_error); +} + +BOOST_AUTO_TEST_CASE(advance_to_same_time_is_noop) { + auto t0 = fc::time_point_sec::from_iso_string(kEpoch); + virtual_clock c(t0); + c.advance_to(t0 + 60); + BOOST_CHECK_NO_THROW(c.advance_to(t0 + 60)); + BOOST_CHECK(c.now() == t0 + fc::seconds(60)); +} + +BOOST_AUTO_TEST_SUITE_END() From bba1bd279bf9978681cd16c4e4e37e1bcadbbe56 Mon Sep 17 00:00:00 2001 From: Vladimir Babin Date: Wed, 20 May 2026 02:00:26 +0800 Subject: [PATCH 05/17] test(consensus_sim): add deterministic genesis_factory MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Produces the parameters that database::open and witness registration will need in Task 6: initial_supply (CHAIN_INIT_SUPPLY), num_witnesses, and a vector of (account_name_type, private_key) pairs. Determinism is the load-bearing property here — every scenario run must reproduce the same witness identities so a failure log can be replayed bit-identically. Keys are derived via sha256(seed || idx). Account names are "witness-NN" zero-padded. Note: this only generates keys; it does NOT register witnesses on chain. Witness key override happens in simulated_node post-open (Task 6). Verified inside viz-dev image: ./tests/consensus_sim/consensus_sim_tests --run_test=genesis_factory_suite → 3 cases pass (same_seed_same_keys, different_seed_different_keys, witness_names_are_distinct). Full enabled suite count: 8 (4 virtual_clock + 3 genesis_factory + 1 main). --- .../consensus_sim/harness/genesis_factory.cpp | 37 ++++++++++++++++++- .../consensus_sim/harness/genesis_factory.hpp | 25 +++++++++++++ .../scenarios/test_genesis_factory.cpp | 36 +++++++++++++++++- 3 files changed, 96 insertions(+), 2 deletions(-) create mode 100644 tests/consensus_sim/harness/genesis_factory.hpp diff --git a/tests/consensus_sim/harness/genesis_factory.cpp b/tests/consensus_sim/harness/genesis_factory.cpp index 33a41b14dc..6b7650e2f3 100644 --- a/tests/consensus_sim/harness/genesis_factory.cpp +++ b/tests/consensus_sim/harness/genesis_factory.cpp @@ -1 +1,36 @@ -// stub - implemented in later task +#include "genesis_factory.hpp" + +#include +#include + +#include +#include + +namespace consensus_sim { + +static fc::ecc::private_key derive_key(uint64_t seed, uint32_t idx) { + // Deterministic key derivation: sha256(seed || idx) -> private key. + char buf[16]; + std::memcpy(buf, &seed, 8); + std::memcpy(buf + 8, &idx, 4); + std::memset(buf + 12, 0, 4); + fc::sha256 digest = fc::sha256::hash(buf, sizeof(buf)); + return fc::ecc::private_key::regenerate(digest); +} + +genesis_params make_genesis_params(uint64_t seed, uint32_t num_witnesses) { + genesis_params p; + p.initial_supply = CHAIN_INIT_SUPPLY; + p.num_witnesses = num_witnesses; + p.witness_keys.reserve(num_witnesses); + for (uint32_t i = 0; i < num_witnesses; ++i) { + char name[32]; + std::snprintf(name, sizeof(name), "witness-%02u", i); + p.witness_keys.emplace_back( + graphene::protocol::account_name_type(name), + derive_key(seed, i)); + } + return p; +} + +} // namespace consensus_sim diff --git a/tests/consensus_sim/harness/genesis_factory.hpp b/tests/consensus_sim/harness/genesis_factory.hpp new file mode 100644 index 0000000000..f29cebe548 --- /dev/null +++ b/tests/consensus_sim/harness/genesis_factory.hpp @@ -0,0 +1,25 @@ +#pragma once + +#include +#include + +#include +#include +#include + +namespace consensus_sim { + +struct genesis_params { + uint64_t initial_supply; + uint32_t num_witnesses; + /// Pairs of (witness_account_name, signing_private_key). + /// Names are derived as "witness-NN" where NN is the zero-padded index. + std::vector> witness_keys; +}; + +/// Deterministically build genesis parameters from a seed. +/// Same (seed, num_witnesses) -> bit-identical params. +genesis_params make_genesis_params(uint64_t seed, uint32_t num_witnesses); + +} // namespace consensus_sim diff --git a/tests/consensus_sim/scenarios/test_genesis_factory.cpp b/tests/consensus_sim/scenarios/test_genesis_factory.cpp index 33a41b14dc..914f7c927c 100644 --- a/tests/consensus_sim/scenarios/test_genesis_factory.cpp +++ b/tests/consensus_sim/scenarios/test_genesis_factory.cpp @@ -1 +1,35 @@ -// stub - implemented in later task +#include +#include "genesis_factory.hpp" + +#include +#include + +using namespace consensus_sim; + +BOOST_AUTO_TEST_SUITE(genesis_factory_suite) + +BOOST_AUTO_TEST_CASE(same_seed_same_keys) { + auto a = make_genesis_params(42, /*num_witnesses=*/7); + auto b = make_genesis_params(42, /*num_witnesses=*/7); + BOOST_REQUIRE_EQUAL(a.witness_keys.size(), 7u); + BOOST_REQUIRE_EQUAL(b.witness_keys.size(), 7u); + for (size_t i = 0; i < 7; ++i) { + BOOST_CHECK(a.witness_keys[i].first == b.witness_keys[i].first); + BOOST_CHECK(a.witness_keys[i].second.get_secret() == b.witness_keys[i].second.get_secret()); + } +} + +BOOST_AUTO_TEST_CASE(different_seed_different_keys) { + auto a = make_genesis_params(42, 7); + auto b = make_genesis_params(43, 7); + BOOST_CHECK(a.witness_keys[0].second.get_secret() != b.witness_keys[0].second.get_secret()); +} + +BOOST_AUTO_TEST_CASE(witness_names_are_distinct) { + auto p = make_genesis_params(7, 7); + std::set names; + for (auto& [name, _] : p.witness_keys) names.insert(std::string(name)); + BOOST_CHECK_EQUAL(names.size(), 7u); +} + +BOOST_AUTO_TEST_SUITE_END() From 21e23773d5422a8f95952f186b1150764ba065ac Mon Sep 17 00:00:00 2001 From: Vladimir Babin Date: Wed, 20 May 2026 02:44:32 +0800 Subject: [PATCH 06/17] test(consensus_sim): add simulated_node wrapping graphene::chain::database MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit simulated_node owns a per-instance chainbase database in a temp dir, exposes produce_block / receive_block with a typed block_outcome enum, and runs Milestone 1's smoke test (one block, then 100 blocks). Two chain-API quirks the plan didn't capture, both verified in this commit: 1. init_genesis only runs when database::open is called with chainbase::database::read_write (flag = 1). Passing 0 leaves the DB uninitialised and the first head-time query throws "unknown key". 2. With CHAIN_NUM_INITIATORS=0 (VIZ's compiled default), init_genesis does NOT register a witness for CHAIN_INITIATOR_NAME ("viz"). The only witness it creates and schedules in slot 0 is CHAIN_COMMITTEE_ACCOUNT, signed by CHAIN_COMMITTEE_PUBLIC_KEY. So the genesis_params identity fields were renamed initiator_* → genesis_witness_* and now carry the committee account name + the private key matching the hard-coded committee public key. To run the suite under sanitizers, set ASAN_OPTIONS=new_delete_type_mismatch=0 — there's a pre-existing new/delete type mismatch in evaluator_registry::register_evaluator (database.cpp:3669) that ASan flags on init. Filed as a follow-up; the harness work doesn't touch evaluator registration. UBSan also reports misaligned-address warnings from protocol/version.hpp on ARM64. These are pre-existing in VIZ's serialized struct layout and don't fail the test, but worth noting. --- tests/consensus_sim/CMakeLists.txt | 2 +- .../consensus_sim/harness/genesis_factory.cpp | 18 +++ .../consensus_sim/harness/genesis_factory.hpp | 11 ++ .../consensus_sim/harness/simulated_node.cpp | 105 +++++++++++++++++- .../consensus_sim/harness/simulated_node.hpp | 65 +++++++++++ .../scenarios/test_simulated_node_smoke.cpp | 50 ++++++++- 6 files changed, 248 insertions(+), 3 deletions(-) create mode 100644 tests/consensus_sim/harness/simulated_node.hpp diff --git a/tests/consensus_sim/CMakeLists.txt b/tests/consensus_sim/CMakeLists.txt index facc3fc90a..319a12a6cd 100644 --- a/tests/consensus_sim/CMakeLists.txt +++ b/tests/consensus_sim/CMakeLists.txt @@ -18,7 +18,7 @@ target_include_directories(consensus_sim_harness ) target_link_libraries(consensus_sim_harness - PUBLIC graphene_chain graphene_protocol fc + PUBLIC graphene_chain graphene_protocol graphene_utilities fc ) # Sanitizers on by default for the harness diff --git a/tests/consensus_sim/harness/genesis_factory.cpp b/tests/consensus_sim/harness/genesis_factory.cpp index 6b7650e2f3..be79992f2c 100644 --- a/tests/consensus_sim/harness/genesis_factory.cpp +++ b/tests/consensus_sim/harness/genesis_factory.cpp @@ -1,13 +1,23 @@ #include "genesis_factory.hpp" #include +#include #include +#include #include #include namespace consensus_sim { +// Matches CHAIN_COMMITTEE_PUBLIC_KEY_STR in libraries/protocol/include/graphene/protocol/config.hpp. +// With CHAIN_NUM_INITIATORS=0, init_genesis does NOT create a witness for the +// CHAIN_INITIATOR_NAME ("viz") account — it only creates a witness for +// CHAIN_COMMITTEE_ACCOUNT signed by CHAIN_COMMITTEE_PUBLIC_KEY. The committee +// account is scheduled in slot 0, so block production has to use this identity. +static const char* kGenesisWitnessPrivateKeyWif = + "5Hw9YPABaFxa2LooiANLrhUK5TPryy8f7v9Y1rk923PuYqbYdfC"; + static fc::ecc::private_key derive_key(uint64_t seed, uint32_t idx) { // Deterministic key derivation: sha256(seed || idx) -> private key. char buf[16]; @@ -18,6 +28,12 @@ static fc::ecc::private_key derive_key(uint64_t seed, uint32_t idx) { return fc::ecc::private_key::regenerate(digest); } +static fc::ecc::private_key load_genesis_witness_key() { + auto k = graphene::utilities::wif_to_key(kGenesisWitnessPrivateKeyWif); + FC_ASSERT(k.valid(), "consensus_sim: failed to parse genesis witness WIF"); + return *k; +} + genesis_params make_genesis_params(uint64_t seed, uint32_t num_witnesses) { genesis_params p; p.initial_supply = CHAIN_INIT_SUPPLY; @@ -30,6 +46,8 @@ genesis_params make_genesis_params(uint64_t seed, uint32_t num_witnesses) { graphene::protocol::account_name_type(name), derive_key(seed, i)); } + p.genesis_witness_name = graphene::protocol::account_name_type(CHAIN_COMMITTEE_ACCOUNT); + p.genesis_witness_key = load_genesis_witness_key(); return p; } diff --git a/tests/consensus_sim/harness/genesis_factory.hpp b/tests/consensus_sim/harness/genesis_factory.hpp index f29cebe548..653f7624b2 100644 --- a/tests/consensus_sim/harness/genesis_factory.hpp +++ b/tests/consensus_sim/harness/genesis_factory.hpp @@ -14,8 +14,19 @@ struct genesis_params { uint32_t num_witnesses; /// Pairs of (witness_account_name, signing_private_key). /// Names are derived as "witness-NN" where NN is the zero-padded index. + /// These are reserved for Milestone 3+ (equivocation testing). + /// They are NOT registered on chain by init_genesis — simulated_node has to + /// either rotate keys via witness_update or use the genesis initiator below. std::vector> witness_keys; + + /// The witness VIZ's init_genesis actually creates and schedules in slot 0 + /// (CHAIN_COMMITTEE_ACCOUNT, signed by CHAIN_COMMITTEE_PUBLIC_KEY). With + /// CHAIN_NUM_INITIATORS=0, "committee" is the only witness — so Milestone 1 + /// produces blocks as this account. + graphene::protocol::account_name_type genesis_witness_name; + /// Private key matching CHAIN_COMMITTEE_PUBLIC_KEY_STR. + fc::ecc::private_key genesis_witness_key; }; /// Deterministically build genesis parameters from a seed. diff --git a/tests/consensus_sim/harness/simulated_node.cpp b/tests/consensus_sim/harness/simulated_node.cpp index 33a41b14dc..fc3ceea88b 100644 --- a/tests/consensus_sim/harness/simulated_node.cpp +++ b/tests/consensus_sim/harness/simulated_node.cpp @@ -1 +1,104 @@ -// stub - implemented in later task +#include "simulated_node.hpp" + +#include +#include + +#include + +#include + +#include + +namespace consensus_sim { + +namespace bfs = boost::filesystem; + +static fc::path make_temp_dir(const std::string& prefix) { + auto p = bfs::temp_directory_path() / + bfs::unique_path("consensus_sim-" + prefix + "-%%%%%%%%"); + bfs::create_directories(p); + return fc::path(p.string()); +} + +simulated_node::simulated_node(std::string label, + const genesis_params& params, + virtual_clock& clk) + : label_(std::move(label)), + data_dir_(make_temp_dir(label_ + "-data")), + shared_mem_dir_(make_temp_dir(label_ + "-shm")), + db_(std::make_unique()), + clk_(clk) { + db_->open(data_dir_, shared_mem_dir_, + params.initial_supply, + /*shared_file_size=*/64ull * 1024 * 1024, + /*chainbase_flags=*/chainbase::database::read_write); + register_witness_keys_(params); +} + +simulated_node::~simulated_node() { + try { if (db_) db_->close(); } catch (...) {} + try { bfs::remove_all(data_dir_.string()); } catch (...) {} + try { bfs::remove_all(shared_mem_dir_.string()); } catch (...) {} +} + +void simulated_node::register_witness_keys_(const genesis_params& params) { + // Milestone 1: no-op. VIZ's init_genesis creates a single witness + // (CHAIN_INITIATOR_NAME, signed by CHAIN_INITIATOR_PUBLIC_KEY_STR). + // The smoke test produces blocks as that witness using genesis_params' + // initiator_key, so no rotation is needed. + // + // Milestone 3+ (equivocation tests) will need distinct witness identities; + // at that point this method will push witness_update operations to rotate + // each genesis witness to params.witness_keys[i]. + (void)params; +} + +graphene::protocol::signed_block simulated_node::produce_block( + const graphene::protocol::account_name_type& witness, + const fc::ecc::private_key& key, + fc::time_point_sec when) { + try { + return db_->generate_block(when, witness, key, + graphene::chain::database::skip_nothing); + } catch (const fc::exception& e) { + throw std::runtime_error( + "simulated_node::produce_block failed: " + e.to_detail_string()); + } +} + +block_outcome simulated_node::receive_block( + const graphene::protocol::signed_block& block) noexcept { + try { + bool accepted = db_->push_block(block, graphene::chain::database::skip_nothing); + if (accepted) { + return (db_->head_block_id() == block.id()) + ? block_outcome::accepted_extends_head + : block_outcome::accepted_into_fork_db; + } + return block_outcome::rejected_duplicate; + } catch (const fc::exception& e) { + const std::string msg = e.to_detail_string(); + if (msg.find("signature") != std::string::npos) + return block_outcome::rejected_invalid_signature; + if (msg.find("witness") != std::string::npos) + return block_outcome::rejected_witness_not_scheduled; + if (msg.find("duplicate") != std::string::npos) + return block_outcome::rejected_duplicate; + if (msg.find("invalid") != std::string::npos || + msg.find("merkle") != std::string::npos || + msg.find("balance") != std::string::npos) + return block_outcome::rejected_invalid_state; + return block_outcome::unexpected_exception; + } catch (...) { + return block_outcome::unexpected_exception; + } +} + +uint32_t simulated_node::head_block_num() const { return db_->head_block_num(); } +graphene::protocol::block_id_type simulated_node::head_block_id() const { return db_->head_block_id(); } +fc::time_point_sec simulated_node::head_block_time() const { return db_->head_block_time(); } +uint32_t simulated_node::last_irreversible_block_num() const { + return db_->last_non_undoable_block_num(); +} + +} // namespace consensus_sim diff --git a/tests/consensus_sim/harness/simulated_node.hpp b/tests/consensus_sim/harness/simulated_node.hpp new file mode 100644 index 0000000000..f098cb923e --- /dev/null +++ b/tests/consensus_sim/harness/simulated_node.hpp @@ -0,0 +1,65 @@ +#pragma once + +#include "genesis_factory.hpp" +#include "virtual_clock.hpp" + +#include +#include + +#include + +#include +#include + +namespace consensus_sim { + +enum class block_outcome { + accepted_extends_head, + accepted_into_fork_db, + rejected_invalid_signature, + rejected_invalid_state, + rejected_witness_not_scheduled, + rejected_duplicate, + unexpected_exception +}; + +class simulated_node { +public: + simulated_node(std::string label, + const genesis_params& params, + virtual_clock& clk); + ~simulated_node(); + + simulated_node(const simulated_node&) = delete; + simulated_node& operator=(const simulated_node&) = delete; + + /// Drive the wrapped database to produce a block at `when`, + /// signed by `witness` with `key`. Returns the produced block. + /// Throws std::runtime_error if the database refuses (caller's bug). + graphene::protocol::signed_block produce_block( + const graphene::protocol::account_name_type& witness, + const fc::ecc::private_key& key, + fc::time_point_sec when); + + /// Push a block produced elsewhere. Maps internal exceptions to + /// a typed outcome. Never throws. + block_outcome receive_block(const graphene::protocol::signed_block& block) noexcept; + + uint32_t head_block_num() const; + graphene::protocol::block_id_type head_block_id() const; + fc::time_point_sec head_block_time() const; + uint32_t last_irreversible_block_num() const; + + const std::string& label() const noexcept { return label_; } + +private: + void register_witness_keys_(const genesis_params& params); + + std::string label_; + fc::path data_dir_; + fc::path shared_mem_dir_; + std::unique_ptr db_; + virtual_clock& clk_; +}; + +} // namespace consensus_sim diff --git a/tests/consensus_sim/scenarios/test_simulated_node_smoke.cpp b/tests/consensus_sim/scenarios/test_simulated_node_smoke.cpp index 33a41b14dc..b3b7dbac8a 100644 --- a/tests/consensus_sim/scenarios/test_simulated_node_smoke.cpp +++ b/tests/consensus_sim/scenarios/test_simulated_node_smoke.cpp @@ -1 +1,49 @@ -// stub - implemented in later task +#include +#include "simulated_node.hpp" +#include "genesis_factory.hpp" +#include "virtual_clock.hpp" + +#include + +using namespace consensus_sim; + +BOOST_AUTO_TEST_SUITE(simulated_node_smoke_suite) + +// Milestone 1 produces blocks as the single witness VIZ's init_genesis +// actually creates (CHAIN_INITIATOR_NAME, "viz"). genesis_factory's +// witness_keys vector is reserved for Milestone 3 — see the comment in +// simulated_node::register_witness_keys_. + +BOOST_AUTO_TEST_CASE(single_node_produces_one_block) { + auto params = make_genesis_params(0xCAFE, 1); + auto t0 = fc::time_point_sec::from_iso_string("2026-01-01T00:00:00"); + virtual_clock clk(t0); + + simulated_node node("node-0", params, clk); + + auto block = node.produce_block(params.genesis_witness_name, + params.genesis_witness_key, + t0 + CHAIN_BLOCK_INTERVAL); + + BOOST_CHECK_EQUAL(node.head_block_num(), 1u); + BOOST_CHECK(node.head_block_id() == block.id()); + BOOST_CHECK(node.head_block_time() == t0 + fc::seconds(CHAIN_BLOCK_INTERVAL)); +} + +BOOST_AUTO_TEST_CASE(single_node_produces_100_blocks) { + auto params = make_genesis_params(0xBEEF, 1); + auto t0 = fc::time_point_sec::from_iso_string("2026-01-01T00:00:00"); + virtual_clock clk(t0); + + simulated_node node("node-0", params, clk); + + fc::time_point_sec t = t0; + for (int i = 0; i < 100; ++i) { + t += CHAIN_BLOCK_INTERVAL; + clk.advance_to(t); + node.produce_block(params.genesis_witness_name, params.genesis_witness_key, t); + } + BOOST_CHECK_EQUAL(node.head_block_num(), 100u); +} + +BOOST_AUTO_TEST_SUITE_END() From cf11ef793c71b04c404a1a2bd5f667d83c8662ca Mon Sep 17 00:00:00 2001 From: Vladimir Babin Date: Wed, 20 May 2026 03:29:17 +0800 Subject: [PATCH 07/17] test(consensus_sim): add message_bus with partition/drop primitives MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit In-process bus carrying std::shared_ptr payloads. Sorts by scheduled deliver_at on pump, applies the active partition split, and consumes drop_next markers per (from, to) link. delay_link adds extra seconds on enqueue (rounded down — fc::time_point_sec is 1-second). Suite covers: in-time-order delivery, partition blocks across the split, heal restores delivery, drop_next skips exactly one message. The plan's example used fc::time_point_sec("2026-..."), which doesn't compile (the ctor takes uint32_t); the test uses fc::time_point_sec::from_iso_string instead, same as Task 4. --- tests/consensus_sim/harness/message_bus.cpp | 71 +++++++++++++++++- tests/consensus_sim/harness/message_bus.hpp | 63 ++++++++++++++++ .../scenarios/test_message_bus.cpp | 75 ++++++++++++++++++- 3 files changed, 207 insertions(+), 2 deletions(-) create mode 100644 tests/consensus_sim/harness/message_bus.hpp diff --git a/tests/consensus_sim/harness/message_bus.cpp b/tests/consensus_sim/harness/message_bus.cpp index 33a41b14dc..46fdf92911 100644 --- a/tests/consensus_sim/harness/message_bus.cpp +++ b/tests/consensus_sim/harness/message_bus.cpp @@ -1 +1,70 @@ -// stub - implemented in later task +#include "message_bus.hpp" + +#include + +namespace consensus_sim { + +void message_bus::enqueue(std::string from, std::string to, + std::shared_ptr payload, + fc::time_point_sec deliver_at) { + auto key = std::make_pair(from, to); + auto it = link_delay_.find(key); + if (it != link_delay_.end()) deliver_at += it->second; + queue_.push_back({std::move(from), std::move(to), std::move(payload), deliver_at}); +} + +std::vector message_bus::pump_until(fc::time_point_sec now) { + std::sort(queue_.begin(), queue_.end(), + [](const queued& a, const queued& b) { return a.at < b.at; }); + + std::vector out; + auto end_it = std::remove_if(queue_.begin(), queue_.end(), + [&](queued& q) { + if (q.at > now) return false; + if (is_blocked_(q.from, q.to)) return true; + if (consume_drop_next_(q.from, q.to)) return true; + out.push_back({q.from, q.to, q.payload, q.at}); + return true; + }); + queue_.erase(end_it, queue_.end()); + return out; +} + +void message_bus::partition(std::set a, std::set b) { + partition_a_ = std::move(a); + partition_b_ = std::move(b); + partitioned_ = true; +} + +void message_bus::heal() { + partitioned_ = false; + partition_a_.clear(); + partition_b_.clear(); +} + +void message_bus::drop_next(const std::string& from, const std::string& to) { + ++drop_next_count_[{from, to}]; +} + +void message_bus::delay_link(const std::string& from, const std::string& to, + fc::microseconds extra) { + link_delay_[{from, to}] = extra; +} + +bool message_bus::is_blocked_(const std::string& from, const std::string& to) const { + if (!partitioned_) return false; + bool from_a = partition_a_.count(from) > 0; + bool to_a = partition_a_.count(to) > 0; + bool from_b = partition_b_.count(from) > 0; + bool to_b = partition_b_.count(to) > 0; + return (from_a && to_b) || (from_b && to_a); +} + +bool message_bus::consume_drop_next_(const std::string& from, const std::string& to) { + auto it = drop_next_count_.find({from, to}); + if (it == drop_next_count_.end() || it->second <= 0) return false; + if (--it->second == 0) drop_next_count_.erase(it); + return true; +} + +} // namespace consensus_sim diff --git a/tests/consensus_sim/harness/message_bus.hpp b/tests/consensus_sim/harness/message_bus.hpp new file mode 100644 index 0000000000..13e7dfbae4 --- /dev/null +++ b/tests/consensus_sim/harness/message_bus.hpp @@ -0,0 +1,63 @@ +#pragma once + +#include + +#include +#include +#include +#include +#include +#include + +namespace consensus_sim { + +struct delivery { + std::string from; + std::string to; + std::shared_ptr payload; + fc::time_point_sec at; +}; + +/// Opaque in-process message bus for the consensus harness. Carries +/// std::shared_ptr payloads without introspecting them, applies +/// partition / drop_next / per-link delay rules on pump. +class message_bus { +public: + void enqueue(std::string from, std::string to, + std::shared_ptr payload, + fc::time_point_sec deliver_at); + + /// Returns deliveries due at or before `now`, sorted by scheduled + /// delivery time. Removes them from the queue. Applies the active + /// partition + drop_next rules. + std::vector pump_until(fc::time_point_sec now); + + void partition(std::set side_a, std::set side_b); + void heal(); + void drop_next(const std::string& from, const std::string& to); + + /// Optional: per-link delay added on top of the deliver_at the caller + /// passed to enqueue. Stored in microseconds; rounded down to seconds + /// because fc::time_point_sec has 1-second granularity. + void delay_link(const std::string& from, const std::string& to, fc::microseconds extra); + +private: + bool is_blocked_(const std::string& from, const std::string& to) const; + bool consume_drop_next_(const std::string& from, const std::string& to); + + struct queued { + std::string from; + std::string to; + std::shared_ptr payload; + fc::time_point_sec at; + }; + + std::vector queue_; + std::set partition_a_; + std::set partition_b_; + bool partitioned_ = false; + std::map, int> drop_next_count_; + std::map, fc::microseconds> link_delay_; +}; + +} // namespace consensus_sim diff --git a/tests/consensus_sim/scenarios/test_message_bus.cpp b/tests/consensus_sim/scenarios/test_message_bus.cpp index 33a41b14dc..b5c837a409 100644 --- a/tests/consensus_sim/scenarios/test_message_bus.cpp +++ b/tests/consensus_sim/scenarios/test_message_bus.cpp @@ -1 +1,74 @@ -// stub - implemented in later task +#include +#include "message_bus.hpp" + +#include + +using namespace consensus_sim; + +namespace { +inline fc::time_point_sec epoch() { + return fc::time_point_sec::from_iso_string("2026-01-01T00:00:00"); +} +} + +BOOST_AUTO_TEST_SUITE(message_bus_suite) + +BOOST_AUTO_TEST_CASE(enqueue_and_pump_delivers_in_time_order) { + message_bus bus; + auto t0 = epoch(); + auto p1 = std::make_shared(1); + auto p2 = std::make_shared(2); + + bus.enqueue("a", "b", std::static_pointer_cast(p2), t0 + 10); + bus.enqueue("a", "b", std::static_pointer_cast(p1), t0 + 5); + + auto deliveries = bus.pump_until(t0 + 7); + BOOST_REQUIRE_EQUAL(deliveries.size(), 1u); + BOOST_CHECK_EQUAL(*static_cast(deliveries[0].payload.get()), 1); + + deliveries = bus.pump_until(t0 + 15); + BOOST_REQUIRE_EQUAL(deliveries.size(), 1u); + BOOST_CHECK_EQUAL(*static_cast(deliveries[0].payload.get()), 2); +} + +BOOST_AUTO_TEST_CASE(partition_drops_messages_across_split) { + message_bus bus; + auto t0 = epoch(); + bus.partition({"a"}, {"b"}); + + auto p = std::make_shared(42); + bus.enqueue("a", "b", std::static_pointer_cast(p), t0 + 1); + + auto deliveries = bus.pump_until(t0 + 10); + BOOST_CHECK_EQUAL(deliveries.size(), 0u); +} + +BOOST_AUTO_TEST_CASE(heal_restores_delivery) { + message_bus bus; + auto t0 = epoch(); + bus.partition({"a"}, {"b"}); + bus.heal(); + + auto p = std::make_shared(42); + bus.enqueue("a", "b", std::static_pointer_cast(p), t0 + 1); + + auto deliveries = bus.pump_until(t0 + 10); + BOOST_CHECK_EQUAL(deliveries.size(), 1u); +} + +BOOST_AUTO_TEST_CASE(drop_next_skips_one_message) { + message_bus bus; + auto t0 = epoch(); + bus.drop_next("a", "b"); + + auto p1 = std::make_shared(1); + auto p2 = std::make_shared(2); + bus.enqueue("a", "b", std::static_pointer_cast(p1), t0 + 1); + bus.enqueue("a", "b", std::static_pointer_cast(p2), t0 + 2); + + auto deliveries = bus.pump_until(t0 + 10); + BOOST_REQUIRE_EQUAL(deliveries.size(), 1u); + BOOST_CHECK_EQUAL(*static_cast(deliveries[0].payload.get()), 2); +} + +BOOST_AUTO_TEST_SUITE_END() From bdabea3f6a3de33e08b0ed9653811e40b5e096bd Mon Sep 17 00:00:00 2001 From: Vladimir Babin Date: Wed, 20 May 2026 03:40:02 +0800 Subject: [PATCH 08/17] test(consensus_sim): add invariant library skeleton MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit invariants.hpp/.cpp expose four cross-node consensus checks returning optional: - chains_consistent: heads at the same num must have the same id (Milestone 2 coarse-graining; finer shared-prefix walk is deferred until simulated_node exposes a block enumerator). - lib_monotone_checker: LIB never decreases per node, stateful via a label -> last-seen map. - supply_conserved: stub; Milestone 2 floor check will land when a scenario actually consumes it. - no_double_signed_in_canonical: stub; filled in by Task 13 once simulated_node grows the block-enumeration helper for the equivocation scenario. std::optional and the structured bindings already in test_genesis_factory need C++17, so the harness library + scenarios target now compile at CXX_STANDARD 17. Chain code itself stays C++14 — this is scoped to the test targets via set_target_properties. --- tests/consensus_sim/CMakeLists.txt | 12 +++++ tests/consensus_sim/harness/invariants.cpp | 59 +++++++++++++++++++++- tests/consensus_sim/harness/invariants.hpp | 50 ++++++++++++++++++ 3 files changed, 120 insertions(+), 1 deletion(-) create mode 100644 tests/consensus_sim/harness/invariants.hpp diff --git a/tests/consensus_sim/CMakeLists.txt b/tests/consensus_sim/CMakeLists.txt index 319a12a6cd..bf8c6bd104 100644 --- a/tests/consensus_sim/CMakeLists.txt +++ b/tests/consensus_sim/CMakeLists.txt @@ -21,6 +21,13 @@ target_link_libraries(consensus_sim_harness PUBLIC graphene_chain graphene_protocol graphene_utilities fc ) +# Harness uses std::optional and structured bindings; chain code itself +# stays C++14, so this is scoped to the harness + scenarios targets. +set_target_properties(consensus_sim_harness PROPERTIES + CXX_STANDARD 17 + CXX_STANDARD_REQUIRED ON +) + # Sanitizers on by default for the harness target_compile_options(consensus_sim_harness PUBLIC -fsanitize=address @@ -57,6 +64,11 @@ target_link_libraries(consensus_sim_tests Boost::unit_test_framework ) +set_target_properties(consensus_sim_tests PROPERTIES + CXX_STANDARD 17 + CXX_STANDARD_REQUIRED ON +) + # We link against the static libboost_unit_test_framework.a in the viz-dev # image, so do NOT define BOOST_TEST_DYN_LINK — that would expand to the # old-signature unit_test_main() which is absent from the static archive. diff --git a/tests/consensus_sim/harness/invariants.cpp b/tests/consensus_sim/harness/invariants.cpp index 33a41b14dc..049da8f91c 100644 --- a/tests/consensus_sim/harness/invariants.cpp +++ b/tests/consensus_sim/harness/invariants.cpp @@ -1 +1,58 @@ -// stub - implemented in later task +#include "invariants.hpp" + +namespace consensus_sim { + +std::optional chains_consistent( + const std::vector& nodes) { + if (nodes.size() < 2) return std::nullopt; + + auto* base = nodes[0]; + for (size_t i = 1; i < nodes.size(); ++i) { + auto* other = nodes[i]; + if (base->head_block_num() == other->head_block_num() && + !(base->head_block_id() == other->head_block_id())) { + return violation_report{ + "chains_consistent", + "head divergence at same height", + base->label() + " vs " + other->label(), + base->head_block_num() + }; + } + } + return std::nullopt; +} + +std::optional lib_monotone_checker::operator()( + const std::vector& nodes) { + for (auto* n : nodes) { + uint32_t cur = n->last_irreversible_block_num(); + auto it = last_lib_.find(n->label()); + if (it != last_lib_.end() && cur < it->second) { + return violation_report{ + "lib_monotone", + "LIB regressed from " + std::to_string(it->second) + + " to " + std::to_string(cur), + n->label(), + cur + }; + } + last_lib_[n->label()] = cur; + } + return std::nullopt; +} + +std::optional supply_conserved( + const std::vector& nodes, + uint64_t expected_supply_floor) { + (void)nodes; + (void)expected_supply_floor; + return std::nullopt; +} + +std::optional no_double_signed_in_canonical( + const std::vector& nodes) { + (void)nodes; + return std::nullopt; +} + +} // namespace consensus_sim diff --git a/tests/consensus_sim/harness/invariants.hpp b/tests/consensus_sim/harness/invariants.hpp new file mode 100644 index 0000000000..4801bf5877 --- /dev/null +++ b/tests/consensus_sim/harness/invariants.hpp @@ -0,0 +1,50 @@ +#pragma once + +#include "simulated_node.hpp" + +#include +#include +#include +#include + +namespace consensus_sim { + +struct violation_report { + std::string invariant_name; + std::string detail; + std::string node_label; // empty if cross-node + uint32_t block_num = 0; +}; + +/// All canonical chains agree on every block they share — one is a prefix +/// of, or equal to, the other. Modulo blocks still in flight, this should +/// hold every tick after pump. Milestone 2 coarse-grains the check to +/// "heads at the same num must have the same id"; finer-grained shared- +/// prefix comparison is deferred until simulated_node exposes a block +/// enumerator. +std::optional chains_consistent( + const std::vector& nodes); + +/// LIB never decreases on any node. +class lib_monotone_checker { +public: + std::optional operator()( + const std::vector& nodes); +private: + std::map last_lib_; +}; + +/// Total VIZ supply matches genesis (no asset created or destroyed +/// outside the chain's own issuance/burn rules). Stubbed until a +/// scenario needs it; floor check only when implemented. +std::optional supply_conserved( + const std::vector& nodes, + uint64_t expected_supply_floor); + +/// No two distinct blocks with the same (witness, slot) appear in any +/// node's canonical chain. Filled in by Task 13 (equivocation), when the +/// simulated_node block-enumeration helper is in place. +std::optional no_double_signed_in_canonical( + const std::vector& nodes); + +} // namespace consensus_sim From ff7b304310ce4e9fd8fc5232fcd22e7e23ea106f Mon Sep 17 00:00:00 2001 From: Vladimir Babin Date: Wed, 20 May 2026 03:43:04 +0800 Subject: [PATCH 09/17] test(consensus_sim): add scenario_driver event loop MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit scenario_driver owns the clock, the message bus, the per-witness simulated_node set, the genesis_params, and the registered invariants. run() steps slot-by-slot up to cfg.max_slots: advance clock, call the slot producer, pump the bus, deliver to peers, and run each invariant against the node set. First violation wins — driver stops and exposes it via violation() alongside the event log. The slot producer is swappable (set_slot_producer); the default round-robins through params.witness_keys. fault_injector will override this in a later task to inject equivocation. Two adaptations from the plan: - scenario_config::start_time defaults to fc::time_point_sec() (=0), not fc::time_point_sec("2026-..."). The ctor is explicit-uint32_t — same compile bug the test code hit in earlier tasks. Scenarios set an explicit time. - The default round-robin producer assumes per-index witness keys are registered on chain. Milestone 1 genesis only registers CHAIN_COMMITTEE_ACCOUNT, so the default producer can't drive blocks yet. Documented in the implementation comment; Milestone 3 will either rotate keys via witness_update or seed a multi-witness genesis. No test exercises run() yet — Task 10 is the first. --- .../consensus_sim/harness/scenario_driver.cpp | 109 +++++++++++++++++- .../consensus_sim/harness/scenario_driver.hpp | 77 +++++++++++++ 2 files changed, 185 insertions(+), 1 deletion(-) create mode 100644 tests/consensus_sim/harness/scenario_driver.hpp diff --git a/tests/consensus_sim/harness/scenario_driver.cpp b/tests/consensus_sim/harness/scenario_driver.cpp index 33a41b14dc..0a6f163196 100644 --- a/tests/consensus_sim/harness/scenario_driver.cpp +++ b/tests/consensus_sim/harness/scenario_driver.cpp @@ -1 +1,108 @@ -// stub - implemented in later task +#include "scenario_driver.hpp" + +#include + +#include + +namespace consensus_sim { + +scenario_driver::scenario_driver(scenario_config cfg) + : cfg_(std::move(cfg)), + params_(make_genesis_params(cfg_.seed, cfg_.num_witnesses)), + clk_(cfg_.start_time) { + for (uint32_t i = 0; i < cfg_.num_witnesses; ++i) { + auto label = "node-" + std::to_string(i); + auto n = std::make_unique(label, params_, clk_); + node_ptrs_.push_back(n.get()); + owned_nodes_.push_back(std::move(n)); + } + produce_slot_ = [this](scenario_driver&, uint32_t slot, fc::time_point_sec when) { + default_slot_producer_(slot, when); + }; +} + +scenario_driver::~scenario_driver() = default; + +void scenario_driver::add_invariant(invariant_fn fn) { + invariants_.push_back(std::move(fn)); +} + +const std::vector& scenario_driver::nodes() const noexcept { + return node_ptrs_; +} + +simulated_node* scenario_driver::witness_to_node_( + const graphene::protocol::account_name_type& w) { + for (size_t i = 0; i < params_.witness_keys.size(); ++i) { + if (params_.witness_keys[i].first == w) return node_ptrs_[i]; + } + return nullptr; +} + +void scenario_driver::default_slot_producer_(uint32_t slot, fc::time_point_sec when) { + // Round-robin: witness index = slot % num_witnesses. This is a + // simplification — real graphene uses a shuffled schedule. It also + // assumes the per-index witness identities in params_.witness_keys + // are registered on the chain; with the Milestone 1 genesis (only + // CHAIN_COMMITTEE_ACCOUNT exists) the chain will reject these + // signatures. Milestone 3+ either rotates keys via witness_update + // or runs the scenarios with a multi-witness genesis. + uint32_t idx = slot % cfg_.num_witnesses; + auto& [name, key] = params_.witness_keys[idx]; + auto* producer = node_ptrs_[idx]; + + auto block = producer->produce_block(name, key, when); + auto ptr = std::make_shared(block); + + events_.push_back("slot=" + std::to_string(slot) + + " producer=" + producer->label() + + " block_num=" + std::to_string(block.block_num())); + + for (auto* peer : node_ptrs_) { + if (peer == producer) continue; + bus_.enqueue(producer->label(), peer->label(), + std::static_pointer_cast(ptr), when); + } +} + +std::optional scenario_driver::run() { + fc::time_point_sec t = cfg_.start_time; + for (uint32_t slot = 1; slot <= cfg_.max_slots; ++slot) { + t += static_cast(CHAIN_BLOCK_INTERVAL); + clk_.advance_to(t); + + produce_slot_(*this, slot, t); + + auto deliveries = bus_.pump_until(t); + for (auto& d : deliveries) { + simulated_node* dest = nullptr; + for (auto* n : node_ptrs_) if (n->label() == d.to) { dest = n; break; } + if (!dest) continue; + auto block = std::static_pointer_cast(d.payload); + auto outcome = dest->receive_block(*block); + std::ostringstream oss; + oss << "deliver " << d.from << "->" << d.to + << " block_num=" << block->block_num() + << " outcome=" << static_cast(outcome); + events_.push_back(oss.str()); + if (outcome == block_outcome::unexpected_exception) { + violation_ = violation_report{ + "block_outcome", + "unexpected_exception delivering block " + + std::to_string(block->block_num()), + dest->label(), + block->block_num() + }; + return violation_; + } + } + + for (auto& inv : invariants_) { + auto v = inv(node_ptrs_); + if (v) { violation_ = v; return violation_; } + } + } + return std::nullopt; +} + +} // namespace consensus_sim diff --git a/tests/consensus_sim/harness/scenario_driver.hpp b/tests/consensus_sim/harness/scenario_driver.hpp new file mode 100644 index 0000000000..0bffeea070 --- /dev/null +++ b/tests/consensus_sim/harness/scenario_driver.hpp @@ -0,0 +1,77 @@ +#pragma once + +#include "genesis_factory.hpp" +#include "invariants.hpp" +#include "message_bus.hpp" +#include "simulated_node.hpp" +#include "virtual_clock.hpp" + +#include +#include +#include +#include +#include + +namespace consensus_sim { + +struct scenario_config { + uint64_t seed = 0; + uint32_t num_witnesses = 7; + /// Defaults to epoch (utc_seconds=0); scenarios should set an explicit + /// time. The plan's literal-string default doesn't compile — + /// fc::time_point_sec's ctor takes uint32_t. + fc::time_point_sec start_time = fc::time_point_sec(); + uint32_t max_slots = 100; + fc::microseconds default_link_delay = fc::microseconds(0); +}; + +using invariant_fn = std::function< + std::optional(const std::vector&)>; + +class scenario_driver { +public: + explicit scenario_driver(scenario_config cfg); + ~scenario_driver(); + + scenario_driver(const scenario_driver&) = delete; + scenario_driver& operator=(const scenario_driver&) = delete; + + void add_invariant(invariant_fn fn); + + /// Runs the scheduled slot count. Returns nullopt on full success; + /// the first violation otherwise. On violation the driver stops; the + /// violation can be retrieved via violation() alongside the event log. + std::optional run(); + + const std::vector& nodes() const noexcept; + message_bus& bus() noexcept { return bus_; } + virtual_clock& clock() noexcept { return clk_; } + const std::vector& event_log() const noexcept { return events_; } + const std::optional& violation() const noexcept { return violation_; } + const scenario_config& config() const noexcept { return cfg_; } + const genesis_params& params() const noexcept { return params_; } + + /// Per-slot producer hook. Default round-robins through + /// params.witness_keys; fault_injector overrides this to inject + /// equivocation in a later task. + using slot_producer_fn = std::function< + void(scenario_driver&, uint32_t slot, fc::time_point_sec when)>; + void set_slot_producer(slot_producer_fn fn) { produce_slot_ = std::move(fn); } + +private: + void default_slot_producer_(uint32_t slot, fc::time_point_sec when); + simulated_node* witness_to_node_(const graphene::protocol::account_name_type& w); + + scenario_config cfg_; + genesis_params params_; + virtual_clock clk_; + message_bus bus_; + std::vector> owned_nodes_; + std::vector node_ptrs_; + std::vector invariants_; + std::vector events_; + std::optional violation_; + slot_producer_fn produce_slot_; +}; + +} // namespace consensus_sim From 550d9a3d26c8f90a8cc33fa0f3b1bc075301509c Mon Sep 17 00:00:00 2001 From: Vladimir Babin Date: Wed, 20 May 2026 10:48:32 +0800 Subject: [PATCH 10/17] test(consensus_sim): 7-node no-faults smoke passes The plan's default round-robin in scenario_driver indexed into params_.witness_keys, assuming each per-witness identity was registered on chain. With CHAIN_NUM_INITIATORS=0 only the committee account exists, so the harness can't actually drive seven distinct witness signatures at Milestone 2. Adapted: the default producer still round-robins which node generates the block (so message-flow + bus + convergence get exercised), but every block is signed by params_.genesis_witness_*. Multi-witness rotation is deferred to Milestone 3, when register_witness_keys_ gains a witness_update path. Suite covers: 7 nodes, 100 slots, chains_consistent + lib_monotone invariants checked every slot, all nodes converge to the same head. --- .../consensus_sim/harness/scenario_driver.cpp | 18 ++++----- .../scenarios/test_smoke_no_faults.cpp | 38 ++++++++++++++++++- 2 files changed, 46 insertions(+), 10 deletions(-) diff --git a/tests/consensus_sim/harness/scenario_driver.cpp b/tests/consensus_sim/harness/scenario_driver.cpp index 0a6f163196..ae26f18bb7 100644 --- a/tests/consensus_sim/harness/scenario_driver.cpp +++ b/tests/consensus_sim/harness/scenario_driver.cpp @@ -40,18 +40,18 @@ simulated_node* scenario_driver::witness_to_node_( } void scenario_driver::default_slot_producer_(uint32_t slot, fc::time_point_sec when) { - // Round-robin: witness index = slot % num_witnesses. This is a - // simplification — real graphene uses a shuffled schedule. It also - // assumes the per-index witness identities in params_.witness_keys - // are registered on the chain; with the Milestone 1 genesis (only - // CHAIN_COMMITTEE_ACCOUNT exists) the chain will reject these - // signatures. Milestone 3+ either rotates keys via witness_update - // or runs the scenarios with a multi-witness genesis. + // Milestone 2 producer: round-robin which node generates the block, but + // every block is signed by the genesis witness (CHAIN_COMMITTEE_ACCOUNT + // is the only on-chain witness with init_genesis + CHAIN_NUM_INITIATORS=0). + // This exercises bus delivery + convergence + invariants without needing + // multi-witness genesis. Milestone 3+ will replace this with a producer + // that uses the per-index witness identities from params_.witness_keys + // once register_witness_keys_ actually rotates keys via witness_update. uint32_t idx = slot % cfg_.num_witnesses; - auto& [name, key] = params_.witness_keys[idx]; auto* producer = node_ptrs_[idx]; - auto block = producer->produce_block(name, key, when); + auto block = producer->produce_block(params_.genesis_witness_name, + params_.genesis_witness_key, when); auto ptr = std::make_shared(block); events_.push_back("slot=" + std::to_string(slot) diff --git a/tests/consensus_sim/scenarios/test_smoke_no_faults.cpp b/tests/consensus_sim/scenarios/test_smoke_no_faults.cpp index 33a41b14dc..2c31337860 100644 --- a/tests/consensus_sim/scenarios/test_smoke_no_faults.cpp +++ b/tests/consensus_sim/scenarios/test_smoke_no_faults.cpp @@ -1 +1,37 @@ -// stub - implemented in later task +#include +#include "scenario_driver.hpp" + +using namespace consensus_sim; + +BOOST_AUTO_TEST_SUITE(smoke_no_faults_suite) + +BOOST_AUTO_TEST_CASE(seven_nodes_one_hundred_slots) { + scenario_config cfg; + cfg.seed = 0xDEADBEEF; + cfg.num_witnesses = 7; + cfg.max_slots = 100; + cfg.start_time = fc::time_point_sec::from_iso_string("2026-01-01T00:00:00"); + + scenario_driver d(cfg); + + d.add_invariant(chains_consistent); + lib_monotone_checker lib_check; + d.add_invariant([&lib_check](const std::vector& ns) { + return lib_check(ns); + }); + + auto result = d.run(); + if (result) { + BOOST_FAIL("invariant violated: " + result->invariant_name + " - " + result->detail); + } + + for (auto* n : d.nodes()) { + BOOST_CHECK_GE(n->head_block_num(), 90u); + } + auto head_id = d.nodes()[0]->head_block_id(); + for (auto* n : d.nodes()) { + BOOST_CHECK(n->head_block_id() == head_id); + } +} + +BOOST_AUTO_TEST_SUITE_END() From 044109febd74653a3f847a1ab87ea8d76e0896af Mon Sep 17 00:00:00 2001 From: Vladimir Babin Date: Wed, 20 May 2026 11:58:09 +0800 Subject: [PATCH 11/17] test(consensus_sim): determinism replay self-test passes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two independent driver runs with seed=0x12345 produce byte-identical event logs across 50 slots × 7 nodes. This is the canary for non-determinism leaks the foundation plan calls out — if it starts failing, suspect (in order) an unordered container with a default hasher in chain code, a stray fc::time_point::now() that affects state, or pointer-address ordering in the harness. The plan's second case (different_seed_diverges) is dropped from Milestone 2: with the current producer signing every block as the genesis witness, `seed` only feeds the unused per-index witness_keys, so different seeds produce identical logs. It comes back in Milestone 3 once register_witness_keys_ rotates per-witness keys via witness_update. --- .../scenarios/test_determinism_replay.cpp | 39 ++++++++++++++++++- 1 file changed, 38 insertions(+), 1 deletion(-) diff --git a/tests/consensus_sim/scenarios/test_determinism_replay.cpp b/tests/consensus_sim/scenarios/test_determinism_replay.cpp index 33a41b14dc..d88cc2a2dd 100644 --- a/tests/consensus_sim/scenarios/test_determinism_replay.cpp +++ b/tests/consensus_sim/scenarios/test_determinism_replay.cpp @@ -1 +1,38 @@ -// stub - implemented in later task +#include +#include "scenario_driver.hpp" + +using namespace consensus_sim; + +BOOST_AUTO_TEST_SUITE(determinism_replay_suite) + +BOOST_AUTO_TEST_CASE(same_seed_byte_identical_event_log) { + auto run_once = [](uint64_t seed) { + scenario_config cfg; + cfg.seed = seed; + cfg.num_witnesses = 7; + cfg.max_slots = 50; + cfg.start_time = fc::time_point_sec::from_iso_string("2026-01-01T00:00:00"); + scenario_driver d(cfg); + d.add_invariant(chains_consistent); + auto v = d.run(); + BOOST_REQUIRE(!v); + return d.event_log(); + }; + + auto a = run_once(0x12345); + auto b = run_once(0x12345); + BOOST_REQUIRE_EQUAL(a.size(), b.size()); + for (size_t i = 0; i < a.size(); ++i) { + BOOST_CHECK_EQUAL(a[i], b[i]); + } +} + +// The plan's second case (different_seed_diverges) is deferred to +// Milestone 3. With the current Milestone 2 producer every block is +// signed by the genesis witness, so the per-index witness_keys that +// `seed` feeds into are never used — different seeds produce identical +// event logs. The divergence sanity check only becomes meaningful +// once register_witness_keys_ rotates per-witness keys via +// witness_update. + +BOOST_AUTO_TEST_SUITE_END() From 34cb1c561a4842cb813c9eafced54da39fff1245 Mon Sep 17 00:00:00 2001 From: Vladimir Babin Date: Wed, 20 May 2026 13:41:06 +0800 Subject: [PATCH 12/17] test(consensus_sim): write failure logs on invariant violation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit write_failure_log dumps seed, config, full event log, final per-node head/lib, and the triggering violation into /failures/-.log. Scenarios call it themselves before BOOST_FAIL so the bad run is reproducible from the seed. Wired into the 7-node smoke scenario; full test binary still passes clean with no failure log written. Milestone 2 ships here — multi-node deterministic harness with invariants and failure capture. --- tests/consensus_sim/harness/failure_log.cpp | 50 ++++++++++++++++++- tests/consensus_sim/harness/failure_log.hpp | 16 ++++++ .../scenarios/test_smoke_no_faults.cpp | 2 + 3 files changed, 67 insertions(+), 1 deletion(-) create mode 100644 tests/consensus_sim/harness/failure_log.hpp diff --git a/tests/consensus_sim/harness/failure_log.cpp b/tests/consensus_sim/harness/failure_log.cpp index 33a41b14dc..75d4e79b80 100644 --- a/tests/consensus_sim/harness/failure_log.cpp +++ b/tests/consensus_sim/harness/failure_log.cpp @@ -1 +1,49 @@ -// stub - implemented in later task +#include "failure_log.hpp" + +#include + +#include + +namespace consensus_sim { + +namespace bfs = boost::filesystem; + +void write_failure_log(const std::string& scenario_name, + const scenario_driver& driver) { + bfs::path dir = bfs::current_path() / "failures"; + boost::system::error_code ec; + bfs::create_directories(dir, ec); + if (ec) return; + + auto seed = driver.config().seed; + auto path = dir / (std::to_string(seed) + "-" + scenario_name + ".log"); + std::ofstream f(path.string()); + if (!f) return; + + f << "seed=" << seed << "\n" + << "scenario=" << scenario_name << "\n" + << "num_witnesses=" << driver.config().num_witnesses << "\n" + << "max_slots=" << driver.config().max_slots << "\n" + << "events=" << driver.event_log().size() << "\n\n"; + + f << "## events\n"; + for (const auto& e : driver.event_log()) f << e << "\n"; + + f << "\n## final state\n"; + for (auto* n : driver.nodes()) { + f << n->label() + << " head=" << n->head_block_num() + << " lib=" << n->last_irreversible_block_num() << "\n"; + } + + if (driver.violation()) { + const auto& v = *driver.violation(); + f << "\n## violation\n" + << "invariant=" << v.invariant_name << "\n" + << "detail=" << v.detail << "\n" + << "node=" << v.node_label << "\n" + << "block_num=" << v.block_num << "\n"; + } +} + +} // namespace consensus_sim diff --git a/tests/consensus_sim/harness/failure_log.hpp b/tests/consensus_sim/harness/failure_log.hpp new file mode 100644 index 0000000000..b4fdcd78f8 --- /dev/null +++ b/tests/consensus_sim/harness/failure_log.hpp @@ -0,0 +1,16 @@ +#pragma once + +#include "scenario_driver.hpp" + +#include + +namespace consensus_sim { + +/// Writes `/failures/-.log` containing the seed, +/// scenario name, config knobs, full event log, final per-node state, and +/// the triggering invariant report (if any). Called from scenarios on +/// violation; no-op-on-disk if the failures/ directory can't be created. +void write_failure_log(const std::string& scenario_name, + const scenario_driver& driver); + +} // namespace consensus_sim diff --git a/tests/consensus_sim/scenarios/test_smoke_no_faults.cpp b/tests/consensus_sim/scenarios/test_smoke_no_faults.cpp index 2c31337860..d39d4fe0b9 100644 --- a/tests/consensus_sim/scenarios/test_smoke_no_faults.cpp +++ b/tests/consensus_sim/scenarios/test_smoke_no_faults.cpp @@ -1,5 +1,6 @@ #include #include "scenario_driver.hpp" +#include "failure_log.hpp" using namespace consensus_sim; @@ -22,6 +23,7 @@ BOOST_AUTO_TEST_CASE(seven_nodes_one_hundred_slots) { auto result = d.run(); if (result) { + write_failure_log("smoke_no_faults", d); BOOST_FAIL("invariant violated: " + result->invariant_name + " - " + result->detail); } From 0371798d540864c95751a87d9412a90e1ff4aa49 Mon Sep 17 00:00:00 2001 From: Vladimir Babin Date: Wed, 20 May 2026 15:53:38 +0800 Subject: [PATCH 13/17] test(consensus_sim): add fault_injector and no_double_signed invariant MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Plumbs the equivocation-detection path end to end without yet driving it from a scenario: - simulated_node: expose recent_blocks(count) walking head backward via fetch_block_by_id; returns block_num + id + witness + timestamp so invariants can key on (witness, slot). - invariants: replace the no_double_signed_in_canonical stub with the real check — for each node, build a map from (witness, slot) to id over the last 200 canonical blocks; report a violation on collision. - fault_injector: new harness facade exposing partition/heal/delay_link/ drop_next as forwarders to message_bus, plus instruct_equivocation() which overrides the slot producer to fire once for a chosen witness. Honest path matches the default driver behavior (every block signed by genesis witness, per Milestone 2's single-witness genesis). The equivocation slot ships block_a only and flags the shadow-chain reconstruction gap inline — full second-block production requires returning signed_block bodies from recent_blocks (or in-place merkle mutation + resigning), both deferred to Task 14 when a concrete failure mode forces a choice. All 16 existing tests still pass under ASan/UBSan. --- .../consensus_sim/harness/fault_injector.cpp | 89 ++++++++++++++++++- .../consensus_sim/harness/fault_injector.hpp | 50 +++++++++++ tests/consensus_sim/harness/invariants.cpp | 26 +++++- .../consensus_sim/harness/simulated_node.cpp | 13 +++ .../consensus_sim/harness/simulated_node.hpp | 13 +++ 5 files changed, 189 insertions(+), 2 deletions(-) create mode 100644 tests/consensus_sim/harness/fault_injector.hpp diff --git a/tests/consensus_sim/harness/fault_injector.cpp b/tests/consensus_sim/harness/fault_injector.cpp index 33a41b14dc..70f2336922 100644 --- a/tests/consensus_sim/harness/fault_injector.cpp +++ b/tests/consensus_sim/harness/fault_injector.cpp @@ -1 +1,88 @@ -// stub - implemented in later task +#include "fault_injector.hpp" + +#include + +#include + +namespace consensus_sim { + +void fault_injector::partition(std::set a, std::set b) { + driver_.bus().partition(std::move(a), std::move(b)); +} + +void fault_injector::heal() { driver_.bus().heal(); } + +void fault_injector::delay_link(const std::string& from, const std::string& to, + fc::microseconds extra) { + driver_.bus().delay_link(from, to, extra); +} + +void fault_injector::drop_next(const std::string& from, const std::string& to) { + driver_.bus().drop_next(from, to); +} + +void fault_injector::instruct_equivocation( + const graphene::protocol::account_name_type& witness) { + equivocate_witness_ = witness; + equivocation_consumed_ = false; + + driver_.set_slot_producer( + [this](scenario_driver& d, uint32_t slot, fc::time_point_sec when) { + const auto& params = d.params(); + const auto& gw_name = params.genesis_witness_name; + const auto& gw_key = params.genesis_witness_key; + + uint32_t idx = slot % d.config().num_witnesses; + auto* prod = d.nodes()[idx]; + + const bool fire = + equivocate_witness_ && gw_name == *equivocate_witness_ && + !equivocation_consumed_; + + // Honest path (also: every slot before/after the equivocation + // slot). Matches the default slot producer in scenario_driver. + if (!fire) { + auto block = prod->produce_block(gw_name, gw_key, when); + auto ptr = std::make_shared(block); + for (auto* peer : d.nodes()) { + if (peer == prod) continue; + d.bus().enqueue(prod->label(), peer->label(), + std::static_pointer_cast(ptr), when); + } + return; + } + + // Equivocation slot: produce two blocks for the same witness/slot. + // + // Approach: build a "shadow" simulated_node that catches up to + // prod's height via receive_block, then call produce_block on + // both prod and shadow at `when`. With identical genesis, identical + // witness/key, and the same head, the two blocks differ only in + // contents the shadow controls independently (transaction pool, + // future state). + // + // GAP (filled by Task 14 once the concrete failure mode is known): + // catching the shadow up requires full signed_block bodies, but + // simulated_node::recent_blocks() returns only block metadata. + // Two viable extensions when needed: + // 1. Extend recent_blocks() (or add canonical_chain_blocks()) + // to return full signed_block objects. + // 2. Take the produced block_a, mutate transaction_merkle_root + // or include/exclude one synthetic tx, and re-sign with + // gw_key. + // + // Until then this path produces and broadcasts block_a only, + // matching the honest path, and marks the gap. + auto block_a = prod->produce_block(gw_name, gw_key, when); + auto ptr_a = std::make_shared(block_a); + for (auto* peer : d.nodes()) { + if (peer == prod) continue; + d.bus().enqueue(prod->label(), peer->label(), + std::static_pointer_cast(ptr_a), when); + } + + equivocation_consumed_ = true; + }); +} + +} // namespace consensus_sim diff --git a/tests/consensus_sim/harness/fault_injector.hpp b/tests/consensus_sim/harness/fault_injector.hpp new file mode 100644 index 0000000000..cfdf64e071 --- /dev/null +++ b/tests/consensus_sim/harness/fault_injector.hpp @@ -0,0 +1,50 @@ +#pragma once + +#include "scenario_driver.hpp" + +#include + +#include + +#include +#include +#include + +namespace consensus_sim { + +/// Thin facade over scenario_driver's bus + slot_producer hooks. Lets a +/// scenario declaratively express network and producer-side faults without +/// reaching into the driver internals. +/// +/// Lifetime: must outlive the driver's run(). +class fault_injector { +public: + explicit fault_injector(scenario_driver& driver) : driver_(driver) {} + + // --- Network faults (forwarded to the message bus) --- + + void partition(std::set a, std::set b); + void heal(); + void delay_link(const std::string& from, const std::string& to, + fc::microseconds extra); + void drop_next(const std::string& from, const std::string& to); + + // --- Producer-side faults --- + + /// On the next slot whose canonical producer is `witness`, instead + /// of producing one block, produce TWO blocks A and B for the same + /// slot, both validly signed by `witness`, differing in their + /// transaction set (or, if the harness can't yet construct two + /// distinct valid blocks for the same slot, ship A only and record + /// the gap — Task 14 surfaces the concrete behavior). + /// + /// Fires once and then reverts to honest production. + void instruct_equivocation(const graphene::protocol::account_name_type& witness); + +private: + scenario_driver& driver_; + std::optional equivocate_witness_; + bool equivocation_consumed_ = false; +}; + +} // namespace consensus_sim diff --git a/tests/consensus_sim/harness/invariants.cpp b/tests/consensus_sim/harness/invariants.cpp index 049da8f91c..31711384fe 100644 --- a/tests/consensus_sim/harness/invariants.cpp +++ b/tests/consensus_sim/harness/invariants.cpp @@ -1,5 +1,10 @@ #include "invariants.hpp" +#include + +#include +#include + namespace consensus_sim { std::optional chains_consistent( @@ -51,7 +56,26 @@ std::optional supply_conserved( std::optional no_double_signed_in_canonical( const std::vector& nodes) { - (void)nodes; + for (auto* n : nodes) { + auto blocks = n->recent_blocks(200); + std::map, + graphene::protocol::block_id_type> seen; + for (auto& b : blocks) { + uint32_t slot = b.timestamp.sec_since_epoch() / CHAIN_BLOCK_INTERVAL; + std::pair key{b.witness, slot}; + auto it = seen.find(key); + if (it != seen.end() && !(it->second == b.id)) { + return violation_report{ + "no_double_signed_in_canonical", + "witness " + b.witness + " has two distinct blocks at slot " + + std::to_string(slot), + n->label(), + b.block_num + }; + } + seen[key] = b.id; + } + } return std::nullopt; } diff --git a/tests/consensus_sim/harness/simulated_node.cpp b/tests/consensus_sim/harness/simulated_node.cpp index fc3ceea88b..92c08512de 100644 --- a/tests/consensus_sim/harness/simulated_node.cpp +++ b/tests/consensus_sim/harness/simulated_node.cpp @@ -101,4 +101,17 @@ uint32_t simulated_node::last_irreversible_block_num() const { return db_->last_non_undoable_block_num(); } +std::vector simulated_node::recent_blocks(uint32_t count) const { + std::vector out; + auto cur = db_->head_block_id(); + for (uint32_t i = 0; i < count; ++i) { + if (cur == graphene::protocol::block_id_type()) break; + auto b = db_->fetch_block_by_id(cur); + if (!b) break; + out.push_back({b->block_num(), cur, b->witness, b->timestamp}); + cur = b->previous; + } + return out; +} + } // namespace consensus_sim diff --git a/tests/consensus_sim/harness/simulated_node.hpp b/tests/consensus_sim/harness/simulated_node.hpp index f098cb923e..115ca89813 100644 --- a/tests/consensus_sim/harness/simulated_node.hpp +++ b/tests/consensus_sim/harness/simulated_node.hpp @@ -10,9 +10,17 @@ #include #include +#include namespace consensus_sim { +struct chain_block_info { + uint32_t block_num; + graphene::protocol::block_id_type id; + std::string witness; + fc::time_point_sec timestamp; +}; + enum class block_outcome { accepted_extends_head, accepted_into_fork_db, @@ -50,6 +58,11 @@ class simulated_node { fc::time_point_sec head_block_time() const; uint32_t last_irreversible_block_num() const; + /// Walks the canonical chain backward from head, returns up to `count` + /// entries (newest first). Exposes witness + timestamp so equivocation + /// invariants can detect repeated (witness, slot) pairs. + std::vector recent_blocks(uint32_t count) const; + const std::string& label() const noexcept { return label_; } private: From dd1285c66dbf7289c58c298f088916b8660c0873 Mon Sep 17 00:00:00 2001 From: Vladimir Babin Date: Wed, 20 May 2026 15:59:41 +0800 Subject: [PATCH 14/17] test(consensus_sim): single-seed equivocation scenario Adds equivocation_suite/seed_deadbeef_no_canonical_double_sign: - 7 witnesses, 30 slots, seed 0xDEADBEEF. - chains_consistent + no_double_signed_in_canonical + lib_monotone. - fi.instruct_equivocation(params.genesis_witness_name) so the override actually fires; the plan's per-index witness_keys[i] target is parked until multi-witness key rotation lands. Passes trivially today: Task 13's instruct_equivocation ships block_a only and flags the shadow-chain reconstruction gap inline. Closing that gap (sibling-state shadow or direct-mutation + resign) is a focused follow-up captured in the inline comment. Result: 3.0s, no invariant violations, exercises the fault_injector facade end to end. --- .../scenarios/test_equivocation.cpp | 46 ++++++++++++++++++- 1 file changed, 45 insertions(+), 1 deletion(-) diff --git a/tests/consensus_sim/scenarios/test_equivocation.cpp b/tests/consensus_sim/scenarios/test_equivocation.cpp index 33a41b14dc..8da666ae3e 100644 --- a/tests/consensus_sim/scenarios/test_equivocation.cpp +++ b/tests/consensus_sim/scenarios/test_equivocation.cpp @@ -1 +1,45 @@ -// stub - implemented in later task +#include + +#include "fault_injector.hpp" +#include "failure_log.hpp" +#include "invariants.hpp" +#include "scenario_driver.hpp" + +using namespace consensus_sim; + +BOOST_AUTO_TEST_SUITE(equivocation_suite) + +BOOST_AUTO_TEST_CASE(seed_deadbeef_no_canonical_double_sign) { + scenario_config cfg; + cfg.seed = 0xDEADBEEF; + cfg.num_witnesses = 7; + cfg.max_slots = 30; + + scenario_driver d(cfg); + fault_injector fi(d); + + d.add_invariant(chains_consistent); + d.add_invariant(no_double_signed_in_canonical); + lib_monotone_checker lib_check; + d.add_invariant([&](const auto& ns) { return lib_check(ns); }); + + // Milestone 2 wires every slot to the genesis witness (CHAIN_COMMITTEE_ACCOUNT, + // the only on-chain witness when CHAIN_NUM_INITIATORS=0). Equivocation must + // therefore target that witness to fire; the plan's `params.witness_keys[3]` + // path will reactivate once multi-witness key rotation lands in a follow-up. + // + // The instruct_equivocation body currently ships block_a only and flags the + // shadow-chain reconstruction gap, so this test is expected to pass trivially + // until that gap is closed (sibling-state or direct-mutation second-block + // construction). + const auto& params = d.params(); + fi.instruct_equivocation(params.genesis_witness_name); + + auto result = d.run(); + if (result) { + write_failure_log("equivocation_seed_deadbeef", d); + BOOST_FAIL("invariant violated: " + result->invariant_name + " - " + result->detail); + } +} + +BOOST_AUTO_TEST_SUITE_END() From cb155bb2fb1b6b292f829f8b0448697fcdd4fc4e Mon Sep 17 00:00:00 2001 From: Vladimir Babin Date: Wed, 20 May 2026 16:17:39 +0800 Subject: [PATCH 15/17] test(consensus_sim): 100-seed equivocation sweep MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds equivocation_suite/seed_sweep_one_hundred: loops seeds 0..99, varying genesis_params for each, runs the equivocation override against the genesis witness, asserts no_double_signed_in_canonical. Slot count dropped to 10 (from 30) for the sweep — each scenario spins up 7 chainbase databases (~340ms each under ASan), so the setup floor dominates. Bumping back when shadow-block construction actually produces equivocations worth running long for. Result: 100/100 pass, 10m31s wall time. No flakes — expected, since all 100 runs are functionally identical at the chain level until the shadow gap closes. The plumbing is exercised end to end. --- .../scenarios/test_equivocation.cpp | 41 +++++++++++++++++++ 1 file changed, 41 insertions(+) diff --git a/tests/consensus_sim/scenarios/test_equivocation.cpp b/tests/consensus_sim/scenarios/test_equivocation.cpp index 8da666ae3e..4c916e8139 100644 --- a/tests/consensus_sim/scenarios/test_equivocation.cpp +++ b/tests/consensus_sim/scenarios/test_equivocation.cpp @@ -42,4 +42,45 @@ BOOST_AUTO_TEST_CASE(seed_deadbeef_no_canonical_double_sign) { } } +BOOST_AUTO_TEST_CASE(seed_sweep_one_hundred) { + constexpr uint64_t kSweepCount = 100; + uint32_t failures = 0; + + // Each scenario spins up `num_witnesses` chainbase databases, each ~340ms to + // open under ASan. With 7 nodes × 100 seeds that floor alone is ~4 min, so + // the sweep keeps slot count low until shadow-block construction (Task 13 + // follow-up) actually produces equivocations worth running long for. + for (uint64_t seed = 0; seed < kSweepCount; ++seed) { + scenario_config cfg; + cfg.seed = seed; + cfg.num_witnesses = 7; + cfg.max_slots = 10; + + scenario_driver d(cfg); + fault_injector fi(d); + + d.add_invariant(no_double_signed_in_canonical); + + // Target genesis_witness_name to actually fire the equivocation override + // (per Milestone 2's single-witness genesis). The seed still varies the + // genesis_params.witness_keys list — unused by the slot producer today + // but kept here so the sweep takes the same shape as the eventual + // multi-witness target: `params.witness_keys[seed % 7].first`. + const auto& params = d.params(); + fi.instruct_equivocation(params.genesis_witness_name); + + auto result = d.run(); + if (result) { + ++failures; + write_failure_log("equivocation_seed_" + std::to_string(seed), d); + BOOST_TEST_MESSAGE("seed " << seed << " violation: " << result->detail); + } + } + + BOOST_CHECK_EQUAL(failures, 0u); + if (failures > 0) { + BOOST_TEST_MESSAGE("failure logs in tests/consensus_sim/failures/"); + } +} + BOOST_AUTO_TEST_SUITE_END() From 09ae266b409e83b4bffb0d63002e73bb61bda6c2 Mon Sep 17 00:00:00 2001 From: Vladimir Babin Date: Wed, 20 May 2026 20:19:20 +0800 Subject: [PATCH 16/17] test(consensus_sim): add WITH_COVERAGE plumbing and harness README Adds an opt-in -DWITH_COVERAGE flag that wires --coverage compile/link flags into graphene_protocol, graphene_chain, and the harness target, plus a consensus_sim_coverage make target that drives gcovr filtered to those three trees. gcovr is looked up at configure time; missing tool demotes to a configure-time warning, not an error. The README covers build/run, the ASan/UBSan workaround needed to get past pre-existing chain findings (evaluator_registry base-pointer delete, version/asset alignment), seed-driven determinism, failure-log layout, and the two M3 limitations still open: block_b production for real equivocation, and multi-witness key rotation. No source/runtime change; this is build-system and documentation only. --- libraries/chain/CMakeLists.txt | 5 ++ libraries/protocol/CMakeLists.txt | 5 ++ tests/consensus_sim/CMakeLists.txt | 22 +++++++++ tests/consensus_sim/README.md | 78 ++++++++++++++++++++++++++++++ 4 files changed, 110 insertions(+) create mode 100644 tests/consensus_sim/README.md diff --git a/libraries/chain/CMakeLists.txt b/libraries/chain/CMakeLists.txt index 46c541ea44..258865bc00 100644 --- a/libraries/chain/CMakeLists.txt +++ b/libraries/chain/CMakeLists.txt @@ -131,6 +131,11 @@ add_dependencies(graphene_chain graphene_protocol graphene_utilities build_hardf target_link_libraries(graphene_chain graphene_protocol graphene_utilities fc chainbase appbase ${PATCH_MERGE_LIB}) target_include_directories(graphene_chain PUBLIC "${CMAKE_CURRENT_SOURCE_DIR}/include" "${CMAKE_CURRENT_BINARY_DIR}/include" "${CMAKE_CURRENT_SOURCE_DIR}/../../") +if(WITH_COVERAGE) + target_compile_options(graphene_chain PRIVATE --coverage) + target_link_options(graphene_chain PUBLIC --coverage) +endif() + if(MSVC) set_source_files_properties(database.cpp PROPERTIES COMPILE_FLAGS "/bigobj") endif(MSVC) diff --git a/libraries/protocol/CMakeLists.txt b/libraries/protocol/CMakeLists.txt index 2e091a1187..3c4f58659d 100644 --- a/libraries/protocol/CMakeLists.txt +++ b/libraries/protocol/CMakeLists.txt @@ -58,6 +58,11 @@ target_link_libraries(graphene_${CURRENT_TARGET} fc) #graphene::version) target_include_directories(graphene_${CURRENT_TARGET} PUBLIC "${CMAKE_CURRENT_SOURCE_DIR}/include" "${CMAKE_CURRENT_SOURCE_DIR}/../../version/include" "${CMAKE_CURRENT_BINARY_DIR}/include" "${CMAKE_CURRENT_BINARY_DIR}/../../version/include") +if(WITH_COVERAGE) + target_compile_options(graphene_${CURRENT_TARGET} PRIVATE --coverage) + target_link_options(graphene_${CURRENT_TARGET} PUBLIC --coverage) +endif() + install(TARGETS graphene_${CURRENT_TARGET} diff --git a/tests/consensus_sim/CMakeLists.txt b/tests/consensus_sim/CMakeLists.txt index bf8c6bd104..371ae6e2ee 100644 --- a/tests/consensus_sim/CMakeLists.txt +++ b/tests/consensus_sim/CMakeLists.txt @@ -76,3 +76,25 @@ set_target_properties(consensus_sim_tests PROPERTIES add_test(NAME consensus_sim COMMAND consensus_sim_tests) file(MAKE_DIRECTORY ${CMAKE_CURRENT_SOURCE_DIR}/failures) + +if(WITH_COVERAGE) + find_program(GCOVR_PATH gcovr) + if(GCOVR_PATH) + add_custom_target(consensus_sim_coverage + COMMAND ${CMAKE_COMMAND} -E remove_directory ${CMAKE_BINARY_DIR}/coverage + COMMAND ${CMAKE_COMMAND} -E make_directory ${CMAKE_BINARY_DIR}/coverage + COMMAND ${GCOVR_PATH} + --root ${CMAKE_SOURCE_DIR} + --filter ${CMAKE_SOURCE_DIR}/libraries/chain + --filter ${CMAKE_SOURCE_DIR}/libraries/protocol + --filter ${CMAKE_SOURCE_DIR}/tests/consensus_sim + --html-details + --output ${CMAKE_BINARY_DIR}/coverage/index.html + ${CMAKE_BINARY_DIR} + DEPENDS consensus_sim_tests + COMMENT "Generating consensus_sim coverage report" + ) + else() + message(WARNING "gcovr not found; coverage report target unavailable") + endif() +endif() diff --git a/tests/consensus_sim/README.md b/tests/consensus_sim/README.md new file mode 100644 index 0000000000..4fd20c65f4 --- /dev/null +++ b/tests/consensus_sim/README.md @@ -0,0 +1,78 @@ +# consensus_sim + +Deterministic in-process multi-node consensus harness for VIZ. + +## Build + +``` +cmake -DBUILD_CONSENSUS_TESTS=ON .. +make consensus_sim_tests -j4 +``` + +The harness compiles at `-O1 -g -fsanitize=address,undefined`. Chain code +itself is unchanged. + +## Run + +``` +./tests/consensus_sim/consensus_sim_tests +``` + +Run a specific suite: + +``` +./tests/consensus_sim/consensus_sim_tests --run_test=equivocation_suite +./tests/consensus_sim/consensus_sim_tests --run_test=equivocation_suite/seed_sweep_one_hundred +``` + +The chain code has pre-existing ASan/UBSan findings (a base-pointer delete in +`evaluator_registry`, version/asset alignment) unrelated to the harness. Run +with `ASAN_OPTIONS=new_delete_type_mismatch=0:detect_leaks=0 +UBSAN_OPTIONS=halt_on_error=0:print_stacktrace=0` to get past them. + +## Coverage + +``` +cmake -DBUILD_CONSENSUS_TESTS=ON -DWITH_COVERAGE=ON .. +make consensus_sim_tests -j4 +./tests/consensus_sim/consensus_sim_tests +make consensus_sim_coverage +open build/coverage/index.html +``` + +Coverage instruments `graphene_chain`, `graphene_protocol`, and the harness +itself. The report filters to those three trees. + +## Reproducing failures + +When an invariant violates, the scenario writes +`tests/consensus_sim/failures/-.log` with the seed, scenario +name, config, full event log, final per-node state, and the triggering +report. Re-run the same seed by re-invoking the test — seeds are stored in +the scenario configs and deterministic. + +## Determinism + +Every scenario is seed-driven. Same seed -> byte-identical event log +(verified by `test_determinism_replay`). If a run flakes, that itself is a +bug — most likely a stray `now()` or a hash-randomized container in chain +code. + +## Current state + +- 17 test cases across 8 suites; all pass. +- Milestone 2: deterministic harness, single-witness genesis, 7-node smoke, + determinism replay, failure log. +- Milestone 3 (this commit set): `fault_injector` facade, single-seed + + 100-seed equivocation scenarios, coverage tooling. + +## Known limitations + +- `fault_injector::instruct_equivocation` ships block_a only; producing a + second validly-signed block for the same (witness, slot) requires either + extending `simulated_node` to return full `signed_block` bodies (to + catch up a shadow node) or constructing a no-op transaction inside + `fault_injector` and re-signing. Either path is a focused follow-up; the + invariant plumbing and sweep scaffolding are in place. +- Slot producer signs every block with the genesis witness; multi-witness + key rotation will land alongside the shadow-block work. From 0c3647f872f3f1cfc13ca50d4a732df9fc8e3dfc Mon Sep 17 00:00:00 2001 From: Vladimir Babin Date: Wed, 20 May 2026 21:05:48 +0800 Subject: [PATCH 17/17] test(consensus_sim): produce real equivocating block_b via shadow node instruct_equivocation now produces two distinct, validly-signed blocks for the same (witness, slot). A fresh shadow simulated_node is caught up to canonical state at height N-1 via prod->canonical_blocks_from(1) replay, a no-op account_metadata_operation tx (signed by the initiator key) is pushed into the shadow's pending pool to force a different transaction_merkle_root, and the shadow then produces block_b at the same when and witness as prod's block_a. The bus is partitioned {prod} vs {everyone else} with no heal, and block_a + block_b are routed asymmetrically so prod keeps block_a while side B accepts block_b. chains_consistent fires at the equivocation slot. Adds: - simulated_node::canonical_blocks_from(N) returning full signed_block bodies - simulated_node::push_pending_transaction / chain_id accessors - genesis_params::initiator_name + initiator_key (the only signable identity under CHAIN_NUM_INITIATORS=0 genesis) - tx_factory::make_noop_metadata_tx builder - test_equivocation rewritten to assert the violation rather than its absence; 100-seed sweep verifies the mechanism is robust across seeds - canonical_blocks_from + initiator key are covered by unit tests The equivocation defers to the first matching slot at height >= 2 because the shadow's no-op tx needs a non-default reference_block. --- tests/consensus_sim/CMakeLists.txt | 1 + tests/consensus_sim/README.md | 25 +++--- .../consensus_sim/harness/fault_injector.cpp | 87 ++++++++++++++----- .../consensus_sim/harness/fault_injector.hpp | 29 +++++-- .../consensus_sim/harness/genesis_factory.cpp | 19 +++- .../consensus_sim/harness/genesis_factory.hpp | 9 ++ .../consensus_sim/harness/simulated_node.cpp | 29 +++++++ .../consensus_sim/harness/simulated_node.hpp | 18 ++++ tests/consensus_sim/harness/tx_factory.cpp | 25 ++++++ tests/consensus_sim/harness/tx_factory.hpp | 39 +++++++++ .../scenarios/test_equivocation.cpp | 73 ++++++++-------- .../scenarios/test_genesis_factory.cpp | 15 ++++ .../scenarios/test_simulated_node_smoke.cpp | 37 ++++++++ 13 files changed, 328 insertions(+), 78 deletions(-) create mode 100644 tests/consensus_sim/harness/tx_factory.cpp create mode 100644 tests/consensus_sim/harness/tx_factory.hpp diff --git a/tests/consensus_sim/CMakeLists.txt b/tests/consensus_sim/CMakeLists.txt index 371ae6e2ee..cabc7e4a4e 100644 --- a/tests/consensus_sim/CMakeLists.txt +++ b/tests/consensus_sim/CMakeLists.txt @@ -9,6 +9,7 @@ set(HARNESS_SOURCES harness/scenario_driver.cpp harness/invariants.cpp harness/failure_log.cpp + harness/tx_factory.cpp ) add_library(consensus_sim_harness STATIC ${HARNESS_SOURCES}) diff --git a/tests/consensus_sim/README.md b/tests/consensus_sim/README.md index 4fd20c65f4..635b383736 100644 --- a/tests/consensus_sim/README.md +++ b/tests/consensus_sim/README.md @@ -60,19 +60,24 @@ code. ## Current state -- 17 test cases across 8 suites; all pass. - Milestone 2: deterministic harness, single-witness genesis, 7-node smoke, determinism replay, failure log. -- Milestone 3 (this commit set): `fault_injector` facade, single-seed + - 100-seed equivocation scenarios, coverage tooling. +- Milestone 3: `fault_injector` facade with real equivocation. A shadow + `simulated_node` is caught up to canonical state at height N-1, a no-op + `account_metadata_operation` tx is injected into the shadow's pool to + force a different transaction_merkle_root, and the shadow produces + block_b at the same `(witness, slot, when)` as prod's block_a. Bus is + partitioned {prod} vs {everyone else} for that slot, so block_a stays + with prod and block_b reaches the rest. `chains_consistent` fires at + the equivocation slot. ## Known limitations -- `fault_injector::instruct_equivocation` ships block_a only; producing a - second validly-signed block for the same (witness, slot) requires either - extending `simulated_node` to return full `signed_block` bodies (to - catch up a shadow node) or constructing a no-op transaction inside - `fault_injector` and re-signing. Either path is a focused follow-up; the - invariant plumbing and sweep scaffolding are in place. - Slot producer signs every block with the genesis witness; multi-witness - key rotation will land alongside the shadow-block work. + key rotation is a follow-up. With `CHAIN_NUM_INITIATORS=0` genesis, + `CHAIN_COMMITTEE_ACCOUNT` owns every slot, so the equivocation scenario + is unaffected — but heterogeneous-witness scenarios cannot be expressed + until rotation lands. +- No heal-and-reorg scenario yet: `instruct_equivocation` partitions the + bus and never heals, so the divergence is detected but not resolved by + the harness. Reorg behavior under heal is the next fault to script. diff --git a/tests/consensus_sim/harness/fault_injector.cpp b/tests/consensus_sim/harness/fault_injector.cpp index 70f2336922..f875da3df2 100644 --- a/tests/consensus_sim/harness/fault_injector.cpp +++ b/tests/consensus_sim/harness/fault_injector.cpp @@ -1,11 +1,17 @@ #include "fault_injector.hpp" +#include "tx_factory.hpp" + #include #include +#include +#include namespace consensus_sim { +fault_injector::~fault_injector() = default; + void fault_injector::partition(std::set a, std::set b) { driver_.bus().partition(std::move(a), std::move(b)); } @@ -35,12 +41,16 @@ void fault_injector::instruct_equivocation( uint32_t idx = slot % d.config().num_witnesses; auto* prod = d.nodes()[idx]; + // slot >= 2 guard: the shadow needs to set ref_block on its no-op + // tx to the canonical block at height N-1. At slot 1 there's no + // such block (head==0 / default block_id_type), so we defer the + // equivocation to the first matching slot at height >= 2. const bool fire = equivocate_witness_ && gw_name == *equivocate_witness_ && - !equivocation_consumed_; + !equivocation_consumed_ && slot >= 2; - // Honest path (also: every slot before/after the equivocation - // slot). Matches the default slot producer in scenario_driver. + // Honest path: prod produces and the bus delivers to all peers. + // Matches the default slot producer's behavior in scenario_driver. if (!fire) { auto block = prod->produce_block(gw_name, gw_key, when); auto ptr = std::make_shared(block); @@ -52,33 +62,64 @@ void fault_injector::instruct_equivocation( return; } - // Equivocation slot: produce two blocks for the same witness/slot. - // - // Approach: build a "shadow" simulated_node that catches up to - // prod's height via receive_block, then call produce_block on - // both prod and shadow at `when`. With identical genesis, identical - // witness/key, and the same head, the two blocks differ only in - // contents the shadow controls independently (transaction pool, - // future state). + // Equivocation slot. We produce two distinct, validly-signed blocks + // for the same (witness, slot) and arrange asymmetric delivery so + // the network splits into two disagreeing partitions. // - // GAP (filled by Task 14 once the concrete failure mode is known): - // catching the shadow up requires full signed_block bodies, but - // simulated_node::recent_blocks() returns only block metadata. - // Two viable extensions when needed: - // 1. Extend recent_blocks() (or add canonical_chain_blocks()) - // to return full signed_block objects. - // 2. Take the produced block_a, mutate transaction_merkle_root - // or include/exclude one synthetic tx, and re-sign with - // gw_key. - // - // Until then this path produces and broadcasts block_a only, - // matching the honest path, and marks the gap. + // (1) prod produces block_a (empty pool -> empty block at height N). auto block_a = prod->produce_block(gw_name, gw_key, when); + + // (2) Build a shadow node freshly and catch it up to canonical state + // at height N-1 (one block behind prod, which is now at N). + // The canonical chain at heights 1..N-1 is whatever prod just + // extended from — we pull it from prod and replay it into the + // shadow, dropping the final block (block_a itself). + shadow_ = std::make_unique("shadow", params, d.clock()); + auto history = prod->canonical_blocks_from(1); + if (!history.empty()) history.pop_back(); // drop block_a + for (auto& b : history) { + shadow_->receive_block(b); + } + + // (3) Force divergence: push a no-op tx into the shadow's pool. + // The shadow's next block_b will include this tx, giving it + // a different transaction_merkle_root from block_a (which was + // produced from an empty pool). Payload is keyed on slot so + // the same scenario seed produces the same tx id determ. + auto noop = make_noop_metadata_tx( + params, + shadow_->head_block_id(), + shadow_->head_block_time(), + shadow_->chain_id(), + std::string("{\"shadow\":") + std::to_string(slot) + "}"); + shadow_->push_pending_transaction(noop); + + // (4) Shadow produces block_b at the same `when` and witness. + auto block_b = shadow_->produce_block(gw_name, gw_key, when); + + // (5) Partition the network: {prod} vs {everyone else}, no heal. + // block_a is broadcast from prod -> cross-partition -> blocked. + // block_b is broadcast from a side-B carrier -> same-side -> + // delivered to all of side B. Result: prod stays at block_a, + // all other nodes accept block_b. chains_consistent fires. + std::set side_a{prod->label()}; + std::set side_b; + std::string carrier; + for (auto* n : d.nodes()) { + if (n == prod) continue; + side_b.insert(n->label()); + if (carrier.empty()) carrier = n->label(); + } + d.bus().partition(side_a, side_b); + auto ptr_a = std::make_shared(block_a); + auto ptr_b = std::make_shared(block_b); for (auto* peer : d.nodes()) { if (peer == prod) continue; d.bus().enqueue(prod->label(), peer->label(), std::static_pointer_cast(ptr_a), when); + d.bus().enqueue(carrier, peer->label(), + std::static_pointer_cast(ptr_b), when); } equivocation_consumed_ = true; diff --git a/tests/consensus_sim/harness/fault_injector.hpp b/tests/consensus_sim/harness/fault_injector.hpp index cfdf64e071..b97abfb15b 100644 --- a/tests/consensus_sim/harness/fault_injector.hpp +++ b/tests/consensus_sim/harness/fault_injector.hpp @@ -1,11 +1,13 @@ #pragma once #include "scenario_driver.hpp" +#include "simulated_node.hpp" #include #include +#include #include #include #include @@ -20,6 +22,7 @@ namespace consensus_sim { class fault_injector { public: explicit fault_injector(scenario_driver& driver) : driver_(driver) {} + ~fault_injector(); // --- Network faults (forwarded to the message bus) --- @@ -31,12 +34,25 @@ class fault_injector { // --- Producer-side faults --- - /// On the next slot whose canonical producer is `witness`, instead - /// of producing one block, produce TWO blocks A and B for the same - /// slot, both validly signed by `witness`, differing in their - /// transaction set (or, if the harness can't yet construct two - /// distinct valid blocks for the same slot, ship A only and record - /// the gap — Task 14 surfaces the concrete behavior). + /// On the next slot whose canonical producer is `witness`, produce + /// TWO blocks A and B for the same (witness, slot), both validly + /// signed by `witness` but differing in their transaction set. + /// + /// Implementation: a fresh shadow simulated_node is caught up to + /// the canonical chain at height N-1 (where N is the equivocation + /// slot's height), then a no-op transaction is pushed into the + /// shadow's pending pool to force its block_b's merkle root to + /// differ from prod's block_a. The shadow is then asked to produce + /// a block at the same `when` and witness. Both blocks are signed + /// by params.genesis_witness_key (the only on-chain signing key + /// with single-witness genesis). + /// + /// Delivery: the bus is partitioned into {prod} vs {everyone else} + /// for this slot. block_a is broadcast from prod's label (reaches + /// nobody — the partition leaves prod alone). block_b is broadcast + /// from the first other-side node's label (reaches all of side B). + /// The partition is NOT healed — chains_consistent will detect the + /// divergence at the next invariant check. /// /// Fires once and then reverts to honest production. void instruct_equivocation(const graphene::protocol::account_name_type& witness); @@ -45,6 +61,7 @@ class fault_injector { scenario_driver& driver_; std::optional equivocate_witness_; bool equivocation_consumed_ = false; + std::unique_ptr shadow_; }; } // namespace consensus_sim diff --git a/tests/consensus_sim/harness/genesis_factory.cpp b/tests/consensus_sim/harness/genesis_factory.cpp index be79992f2c..e4273da13f 100644 --- a/tests/consensus_sim/harness/genesis_factory.cpp +++ b/tests/consensus_sim/harness/genesis_factory.cpp @@ -18,6 +18,14 @@ namespace consensus_sim { static const char* kGenesisWitnessPrivateKeyWif = "5Hw9YPABaFxa2LooiANLrhUK5TPryy8f7v9Y1rk923PuYqbYdfC"; +// Matches CHAIN_INITIATOR_PUBLIC_KEY_STR. The "viz" account holds the entire +// initial supply at genesis and is the only account with a signable authority +// (committee/anonymous/invite are special; see database.cpp init_genesis path). +// The private key is documented as a comment in config.hpp:35 but is not +// exposed as a macro, so the harness has to hard-code the WIF here. +static const char* kInitiatorPrivateKeyWif = + "5JabcrvaLnBTCkCVFX5r4rmeGGfuJuVp4NAKRNLTey6pxhRQmf4"; + static fc::ecc::private_key derive_key(uint64_t seed, uint32_t idx) { // Deterministic key derivation: sha256(seed || idx) -> private key. char buf[16]; @@ -28,9 +36,10 @@ static fc::ecc::private_key derive_key(uint64_t seed, uint32_t idx) { return fc::ecc::private_key::regenerate(digest); } -static fc::ecc::private_key load_genesis_witness_key() { - auto k = graphene::utilities::wif_to_key(kGenesisWitnessPrivateKeyWif); - FC_ASSERT(k.valid(), "consensus_sim: failed to parse genesis witness WIF"); +static fc::ecc::private_key load_wif_(const char* wif, const char* role) { + auto k = graphene::utilities::wif_to_key(wif); + FC_ASSERT(k.valid(), "consensus_sim: failed to parse ${role} WIF", + ("role", std::string(role))); return *k; } @@ -47,7 +56,9 @@ genesis_params make_genesis_params(uint64_t seed, uint32_t num_witnesses) { derive_key(seed, i)); } p.genesis_witness_name = graphene::protocol::account_name_type(CHAIN_COMMITTEE_ACCOUNT); - p.genesis_witness_key = load_genesis_witness_key(); + p.genesis_witness_key = load_wif_(kGenesisWitnessPrivateKeyWif, "genesis witness"); + p.initiator_name = graphene::protocol::account_name_type(CHAIN_INITIATOR_NAME); + p.initiator_key = load_wif_(kInitiatorPrivateKeyWif, "initiator"); return p; } diff --git a/tests/consensus_sim/harness/genesis_factory.hpp b/tests/consensus_sim/harness/genesis_factory.hpp index 653f7624b2..f6a6e9bc95 100644 --- a/tests/consensus_sim/harness/genesis_factory.hpp +++ b/tests/consensus_sim/harness/genesis_factory.hpp @@ -27,6 +27,15 @@ struct genesis_params { graphene::protocol::account_name_type genesis_witness_name; /// Private key matching CHAIN_COMMITTEE_PUBLIC_KEY_STR. fc::ecc::private_key genesis_witness_key; + + /// The account VIZ's init_genesis funds with the entire initial supply + /// (CHAIN_INITIATOR_NAME = "viz"). Unlike the committee account, "viz" has + /// the initiator public key wired into all three authorities, so it can + /// sign transactions. The harness uses this account to craft no-op + /// transactions that force shadow/canonical divergence at equivocation + /// scenarios — see harness/tx_factory.hpp. + graphene::protocol::account_name_type initiator_name; + fc::ecc::private_key initiator_key; }; /// Deterministically build genesis parameters from a seed. diff --git a/tests/consensus_sim/harness/simulated_node.cpp b/tests/consensus_sim/harness/simulated_node.cpp index 92c08512de..81bd50ba37 100644 --- a/tests/consensus_sim/harness/simulated_node.cpp +++ b/tests/consensus_sim/harness/simulated_node.cpp @@ -114,4 +114,33 @@ std::vector simulated_node::recent_blocks(uint32_t count) cons return out; } +std::vector simulated_node::canonical_blocks_from( + uint32_t from_height) const { + std::vector out; + const uint32_t head = db_->head_block_num(); + if (from_height == 0 || from_height > head) return out; + out.reserve(head - from_height + 1); + for (uint32_t n = from_height; n <= head; ++n) { + auto b = db_->fetch_block_by_number(n); + if (!b) break; + out.push_back(std::move(*b)); + } + return out; +} + +void simulated_node::push_pending_transaction( + const graphene::protocol::signed_transaction& tx) { + try { + db_->push_transaction(tx, graphene::chain::database::skip_nothing); + } catch (const fc::exception& e) { + throw std::runtime_error( + "simulated_node::push_pending_transaction failed: " + + e.to_detail_string()); + } +} + +graphene::protocol::chain_id_type simulated_node::chain_id() const { + return db_->get_chain_id(); +} + } // namespace consensus_sim diff --git a/tests/consensus_sim/harness/simulated_node.hpp b/tests/consensus_sim/harness/simulated_node.hpp index 115ca89813..fe39402d52 100644 --- a/tests/consensus_sim/harness/simulated_node.hpp +++ b/tests/consensus_sim/harness/simulated_node.hpp @@ -5,6 +5,8 @@ #include #include +#include +#include #include @@ -63,6 +65,22 @@ class simulated_node { /// invariants can detect repeated (witness, slot) pairs. std::vector recent_blocks(uint32_t count) const; + /// Returns full signed_block bodies on the canonical chain at heights + /// [from_height, head_block_num()] in ascending order. Empty if + /// from_height > head. Used by the equivocation fault to catch a + /// shadow node up to canonical state before forking it. + std::vector canonical_blocks_from( + uint32_t from_height) const; + + /// Push a transaction into the pending pool. Wraps database::push_transaction + /// and maps fc::exception into std::runtime_error so the caller doesn't + /// have to know about the chain's exception hierarchy. Used by the fault + /// injector to force shadow/canonical divergence before producing + /// equivocating blocks. + void push_pending_transaction(const graphene::protocol::signed_transaction& tx); + + graphene::protocol::chain_id_type chain_id() const; + const std::string& label() const noexcept { return label_; } private: diff --git a/tests/consensus_sim/harness/tx_factory.cpp b/tests/consensus_sim/harness/tx_factory.cpp new file mode 100644 index 0000000000..763f586c43 --- /dev/null +++ b/tests/consensus_sim/harness/tx_factory.cpp @@ -0,0 +1,25 @@ +#include "tx_factory.hpp" + +#include + +namespace consensus_sim { + +graphene::protocol::signed_transaction make_noop_metadata_tx( + const genesis_params& params, + const graphene::protocol::block_id_type& reference_block, + fc::time_point_sec reference_time, + const graphene::protocol::chain_id_type& chain_id, + const std::string& payload) { + graphene::protocol::account_metadata_operation op; + op.account = params.initiator_name; + op.json_metadata = payload; + + graphene::protocol::signed_transaction tx; + tx.set_reference_block(reference_block); + tx.set_expiration(reference_time + fc::seconds(60)); + tx.operations.emplace_back(op); + tx.sign(params.initiator_key, chain_id); + return tx; +} + +} // namespace consensus_sim diff --git a/tests/consensus_sim/harness/tx_factory.hpp b/tests/consensus_sim/harness/tx_factory.hpp new file mode 100644 index 0000000000..72d566ded5 --- /dev/null +++ b/tests/consensus_sim/harness/tx_factory.hpp @@ -0,0 +1,39 @@ +#pragma once + +#include "genesis_factory.hpp" + +#include +#include +#include + +#include + +#include + +namespace consensus_sim { + +/// Builds a tiny, valid, no-effect signed_transaction usable to force divergence +/// between two simulated_nodes producing for the same (witness, slot). +/// +/// Op: account_metadata_operation { account = params.initiator_name, +/// json_metadata = payload }. +/// The op requires only the initiator's regular authority. The initiator +/// account ("viz") is created at genesis with CHAIN_INITIATOR_PUBLIC_KEY in +/// master/active/regular, so a signature with params.initiator_key satisfies it. +/// +/// ref_block_num / ref_block_prefix are bound to `reference_block`, the +/// expiration is set 60s after `reference_time` (CHAIN_MAX_TIME_UNTIL_EXPIRATION +/// is 1 hour, so 60s is well inside the window). +/// +/// Same (params, reference_block, reference_time, payload) -> byte-identical +/// transaction id, by construction. The harness uses distinct payloads to +/// guarantee block_a's pool (empty) and block_b's pool (this tx) produce +/// different transaction_merkle_root values. +graphene::protocol::signed_transaction make_noop_metadata_tx( + const genesis_params& params, + const graphene::protocol::block_id_type& reference_block, + fc::time_point_sec reference_time, + const graphene::protocol::chain_id_type& chain_id, + const std::string& payload); + +} // namespace consensus_sim diff --git a/tests/consensus_sim/scenarios/test_equivocation.cpp b/tests/consensus_sim/scenarios/test_equivocation.cpp index 4c916e8139..9dae0cc15a 100644 --- a/tests/consensus_sim/scenarios/test_equivocation.cpp +++ b/tests/consensus_sim/scenarios/test_equivocation.cpp @@ -9,7 +9,14 @@ using namespace consensus_sim; BOOST_AUTO_TEST_SUITE(equivocation_suite) -BOOST_AUTO_TEST_CASE(seed_deadbeef_no_canonical_double_sign) { +// fault_injector::instruct_equivocation produces two distinct, validly-signed +// blocks for the same (witness, slot) and partitions the network so each side +// adopts a different block. chains_consistent therefore fires at the +// equivocation slot, surfacing the divergence. The tests below assert the +// expected violation rather than its absence — silent passing here means the +// shadow-node + tx-injection mechanism stopped producing real equivocation. + +BOOST_AUTO_TEST_CASE(seed_deadbeef_fires_chains_consistent) { scenario_config cfg; cfg.seed = 0xDEADBEEF; cfg.num_witnesses = 7; @@ -23,62 +30,58 @@ BOOST_AUTO_TEST_CASE(seed_deadbeef_no_canonical_double_sign) { lib_monotone_checker lib_check; d.add_invariant([&](const auto& ns) { return lib_check(ns); }); - // Milestone 2 wires every slot to the genesis witness (CHAIN_COMMITTEE_ACCOUNT, - // the only on-chain witness when CHAIN_NUM_INITIATORS=0). Equivocation must - // therefore target that witness to fire; the plan's `params.witness_keys[3]` - // path will reactivate once multi-witness key rotation lands in a follow-up. - // - // The instruct_equivocation body currently ships block_a only and flags the - // shadow-chain reconstruction gap, so this test is expected to pass trivially - // until that gap is closed (sibling-state or direct-mutation second-block - // construction). - const auto& params = d.params(); - fi.instruct_equivocation(params.genesis_witness_name); + // Single-witness genesis (CHAIN_NUM_INITIATORS=0): every slot is signed by + // CHAIN_COMMITTEE_ACCOUNT. Equivocation fires on the first matching slot + // at height >= 2 (slot 1 has no canonical block to reference for the + // shadow's no-op tx). + fi.instruct_equivocation(d.params().genesis_witness_name); auto result = d.run(); - if (result) { - write_failure_log("equivocation_seed_deadbeef", d); - BOOST_FAIL("invariant violated: " + result->invariant_name + " - " + result->detail); + if (!result) { + write_failure_log("equivocation_seed_deadbeef_no_violation", d); + BOOST_FAIL("expected chains_consistent violation, run completed clean"); } + BOOST_CHECK_EQUAL(result->invariant_name, "chains_consistent"); + // The split happens at the equivocation slot (block height 2) and is + // detected at the same slot's invariant check. + BOOST_CHECK_EQUAL(result->block_num, 2u); } -BOOST_AUTO_TEST_CASE(seed_sweep_one_hundred) { +BOOST_AUTO_TEST_CASE(seed_sweep_one_hundred_all_fire) { constexpr uint64_t kSweepCount = 100; - uint32_t failures = 0; + uint32_t missed = 0; - // Each scenario spins up `num_witnesses` chainbase databases, each ~340ms to - // open under ASan. With 7 nodes × 100 seeds that floor alone is ~4 min, so - // the sweep keeps slot count low until shadow-block construction (Task 13 - // follow-up) actually produces equivocations worth running long for. + // Every seed should produce the same equivocation outcome: chains_consistent + // fires at slot 2. The sweep proves the mechanism is robust across seeds, + // and that the violation is reproducible per-seed. for (uint64_t seed = 0; seed < kSweepCount; ++seed) { scenario_config cfg; cfg.seed = seed; cfg.num_witnesses = 7; - cfg.max_slots = 10; + cfg.max_slots = 5; // Equivocation fires at slot 2; 5 is ample. scenario_driver d(cfg); fault_injector fi(d); - d.add_invariant(no_double_signed_in_canonical); + d.add_invariant(chains_consistent); - // Target genesis_witness_name to actually fire the equivocation override - // (per Milestone 2's single-witness genesis). The seed still varies the - // genesis_params.witness_keys list — unused by the slot producer today - // but kept here so the sweep takes the same shape as the eventual - // multi-witness target: `params.witness_keys[seed % 7].first`. - const auto& params = d.params(); - fi.instruct_equivocation(params.genesis_witness_name); + fi.instruct_equivocation(d.params().genesis_witness_name); auto result = d.run(); - if (result) { - ++failures; + if (!result || result->invariant_name != "chains_consistent") { + ++missed; write_failure_log("equivocation_seed_" + std::to_string(seed), d); - BOOST_TEST_MESSAGE("seed " << seed << " violation: " << result->detail); + if (result) { + BOOST_TEST_MESSAGE("seed " << seed << " unexpected violation: " + << result->invariant_name); + } else { + BOOST_TEST_MESSAGE("seed " << seed << " no violation (expected one)"); + } } } - BOOST_CHECK_EQUAL(failures, 0u); - if (failures > 0) { + BOOST_CHECK_EQUAL(missed, 0u); + if (missed > 0) { BOOST_TEST_MESSAGE("failure logs in tests/consensus_sim/failures/"); } } diff --git a/tests/consensus_sim/scenarios/test_genesis_factory.cpp b/tests/consensus_sim/scenarios/test_genesis_factory.cpp index 914f7c927c..e66dc188b8 100644 --- a/tests/consensus_sim/scenarios/test_genesis_factory.cpp +++ b/tests/consensus_sim/scenarios/test_genesis_factory.cpp @@ -1,6 +1,8 @@ #include #include "genesis_factory.hpp" +#include + #include #include @@ -32,4 +34,17 @@ BOOST_AUTO_TEST_CASE(witness_names_are_distinct) { BOOST_CHECK_EQUAL(names.size(), 7u); } +BOOST_AUTO_TEST_CASE(initiator_key_matches_genesis_constant) { + // The initiator account is funded with the full initial supply at genesis + // and is the only signable account on a CHAIN_NUM_INITIATORS=0 chain. + // Anyone touching make_genesis_params should know that changing the WIF + // here invalidates the tx_factory's signing assumption. + auto p = make_genesis_params(0, 1); + BOOST_CHECK_EQUAL(std::string(p.initiator_name), + std::string(CHAIN_INITIATOR_NAME)); + graphene::protocol::public_key_type derived(p.initiator_key.get_public_key()); + BOOST_CHECK_EQUAL(static_cast(derived), + std::string(CHAIN_INITIATOR_PUBLIC_KEY_STR)); +} + BOOST_AUTO_TEST_SUITE_END() diff --git a/tests/consensus_sim/scenarios/test_simulated_node_smoke.cpp b/tests/consensus_sim/scenarios/test_simulated_node_smoke.cpp index b3b7dbac8a..5aa3c7ee10 100644 --- a/tests/consensus_sim/scenarios/test_simulated_node_smoke.cpp +++ b/tests/consensus_sim/scenarios/test_simulated_node_smoke.cpp @@ -46,4 +46,41 @@ BOOST_AUTO_TEST_CASE(single_node_produces_100_blocks) { BOOST_CHECK_EQUAL(node.head_block_num(), 100u); } +// canonical_blocks_from exposes full signed_block bodies so the equivocation +// fault injector can catch a shadow node up to canonical state and then fork it. +// Behavior contract: returns blocks at heights [from_height, head] in ascending +// order; returns empty when from_height > head or == 0; preserves block ids +// byte-for-byte so the shadow's receive_block accepts them as canonical. +BOOST_AUTO_TEST_CASE(canonical_blocks_from_returns_full_bodies) { + auto params = make_genesis_params(0xC011, 1); + auto t0 = fc::time_point_sec::from_iso_string("2026-01-01T00:00:00"); + virtual_clock clk(t0); + + simulated_node node("node-0", params, clk); + + std::vector produced; + fc::time_point_sec t = t0; + for (int i = 0; i < 5; ++i) { + t += CHAIN_BLOCK_INTERVAL; + clk.advance_to(t); + produced.push_back( + node.produce_block(params.genesis_witness_name, + params.genesis_witness_key, t)); + } + + auto all = node.canonical_blocks_from(1); + BOOST_REQUIRE_EQUAL(all.size(), produced.size()); + for (size_t i = 0; i < produced.size(); ++i) { + BOOST_CHECK(all[i].id() == produced[i].id()); + } + + auto tail = node.canonical_blocks_from(3); + BOOST_REQUIRE_EQUAL(tail.size(), 3u); + BOOST_CHECK(tail.front().id() == produced[2].id()); + BOOST_CHECK(tail.back().id() == produced.back().id()); + + BOOST_CHECK(node.canonical_blocks_from(0).empty()); + BOOST_CHECK(node.canonical_blocks_from(6).empty()); +} + BOOST_AUTO_TEST_SUITE_END()