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/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/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/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 +``` 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/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()` diff --git a/tests/consensus_sim/CMakeLists.txt b/tests/consensus_sim/CMakeLists.txt new file mode 100644 index 0000000000..cabc7e4a4e --- /dev/null +++ b/tests/consensus_sim/CMakeLists.txt @@ -0,0 +1,101 @@ +# 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 + harness/tx_factory.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 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 + -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 +) + +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. + +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..635b383736 --- /dev/null +++ b/tests/consensus_sim/README.md @@ -0,0 +1,83 @@ +# 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 + +- Milestone 2: deterministic harness, single-witness genesis, 7-node smoke, + determinism replay, failure log. +- 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 + +- Slot producer signs every block with the genesis witness; multi-witness + 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/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..75d4e79b80 --- /dev/null +++ b/tests/consensus_sim/harness/failure_log.cpp @@ -0,0 +1,49 @@ +#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/harness/fault_injector.cpp b/tests/consensus_sim/harness/fault_injector.cpp new file mode 100644 index 0000000000..f875da3df2 --- /dev/null +++ b/tests/consensus_sim/harness/fault_injector.cpp @@ -0,0 +1,129 @@ +#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)); +} + +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]; + + // 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_ && slot >= 2; + + // 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); + 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. We produce two distinct, validly-signed blocks + // for the same (witness, slot) and arrange asymmetric delivery so + // the network splits into two disagreeing partitions. + // + // (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; + }); +} + +} // 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..b97abfb15b --- /dev/null +++ b/tests/consensus_sim/harness/fault_injector.hpp @@ -0,0 +1,67 @@ +#pragma once + +#include "scenario_driver.hpp" +#include "simulated_node.hpp" + +#include + +#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) {} + ~fault_injector(); + + // --- 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`, 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); + +private: + 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 new file mode 100644 index 0000000000..e4273da13f --- /dev/null +++ b/tests/consensus_sim/harness/genesis_factory.cpp @@ -0,0 +1,65 @@ +#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"; + +// 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]; + 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); +} + +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; +} + +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)); + } + p.genesis_witness_name = graphene::protocol::account_name_type(CHAIN_COMMITTEE_ACCOUNT); + 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; +} + +} // 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..f6a6e9bc95 --- /dev/null +++ b/tests/consensus_sim/harness/genesis_factory.hpp @@ -0,0 +1,45 @@ +#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. + /// 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; + + /// 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. +/// 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/harness/invariants.cpp b/tests/consensus_sim/harness/invariants.cpp new file mode 100644 index 0000000000..31711384fe --- /dev/null +++ b/tests/consensus_sim/harness/invariants.cpp @@ -0,0 +1,82 @@ +#include "invariants.hpp" + +#include + +#include +#include + +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) { + 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; +} + +} // 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 diff --git a/tests/consensus_sim/harness/message_bus.cpp b/tests/consensus_sim/harness/message_bus.cpp new file mode 100644 index 0000000000..46fdf92911 --- /dev/null +++ b/tests/consensus_sim/harness/message_bus.cpp @@ -0,0 +1,70 @@ +#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/harness/scenario_driver.cpp b/tests/consensus_sim/harness/scenario_driver.cpp new file mode 100644 index 0000000000..ae26f18bb7 --- /dev/null +++ b/tests/consensus_sim/harness/scenario_driver.cpp @@ -0,0 +1,108 @@ +#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) { + // 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* producer = node_ptrs_[idx]; + + 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) + + " 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 diff --git a/tests/consensus_sim/harness/simulated_node.cpp b/tests/consensus_sim/harness/simulated_node.cpp new file mode 100644 index 0000000000..81bd50ba37 --- /dev/null +++ b/tests/consensus_sim/harness/simulated_node.cpp @@ -0,0 +1,146 @@ +#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(); +} + +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; +} + +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 new file mode 100644 index 0000000000..fe39402d52 --- /dev/null +++ b/tests/consensus_sim/harness/simulated_node.hpp @@ -0,0 +1,96 @@ +#pragma once + +#include "genesis_factory.hpp" +#include "virtual_clock.hpp" + +#include +#include +#include +#include + +#include + +#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, + 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; + + /// 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; + + /// 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: + 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/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/harness/virtual_clock.cpp b/tests/consensus_sim/harness/virtual_clock.cpp new file mode 100644 index 0000000000..599248d5be --- /dev/null +++ b/tests/consensus_sim/harness/virtual_clock.cpp @@ -0,0 +1,16 @@ +#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_determinism_replay.cpp b/tests/consensus_sim/scenarios/test_determinism_replay.cpp new file mode 100644 index 0000000000..d88cc2a2dd --- /dev/null +++ b/tests/consensus_sim/scenarios/test_determinism_replay.cpp @@ -0,0 +1,38 @@ +#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() diff --git a/tests/consensus_sim/scenarios/test_equivocation.cpp b/tests/consensus_sim/scenarios/test_equivocation.cpp new file mode 100644 index 0000000000..9dae0cc15a --- /dev/null +++ b/tests/consensus_sim/scenarios/test_equivocation.cpp @@ -0,0 +1,89 @@ +#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) + +// 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; + 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); }); + + // 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_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_all_fire) { + constexpr uint64_t kSweepCount = 100; + uint32_t missed = 0; + + // 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 = 5; // Equivocation fires at slot 2; 5 is ample. + + scenario_driver d(cfg); + fault_injector fi(d); + + d.add_invariant(chains_consistent); + + fi.instruct_equivocation(d.params().genesis_witness_name); + + auto result = d.run(); + if (!result || result->invariant_name != "chains_consistent") { + ++missed; + write_failure_log("equivocation_seed_" + std::to_string(seed), d); + 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(missed, 0u); + if (missed > 0) { + BOOST_TEST_MESSAGE("failure logs in tests/consensus_sim/failures/"); + } +} + +BOOST_AUTO_TEST_SUITE_END() 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..e66dc188b8 --- /dev/null +++ b/tests/consensus_sim/scenarios/test_genesis_factory.cpp @@ -0,0 +1,50 @@ +#include +#include "genesis_factory.hpp" + +#include + +#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_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_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..b5c837a409 --- /dev/null +++ b/tests/consensus_sim/scenarios/test_message_bus.cpp @@ -0,0 +1,74 @@ +#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() 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..5aa3c7ee10 --- /dev/null +++ b/tests/consensus_sim/scenarios/test_simulated_node_smoke.cpp @@ -0,0 +1,86 @@ +#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); +} + +// 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() 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..d39d4fe0b9 --- /dev/null +++ b/tests/consensus_sim/scenarios/test_smoke_no_faults.cpp @@ -0,0 +1,39 @@ +#include +#include "scenario_driver.hpp" +#include "failure_log.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) { + write_failure_log("smoke_no_faults", d); + 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() 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..e932dc8b2c --- /dev/null +++ b/tests/consensus_sim/scenarios/test_virtual_clock.cpp @@ -0,0 +1,41 @@ +#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()