diff --git a/CMakeLists.txt b/CMakeLists.txt index 3e25f54..70a48a9 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -7,6 +7,8 @@ include(ExternalProject) option(PROTOCOL_STACK_ENABLE_SANITIZERS "Build protocol-stack verification targets with ASan and UBSan" OFF) +option(PROTOCOL_STACK_ENABLE_FUZZING + "Build Clang libFuzzer targets" OFF) find_package(Python3 3.11 REQUIRED COMPONENTS Interpreter) find_program(PROTOCOL_STACK_MAKE_EXECUTABLE make REQUIRED) @@ -27,6 +29,14 @@ if(PROTOCOL_STACK_ENABLE_SANITIZERS) set(PROTOCOL_STACK_SANITIZER_FLAGS "-fsanitize=address,undefined;-fno-omit-frame-pointer") endif() +if(PROTOCOL_STACK_ENABLE_FUZZING) + if(NOT CMAKE_CXX_COMPILER_ID STREQUAL "Clang") + message(FATAL_ERROR "Fuzzing requires Clang") + endif() + if(NOT PROTOCOL_STACK_ENABLE_SANITIZERS) + message(FATAL_ERROR "Fuzzing requires sanitizer instrumentation") + endif() +endif() file(MAKE_DIRECTORY "${PROTOCOL_STACK_SODIUM_PREFIX}/include") @@ -74,13 +84,114 @@ add_executable( ledger_transition_vectors tools/ledger-vectors/verify.cpp ) +set( + PROTOCOL_STACK_KERNEL_SOURCES + src/v1/address.cpp + src/v1/admission.cpp + src/v1/commitments.cpp + src/v1/crypto.cpp + src/v1/execution.cpp + src/v1/genesis.cpp + src/v1/ledger.cpp +) +add_library(protocol_kernel STATIC ${PROTOCOL_STACK_KERNEL_SOURCES}) +target_include_directories( + protocol_kernel + PUBLIC + "${PROJECT_SOURCE_DIR}/include" +) +add_executable( + kernel_primitive_tests + tests/kernel/primitives_test.cpp +) +target_link_libraries(kernel_primitive_tests PRIVATE protocol_kernel) +add_executable( + kernel_admission_tests + tests/kernel/admission_test.cpp +) +target_link_libraries(kernel_admission_tests PRIVATE protocol_kernel) +add_executable( + kernel_execution_tests + tests/kernel/execution_test.cpp +) +target_link_libraries(kernel_execution_tests PRIVATE protocol_kernel) +add_executable( + kernel_genesis_tests + tests/kernel/genesis_test.cpp +) +target_link_libraries(kernel_genesis_tests PRIVATE protocol_kernel) +add_executable( + kernel_commitment_tests + tests/kernel/commitments_test.cpp +) +target_link_libraries(kernel_commitment_tests PRIVATE protocol_kernel) +add_executable( + kernel_block_tests + tests/kernel/block_test.cpp +) +target_link_libraries(kernel_block_tests PRIVATE protocol_kernel) +add_executable( + kernel_property_tests + tests/kernel/property_test.cpp +) +target_link_libraries(kernel_property_tests PRIVATE protocol_kernel) +add_executable( + kernel_differential_runner + tests/differential/kernel_runner.cpp +) +target_link_libraries(kernel_differential_runner PRIVATE protocol_kernel) -foreach( - protocol_stack_target - IN ITEMS - protocol_primitive_vectors - ledger_transition_vectors +set( + PROTOCOL_STACK_TARGETS + protocol_primitive_vectors + ledger_transition_vectors + protocol_kernel + kernel_primitive_tests + kernel_admission_tests + kernel_execution_tests + kernel_genesis_tests + kernel_commitment_tests + kernel_block_tests + kernel_property_tests + kernel_differential_runner ) +if(PROTOCOL_STACK_ENABLE_FUZZING) + add_library( + protocol_kernel_fuzz + STATIC + ${PROTOCOL_STACK_KERNEL_SOURCES} + ) + target_include_directories( + protocol_kernel_fuzz + PUBLIC + "${PROJECT_SOURCE_DIR}/include" + ) + add_executable( + kernel_admission_fuzz + tests/fuzz/admission_fuzz.cpp + ) + target_link_libraries(kernel_admission_fuzz PRIVATE protocol_kernel_fuzz) + add_executable( + kernel_address_fuzz + tests/fuzz/address_fuzz.cpp + ) + target_link_libraries(kernel_address_fuzz PRIVATE protocol_kernel_fuzz) + add_executable( + kernel_genesis_fuzz + tests/fuzz/genesis_fuzz.cpp + ) + target_link_libraries(kernel_genesis_fuzz PRIVATE protocol_kernel_fuzz) + list( + APPEND + PROTOCOL_STACK_TARGETS + protocol_kernel_fuzz + kernel_admission_fuzz + kernel_address_fuzz + kernel_genesis_fuzz + ) +endif() + +foreach(protocol_stack_target IN LISTS PROTOCOL_STACK_TARGETS) target_compile_features(${protocol_stack_target} PRIVATE cxx_std_20) target_compile_definitions( ${protocol_stack_target} @@ -103,6 +214,35 @@ foreach( ) target_link_libraries(${protocol_stack_target} PRIVATE protocol_stack_sodium) endforeach() +if(PROTOCOL_STACK_ENABLE_FUZZING) + foreach( + protocol_stack_fuzz_target + IN ITEMS + protocol_kernel_fuzz + kernel_admission_fuzz + kernel_address_fuzz + kernel_genesis_fuzz + ) + target_compile_options( + ${protocol_stack_fuzz_target} + PRIVATE + -fsanitize=fuzzer-no-link + ) + endforeach() + foreach( + protocol_stack_fuzz_executable + IN ITEMS + kernel_admission_fuzz + kernel_address_fuzz + kernel_genesis_fuzz + ) + target_link_options( + ${protocol_stack_fuzz_executable} + PRIVATE + -fsanitize=fuzzer + ) + endforeach() +endif() add_test( NAME protocol-primitives-cpp @@ -130,6 +270,57 @@ add_test( "${PROJECT_SOURCE_DIR}/tools/ledger-vectors/verify.py" "${PROJECT_SOURCE_DIR}/test-vectors/ledger-transition-v1.txt" ) +add_test( + NAME kernel-primitives + COMMAND + kernel_primitive_tests + "${PROJECT_SOURCE_DIR}/test-vectors/protocol-primitives-v1.txt" +) +add_test( + NAME kernel-admission + COMMAND + kernel_admission_tests + "${PROJECT_SOURCE_DIR}/test-vectors/ledger-transition-v1.txt" +) +add_test( + NAME kernel-execution + COMMAND + kernel_execution_tests + "${PROJECT_SOURCE_DIR}/test-vectors/ledger-transition-v1.txt" +) +add_test( + NAME kernel-genesis + COMMAND + kernel_genesis_tests + "${PROJECT_SOURCE_DIR}/test-vectors/ledger-transition-v1.txt" +) +add_test( + NAME kernel-commitments + COMMAND + kernel_commitment_tests + "${PROJECT_SOURCE_DIR}/test-vectors/ledger-transition-v1.txt" +) +add_test( + NAME kernel-block + COMMAND + kernel_block_tests + "${PROJECT_SOURCE_DIR}/test-vectors/ledger-transition-v1.txt" +) +add_test( + NAME kernel-properties + COMMAND kernel_property_tests +) +add_test( + NAME kernel-differential + COMMAND + "${Python3_EXECUTABLE}" + "${PROJECT_SOURCE_DIR}/tests/differential/run.py" + "$" + --count + 10000 + --libsodium + "${PROTOCOL_STACK_SODIUM_SHARED_LIBRARY}" +) set_tests_properties( protocol-primitives-python ledger-transition-python @@ -137,3 +328,45 @@ set_tests_properties( ENVIRONMENT "PROTOCOL_STACK_LIBSODIUM=${PROTOCOL_STACK_SODIUM_SHARED_LIBRARY}" ) +set_tests_properties( + kernel-differential + PROPERTIES + TIMEOUT 300 +) +if(PROTOCOL_STACK_ENABLE_FUZZING) + add_test( + NAME kernel-admission-fuzz-smoke + COMMAND + kernel_admission_fuzz + -seed=824311 + -runs=512 + -max_len=256 + -len_control=0 + ) + add_test( + NAME kernel-genesis-fuzz-smoke + COMMAND + kernel_genesis_fuzz + -seed=824311 + -runs=512 + -max_len=4096 + -len_control=0 + ) + add_test( + NAME kernel-address-fuzz-smoke + COMMAND + kernel_address_fuzz + -seed=824311 + -runs=512 + -max_len=256 + -len_control=0 + ) + set_tests_properties( + kernel-admission-fuzz-smoke + kernel-address-fuzz-smoke + kernel-genesis-fuzz-smoke + PROPERTIES + LABELS fuzz + TIMEOUT 60 + ) +endif() diff --git a/CMakePresets.json b/CMakePresets.json index ff0f999..ddaccb7 100644 --- a/CMakePresets.json +++ b/CMakePresets.json @@ -47,6 +47,7 @@ "inherits": "clang-debug", "displayName": "Clang ASan and UBSan", "cacheVariables": { + "PROTOCOL_STACK_ENABLE_FUZZING": "ON", "PROTOCOL_STACK_ENABLE_SANITIZERS": "ON" } } diff --git a/docs/README.md b/docs/README.md index de12d20..c6f83bc 100644 --- a/docs/README.md +++ b/docs/README.md @@ -11,6 +11,8 @@ ## Architecture - `architecture/sovereign-core.md`: system layers and replaceable boundaries. +- `architecture/ledger-kernel.md`: ledger ownership, atomic block application, + canonical outputs, and failure boundaries. - `architecture/local-ai-authority.md`: future self-hosted AI control plane. ## Decisions diff --git a/docs/architecture/ledger-kernel.md b/docs/architecture/ledger-kernel.md new file mode 100644 index 0000000..49fc25f --- /dev/null +++ b/docs/architecture/ledger-kernel.md @@ -0,0 +1,148 @@ +# Ledger kernel boundary + +Status: M1 implementation architecture + +The version-one ledger kernel is the application-state authority described by +`sovereign-core.md`. Its consensus-visible behavior is defined by +`../specifications/protocol-primitives-v1.md` and +`../specifications/ledger-transition-v1.md`; this document defines the C++ +ownership, concurrency, and failure boundary around that behavior. + +## Public ownership model + +`load_genesis` validates canonical genesis bytes and, on success, returns a +`Ledger` that owns the resulting state. The input byte span is borrowed only +for the duration of the call. The ledger does not retain a pointer into caller +storage. + +A `Ledger` exclusively owns its mutable `State`. Its `state()` accessor returns +a read-only borrowed view whose lifetime cannot exceed the ledger's lifetime. +The caller must not retain that view across a mutation such as +`apply_block`. `current_state_root()` computes a typed commitment to the +ledger's current state. + +`apply_block` borrows each raw input span only while the call is active. A +successful call returns a `BlockCommit` that owns its transaction identifiers, +typed receipts, canonical encoded receipts, roots, application header bytes, +and block identifier. None of those outputs refer to the submitted input +buffers or to mutable ledger storage. + +Copy construction creates an independent deterministic ledger fork. Move +construction transfers ownership. Assignment is intentionally unavailable so +an already-published ledger instance cannot silently change identity or +invalidate references through assignment. + +## Caller synchronization + +The kernel contains no locks and does not schedule work. A caller must serialize +all operations that can mutate one `Ledger` and must prevent a mutation from +overlapping any read of that ledger or of a view borrowed from it. Independent +ledger instances, including copies used for proposal evaluation, may be +processed independently. + +This keeps thread scheduling outside consensus meaning. A future node adapter +may use a mutex, a single-owner event loop, or another synchronization +mechanism, but it cannot change the order supplied to `apply_block`. + +## Atomic block application + +`apply_block` first validates the raw-input bound, the current state, and the +exact next height. It then copies the state and performs admission and +execution in input order against that tentative copy. + +Ordinary admission failures and transfer-result failures are deterministic +per-input outcomes. An internal invariant failure, invalid height, +height exhaustion, invalid current state, or input-bound violation rejects the +whole block. In every such case, the owned pre-block state remains unchanged. + +Only after all inputs, commitments, canonical receipt bytes, and canonical +application-header bytes have been produced successfully does the ledger +replace its state with the tentative result. The final state transfer uses a +non-throwing move, so there is one atomic commit point from the public API's +perspective. Operational exceptions before that point also leave the ledger +unchanged. + +## Ordered result alignment + +`BlockCommit::admissions` has exactly one element for every raw input, in raw +input order: + +- an admission error identifies an omitted input; +- an empty optional identifies an admitted input. + +`transaction_ids`, `receipts`, and `encoded_receipts` contain only admitted +inputs. All three have the same length and use admitted execution order, +including duplicates. Element `i` in each sequence describes the same +admitted transaction. An admission failure has no application receipt and no +transaction-root leaf. + +This dual alignment lets an adapter report raw submission outcomes without +allowing adapter metadata to enter the canonical application commitment. + +## Canonical byte authority + +Typed results make in-process inspection explicit, but the kernel-produced +byte sequences are the persistence and interoperability authority: + +- each `encoded_receipts` element is the exact canonical 47-byte receipt; +- `header` is the exact canonical 146-byte version-one application header; +- `block_id` is derived from that exact header. + +Adapters should persist or transmit these bytes directly. They must not use +native struct layout, platform serialization, or independently reorder and +re-encode fields. If an adapter also stores typed projections, it must treat +the canonical bytes as the value against which those projections are checked. +Consensus-engine metadata may wrap these outputs but cannot alter them. + +## Deterministic errors and operational failure + +Protocol failures are closed typed values: + +- `GenesisError` reports deterministic canonical-genesis rejection; +- `BlockError` reports deterministic whole-block rejection; +- `AdmissionError` is aligned with a raw input; +- `TransferResult` is committed through an admitted transaction's receipt. + +Given the same prior state, height, parameters, and ordered bytes, every +correct node must produce the same typed values and canonical outputs. + +Resource exhaustion and implementation-provider failure are not protocol +results. Memory-allocation exceptions and failures reported by the pinned +libsodium boundary can depend on local process state, so the kernel does not +translate them into `GenesisError`, `BlockError`, `AdmissionError`, or a +receipt result. They propagate as local operational failures while the public +ledger state remains unchanged. + +A future node adapter must catch such failures at its process boundary, record +diagnostics, fail closed, and stop or halt the affected proposal-processing +path. It must not invent a consensus-visible rejection code and continue as +though peers necessarily observed the same condition. No C++ exception may +cross a C ABI; an adapter exposing a C boundary must catch every exception +before returning through that boundary. + +## Text address boundary + +`encode_address` derives the canonical lowercase Bech32m text form of a typed +account identifier for a validated chain HRP. `decode_address` accepts only +that canonical form, requires the configured HRP and version-one 33-byte +payload, and rejects bad checksums, nonzero padding, mixed or uppercase text, +unknown payload versions, and noncanonical HRPs. Both operations return an +empty optional for invalid text or configuration rather than defining new +protocol error codes. + +Text addresses are an input and display boundary only. The ledger, canonical +transactions, and commitments continue to store the typed 32-byte account +identifier. A caller must supply the HRP selected by its validated network +configuration; address text does not select or override a ledger chain ID. + +## Tagged protocol values + +The kernel represents `AccountId`, `ChainId`, `TransactionId`, `StateRoot`, +`TransactionRoot`, and `BlockId` as distinct tagged 32-byte types. Their +canonical widths and bytes are unchanged, but C++ does not implicitly convert +one role into another. + +Conversions at hashing, encoding, and decoding boundaries are explicit. This +prevents equal-width values from being accidentally substituted in header, +state, transaction, or account operations while preserving the immutable +version-one wire format. diff --git a/docs/decisions/0006-m1-devnet-ledger-parameters.md b/docs/decisions/0006-m1-devnet-ledger-parameters.md index aaf78da..64c3f3c 100644 --- a/docs/decisions/0006-m1-devnet-ledger-parameters.md +++ b/docs/decisions/0006-m1-devnet-ledger-parameters.md @@ -31,6 +31,11 @@ deterministic execution receipts in input order. Malformed, wrong-chain, and invalid-signature bytes remain outside application receipts and commitments as required by ADR 0004. +Retain the generic 1,048,576-byte canonical-object limit from ADR 0004. A +version-one genesis has a 46-byte fixed prefix and 48 bytes per account, so its +account count is limited to 21,844. Decoders must reject a larger declared +count before allocating account storage. + ## Rationale and alternatives Nine decimal places provide sub-unit granularity while allowing one billion @@ -59,6 +64,13 @@ observable at the application boundary. Committing malformed or invalid-signature bytes would conflict with the primitive specification and unnecessarily give meaningless bytes application identity. +Keeping the generic 65,535 list-count ceiling as the account-count bound would +make the largest genesis 3,145,726 bytes and require an exception to the +accepted canonical-object limit. Narrowing the count instead preserves the +existing allocation ceiling and still exceeds any M1 deployment requirement. +The largest accepted genesis is 1,048,558 bytes; 21,845 accounts would require +1,048,606 bytes and are rejected. + ## Security, economic, and compatibility effects - No public operation can mint, burn, issue another asset, or change the @@ -66,6 +78,9 @@ unnecessarily give meaningless bytes application identity. - Checked arithmetic and full-transition atomicity protect conservation. - Chain ID, strict signatures, exact next nonces, and expiry heights bound replay. +- Static transaction shape failures are malformed transactions. Public-key, + signature-point, scalar-canonicality, small-order, and signature-equation + failures all collapse to invalid signature after the chain check. - A successful self-transfer charges the same fixed fee and advances replay state without changing ownership. - The unused difference between genesis supply and the supply limit is not @@ -80,4 +95,6 @@ Acceptance requires the normative ledger-transition vectors and passing independent C++20 and Python harnesses under all compiler and sanitizer presets. The first production kernel change must use these vectors unchanged and add property and randomized differential sequences rather than replacing -the decision harness. +the decision harness. Production genesis-decoder tests must accept a canonical +21,844-account object and reject a declared count of 21,845 before allocation; +the existing default and synthetic genesis fixture bytes remain unchanged. diff --git a/docs/engineering/build-toolchain.md b/docs/engineering/build-toolchain.md index ab18afe..4761ede 100644 --- a/docs/engineering/build-toolchain.md +++ b/docs/engineering/build-toolchain.md @@ -40,13 +40,19 @@ The script checks the supported platform and host prerequisites, creates `.cache/toolchain-linux-x86_64`, and uses hash-checked requirements to install the exact CMake and Ninja wheels. CMake then downloads the official libsodium 1.0.22 archive, verifies its committed SHA-256 digest, builds it within the -selected preset, builds the C++20 vector harness, and runs both C++ and Python -tests through CTest. +selected preset, builds the C++20 verification targets, and runs the C++ and +Python suite through CTest. -The Python test uses only the standard library and the exact libsodium shared -library produced by that build. It does not inspect or modify the user's +The Python tests use only the standard library and the exact libsodium shared +library produced by that build. They do not inspect or modify the user's Python environment. +The `clang-sanitizers` preset additionally builds a separate copy of the +protocol kernel with libFuzzer coverage instrumentation. CTest runs bounded +512-input smoke sessions for transaction admission, text-address decoding, +and canonical genesis loading under AddressSanitizer and +UndefinedBehaviorSanitizer. The other three presets do not build fuzz targets. + ## Cache and cleanup Tool wheels and the isolated virtual environment live under `.cache/`. Each diff --git a/docs/engineering/verification.md b/docs/engineering/verification.md index 89ff40e..4680459 100644 --- a/docs/engineering/verification.md +++ b/docs/engineering/verification.md @@ -27,12 +27,15 @@ CTest. See `build-toolchain.md` for host prerequisites, other presets, cache behavior, and cleanup. CI runs GCC and Clang debug builds plus AddressSanitizer and -UndefinedBehaviorSanitizer builds. As production surfaces are added, this same -entry point will expand to orchestrate: +UndefinedBehaviorSanitizer builds. The current suite includes unit and boundary +tests, deterministic properties, 10,000 seeded differential sequences, and +bounded libFuzzer smoke under the Clang sanitizer preset. + +As production surfaces are added, this same entry point will expand to +orchestrate: - format and static analysis; -- unit, property, and integration tests; -- bounded CI fuzz smoke tests; +- integration tests; - deterministic replay and restart tests. Long-running fuzzing, economic simulations, and multi-platform reproducibility diff --git a/docs/project/current-state.md b/docs/project/current-state.md index 911162d..b468cd3 100644 --- a/docs/project/current-state.md +++ b/docs/project/current-state.md @@ -14,6 +14,9 @@ vectors. - F0 merged to `main` through PR #3 on 2026-07-23. - The reproducible build/toolchain slice merged through PR #7 on 2026-07-23; all four GitHub compiler/sanitizer jobs passed. +- Ledger-transition v1 merged through PR #9 on 2026-07-23; all four GitHub + compiler/sanitizer jobs passed. +- The complete issue #8 in-memory kernel is published for review in PR #10. - On 2026-07-23 the owner granted standing authority for autonomous project decisions and repository operations. A `proceed` instruction requires no follow-up approval. @@ -33,11 +36,21 @@ vectors. - ADR 0006 and `ledger-transition-v1.md` define canonical genesis, a single-native-asset transfer, fixed fee-pool routing, exact nonce/expiry and failure rules, receipts, and ordered atomic block execution. +- The 1,048,576-byte canonical-object limit bounds version-one genesis to + 21,844 accounts. Transaction shape errors are malformed, while all strict + Ed25519 canonicality, small-order, and equation failures are invalid + signatures after the chain check. - The M1 devnet uses nine atomic decimal places, a `10^18` atomic supply limit, a default `10^17` atomic four-account genesis, a 1,000-atomic fixed fee, and no post-genesis issuance. -- The repository still contains no ledger, networking, persistence, or - production deployment implementation. +- The issue #8 in-memory kernel branch implements strict transaction admission, + checked transfer execution, bounded canonical genesis loading, state and + transaction commitments, receipts, and atomic ordered block commit behind an + owning public `Ledger`. +- Account IDs, chain IDs, transaction IDs, state roots, transaction roots, and + block IDs are distinct tagged C++ types with unchanged canonical 32-byte + representations. Persistence, networking, RPC, consensus integration, and + deployment remain outside the kernel. ## Verification evidence @@ -63,15 +76,74 @@ vectors. unauthorized transaction kind. - All four local presets pass 4/4 CTest tests: GCC, GCC ASan+UBSan, Clang, and Clang ASan+UBSan. +- The initial production kernel slice uses owned value types, exact canonical + shape and chain checks, domain-separated account/transaction IDs, and the + pinned strict libsodium adapter. Its frozen admission vectors pass 5/5 CTest + tests under all four local presets. +- The unchanged primitive vector now runs directly through production hashing, + strict Ed25519 verification and admission, canonical Bech32m address + encoding/decoding, populated and empty state commitments, and ordered + transaction commitments. Focused cases cover non-canonical `S`, small-order + public keys and `R`, malformed lengths, bad checksums and padding, wrong + chains and HRPs, and admission-precedence overlaps. +- Checked production transfer execution reproduces all nine result codes and + the 11 admitted frozen-vector receipts. Tests establish fee routing, + conservation after every accepted transition, self-transfer, recipient + creation, nonce exhaustion, and byte-equivalent state atomicity for ordinary + failures and checked recipient/fee-pool invariant failures. +- All four local presets pass 6/6 CTest tests with the transfer execution + slice: GCC, GCC ASan+UBSan, Clang, and Clang ASan+UBSan. +- The production genesis decoder accepts the full 21,844-account boundary, + rejects an oversized declared count before allocation, and covers malformed + framing, parameter, account-order, checked-supply, exact-`u64`, and trailing + byte failures. +- Commitment tests reproduce the frozen previous/resulting state roots, + ordered transaction root, canonical receipt bytes, block header, and block + ID, and independently cover RFC 9162 tree shapes through 65,535 leaves. +- The public ledger tests run the unchanged frozen vectors through production + genesis load and atomic block commit. They cover all five genesis error + classes, raw/admitted output alignment, exact receipt bytes, height and + 65,535-input boundaries, empty and unadmitted blocks, duplicates, ordering, + determinism, tentative-copy isolation, and internal execution atomicity. +- All four local presets pass 9/9 CTest tests with the public block slice: GCC, + GCC ASan+UBSan, Clang, and Clang ASan+UBSan. +- Deterministic property tests run 9,000 generated states and transfers, cover + all nine execution results with deliberately overlapping invalid conditions, + compare every successful post-state exactly, and assert determinism, + failure atomicity, receipt validity, commitment validity, and supply + conservation. +- A standard-library-only Python reference model differentially checks 10,000 + nonempty SplitMix64-v1-seeded transaction sequences plus 11 directed + sequences against the public C++ ledger. Across 19,972 successful blocks and + 60,432 raw inputs, it compares raw-aligned admission results, 48,471 admitted + transaction IDs, typed and encoded receipts, all roots, headers, block IDs, + immutable parameters, height, fee pool, and every account after each block. +- The randomized corpus independently covers all three admission errors, every + execution result reachable from valid genesis, replay, reversed order, + self-transfer, recipient creation, empty blocks, and all-unadmitted blocks. + Nonce exhaustion and rejected genesis/block containers remain covered by + focused boundary tests because nonce exhaustion is not reachable from valid + genesis within a bounded sequence. +- All four local presets pass 11/11 CTest tests with property and differential + coverage: GCC, GCC ASan+UBSan, Clang, and Clang ASan+UBSan. +- The Clang sanitizer preset builds a separate copy of every kernel source with + libFuzzer coverage instrumentation. Fixed-seed 512-input smoke sessions + exercise raw and structured transaction admission up to 256 bytes, raw and + structured address decoding up to 256 bytes, and raw and structured genesis + loading up to 4,096 bytes. Every callback includes a valid signed + transaction, canonical address round trip, or successful minimal genesis, + respectively. +- The Clang ASan+UBSan preset passes 15/15 CTest tests including all three fuzz + targets; GCC, GCC ASan+UBSan, and Clang pass 12/12. ## Exact next action -Land the verified GitHub issue #6 specification/vector slice, then begin issue -#8: +Continue issue #8: -> Implement the original in-memory C++20 ledger kernel and differentially -> verify at least 10,000 seeded ordered transaction sequences against the -> independent Python model. +> Monitor all four required checks on PR #10 to a terminal result and +> rebase-merge it when green. Then open the next M1 issue for replaceable +> atomic persistence, reopen/replay, snapshots, corruption detection, and +> crash recovery before starting its storage ADR. ## Open autonomous decisions diff --git a/docs/specifications/ledger-transition-v1.md b/docs/specifications/ledger-transition-v1.md index 828f686..0a6d172 100644 --- a/docs/specifications/ledger-transition-v1.md +++ b/docs/specifications/ledger-transition-v1.md @@ -47,7 +47,7 @@ The version-one canonical genesis bytes are: | total supply | `u64` | configured, nonzero and at most the limit | | fixed transfer fee | `u64` | configured, nonzero | | initial fee pool | `u64` | configured | -| account count | `u32` | 1 through 65,535 | +| account count | `u32` | 1 through 21,844 | | accounts | repeated 48-byte state entries | exactly `account count` entries | Each account entry has the layout specified by ADR 0004: @@ -61,6 +61,12 @@ nonzero, and every genesis nonce must be zero. Checked addition of all balances and the initial fee pool must equal total supply. No trailing bytes are allowed. +The generic maximum canonical-object size is 1,048,576 bytes. The genesis +prefix through `account count` is 46 bytes, so at most 21,844 48-byte entries +fit: `46 + 48 * 21,844 = 1,048,558`. A decoder must reject a declared count +above 21,844 before allocating account storage or deriving the expected byte +length. A count of 21,845 would require 1,048,606 bytes and is invalid. + ```text chain_id = H(D("protocol-stack:v1:chain-id") || canonical_genesis_bytes) @@ -104,6 +110,13 @@ these checks in order: 3. derive the sender account ID from the encoded public key; 4. strictly verify the Ed25519 signature over the version-one signing message. +Step 1 classifies a wrong length, magic, schema version, transaction kind, or +signature-scheme identifier as `MALFORMED_TRANSACTION`. The fixed-width public +key and signature fields remain uninterpreted bytes during shape decoding. +At step 4, a non-canonical or small-order public key or `R`, a non-canonical +`S`, or a failed signature equation all return `INVALID_SIGNATURE`; these +cryptographic distinctions are never malformed-transaction results. + A failure returns the first applicable admission error: | Code | Name | diff --git a/include/protocol/v1/address.hpp b/include/protocol/v1/address.hpp new file mode 100644 index 0000000..a73450a --- /dev/null +++ b/include/protocol/v1/address.hpp @@ -0,0 +1,20 @@ +#pragma once + +#include "protocol/v1/types.hpp" + +#include +#include +#include + +namespace protocol::v1 { + +// Returns no value when hrp is not a canonical protocol HRP. +std::optional encode_address(const AccountId& account_id, + std::string_view hrp); + +// expected_hrp is a configured chain parameter. The decoder accepts only the +// canonical lowercase Bech32m form for that HRP and payload version 1. +std::optional decode_address(std::string_view address, + std::string_view expected_hrp); + +} // namespace protocol::v1 diff --git a/include/protocol/v1/admission.hpp b/include/protocol/v1/admission.hpp new file mode 100644 index 0000000..b4d5f1d --- /dev/null +++ b/include/protocol/v1/admission.hpp @@ -0,0 +1,12 @@ +#pragma once + +#include "protocol/v1/types.hpp" + +#include + +namespace protocol::v1 { + +Admission admit_transfer(std::span raw_transaction, + const ChainId& expected_chain_id); + +} // namespace protocol::v1 diff --git a/include/protocol/v1/crypto.hpp b/include/protocol/v1/crypto.hpp new file mode 100644 index 0000000..b67744a --- /dev/null +++ b/include/protocol/v1/crypto.hpp @@ -0,0 +1,17 @@ +#pragma once + +#include "protocol/v1/types.hpp" + +#include +#include + +namespace protocol::v1 { + +Hash hash(std::string_view domain_label, + std::span payload = {}); + +bool strict_ed25519_verify(std::span public_key, + std::span message, + std::span signature); + +} // namespace protocol::v1 diff --git a/include/protocol/v1/ledger.hpp b/include/protocol/v1/ledger.hpp new file mode 100644 index 0000000..c007580 --- /dev/null +++ b/include/protocol/v1/ledger.hpp @@ -0,0 +1,67 @@ +#pragma once + +#include "protocol/v1/types.hpp" + +#include +#include +#include +#include +#include + +namespace protocol::v1 { + +enum class BlockError : std::uint8_t { + invalid_state = 1, + height_exhausted = 2, + invalid_height = 3, + too_many_inputs = 4, + invariant_failure = 5, +}; + +struct BlockCommit { + std::uint64_t height; + std::vector> admissions; + std::vector transaction_ids; + std::vector receipts; + std::vector encoded_receipts; + StateRoot previous_state_root; + TransactionRoot transaction_root; + StateRoot resulting_state_root; + Bytes header; + BlockId block_id; +}; + +struct LedgerLoad; + +class Ledger { + public: + Ledger(const Ledger&) = default; + Ledger(Ledger&&) noexcept = default; + Ledger& operator=(const Ledger&) = delete; + Ledger& operator=(Ledger&&) = delete; + + const State& state() const noexcept { return state_; } + + std::variant current_state_root() const; + + std::variant apply_block( + std::uint64_t height, + std::span raw_transactions); + + private: + explicit Ledger(State state) noexcept; + + friend LedgerLoad load_genesis( + std::span canonical_genesis); + + State state_; +}; + +struct LedgerLoad { + std::variant result; +}; + +LedgerLoad load_genesis( + std::span canonical_genesis); + +} // namespace protocol::v1 diff --git a/include/protocol/v1/types.hpp b/include/protocol/v1/types.hpp new file mode 100644 index 0000000..4c6cc0e --- /dev/null +++ b/include/protocol/v1/types.hpp @@ -0,0 +1,122 @@ +#pragma once + +#include +#include +#include +#include +#include +#include +#include +#include + +namespace protocol::v1 { + +using Bytes = std::vector; +using Hash = std::array; + +template +class TaggedHash { + public: + TaggedHash() = default; + explicit TaggedHash(Hash value) noexcept : value_(std::move(value)) {} + + auto begin() noexcept { return value_.begin(); } + auto begin() const noexcept { return value_.begin(); } + auto end() noexcept { return value_.end(); } + auto end() const noexcept { return value_.end(); } + std::uint8_t* data() noexcept { return value_.data(); } + const std::uint8_t* data() const noexcept { return value_.data(); } + constexpr std::size_t size() const noexcept { return value_.size(); } + + auto operator<=>(const TaggedHash&) const = default; + + private: + Hash value_{}; +}; + +struct AccountIdTag; +struct ChainIdTag; +struct TransactionIdTag; +struct StateRootTag; +struct TransactionRootTag; +struct BlockIdTag; + +using AccountId = TaggedHash; +using ChainId = TaggedHash; +using TransactionId = TaggedHash; +using StateRoot = TaggedHash; +using TransactionRoot = TaggedHash; +using BlockId = TaggedHash; + +struct Account { + std::uint64_t balance; + std::uint64_t nonce; + + auto operator<=>(const Account&) const = default; +}; + +struct Parameters { + ChainId chain_id; + std::uint64_t supply_limit; + std::uint64_t total_supply; + std::uint64_t fixed_fee; + + bool operator==(const Parameters&) const = default; +}; + +struct State { + Parameters parameters; + std::uint64_t height; + std::uint64_t fee_pool; + std::map accounts; + + bool operator==(const State&) const = default; +}; + +enum class AdmissionError : std::uint8_t { + malformed_transaction = 1, + wrong_chain = 2, + invalid_signature = 3, +}; + +enum class GenesisError : std::uint8_t { + malformed = 1, + unsupported_network = 2, + invalid_parameters = 3, + invalid_accounts = 4, + invalid_supply = 5, +}; + +enum class TransferResult : std::uint8_t { + success = 0, + zero_amount = 1, + fee_limit_too_low = 2, + expired = 3, + sender_not_found = 4, + nonce_exhausted = 5, + nonce_mismatch = 6, + debit_overflow = 7, + insufficient_balance = 8, +}; + +struct Transfer { + AccountId sender_id; + TransactionId transaction_id; + std::uint64_t nonce; + AccountId recipient; + std::uint64_t amount; + std::uint64_t fee_limit; + std::uint64_t valid_until; +}; + +using Admission = std::variant; + +struct Receipt { + TransactionId transaction_id; + TransferResult result; + std::uint64_t fee_charged; + + bool operator==(const Receipt&) const = default; +}; + +} // namespace protocol::v1 diff --git a/src/v1/address.cpp b/src/v1/address.cpp new file mode 100644 index 0000000..ea59ae1 --- /dev/null +++ b/src/v1/address.cpp @@ -0,0 +1,190 @@ +#include "protocol/v1/address.hpp" + +#include +#include +#include +#include +#include +#include +#include + +namespace protocol::v1 { +namespace { + +constexpr std::string_view kCharset = + "qpzry9x8gf2tvdw0s3jn54khce6mua7l"; +constexpr std::uint32_t kBech32mConstant = 0x2bc830a3U; +constexpr std::array kGenerators{ + 0x3b6a57b2U, + 0x26508e6dU, + 0x1ea119faU, + 0x3d4233ddU, + 0x2a1462b3U, +}; + +bool valid_hrp(std::string_view hrp) { + return !hrp.empty() && hrp.size() <= 20 && + std::all_of(hrp.begin(), hrp.end(), [](char character) { + return (character >= 'a' && character <= 'z') || + (character >= '0' && character <= '9'); + }); +} + +std::vector expand_hrp(std::string_view hrp) { + std::vector expanded; + expanded.reserve(hrp.size() * 2 + 1); + for (const char character : hrp) { + expanded.push_back(static_cast(character) >> 5U); + } + expanded.push_back(0); + for (const char character : hrp) { + expanded.push_back(static_cast(character) & 31U); + } + return expanded; +} + +std::uint32_t polymod(std::span values) { + std::uint32_t checksum = 1; + for (const auto value : values) { + const auto top = checksum >> 25U; + checksum = ((checksum & 0x1ffffffU) << 5U) ^ value; + for (std::size_t index = 0; index < kGenerators.size(); ++index) { + if (((top >> index) & 1U) != 0U) checksum ^= kGenerators[index]; + } + } + return checksum; +} + +std::vector to_base32( + std::span payload) { + std::vector encoded; + encoded.reserve((payload.size() * 8 + 4) / 5); + std::uint32_t accumulator = 0; + unsigned bit_count = 0; + for (const auto value : payload) { + accumulator = ((accumulator << 8U) | value) & 0xfffU; + bit_count += 8; + while (bit_count >= 5) { + bit_count -= 5; + encoded.push_back( + static_cast((accumulator >> bit_count) & 31U)); + } + } + if (bit_count != 0) { + encoded.push_back( + static_cast((accumulator << (5 - bit_count)) & 31U)); + } + return encoded; +} + +bool from_base32(std::span encoded, Bytes& payload) { + payload.clear(); + payload.reserve(encoded.size() * 5 / 8); + std::uint32_t accumulator = 0; + unsigned bit_count = 0; + for (const auto value : encoded) { + if (value > 31) return false; + accumulator = ((accumulator << 5U) | value) & 0xfffU; + bit_count += 5; + if (bit_count >= 8) { + bit_count -= 8; + payload.push_back( + static_cast((accumulator >> bit_count) & 0xffU)); + } + } + if (bit_count >= 5) return false; + return bit_count == 0 || + ((accumulator << (8 - bit_count)) & 0xffU) == 0; +} + +void append_checksum(std::string_view hrp, + std::vector& data) { + auto values = expand_hrp(hrp); + values.insert(values.end(), data.begin(), data.end()); + values.resize(values.size() + 6, 0); + const auto checksum = polymod(values) ^ kBech32mConstant; + for (int index = 0; index < 6; ++index) { + data.push_back(static_cast( + (checksum >> (5 * (5 - index))) & 31U)); + } +} + +bool valid_checksum(std::string_view hrp, + std::span data) { + auto values = expand_hrp(hrp); + values.insert(values.end(), data.begin(), data.end()); + return polymod(values) == kBech32mConstant; +} + +std::optional> decode_characters( + std::string_view encoded) { + std::vector values; + values.reserve(encoded.size()); + for (const char character : encoded) { + const auto position = kCharset.find(character); + if (position == std::string_view::npos) return std::nullopt; + values.push_back(static_cast(position)); + } + return values; +} + +} // namespace + +std::optional encode_address(const AccountId& account_id, + std::string_view hrp) { + if (!valid_hrp(hrp)) return std::nullopt; + + Bytes payload{1}; + payload.insert(payload.end(), account_id.begin(), account_id.end()); + auto data = to_base32(payload); + append_checksum(hrp, data); + + std::string address(hrp); + address.reserve(hrp.size() + 1 + data.size()); + address.push_back('1'); + for (const auto value : data) address.push_back(kCharset[value]); + if (address.size() > 90) return std::nullopt; + return address; +} + +std::optional decode_address(std::string_view address, + std::string_view expected_hrp) { + if (!valid_hrp(expected_hrp)) return std::nullopt; + if (address.empty() || address.size() > 90) { + return std::nullopt; + } + + const auto separator = address.rfind('1'); + if (separator == std::string_view::npos || separator == 0 || + separator > 20 || address.size() - separator - 1 < 6) { + return std::nullopt; + } + const auto embedded_hrp = address.substr(0, separator); + if (!valid_hrp(embedded_hrp)) return std::nullopt; + + const auto encoded = decode_characters(address.substr(separator + 1)); + if (!encoded) return std::nullopt; + if (!valid_checksum(embedded_hrp, *encoded)) { + return std::nullopt; + } + if (embedded_hrp != expected_hrp) return std::nullopt; + + constexpr std::size_t kChecksumSize = 6; + Bytes payload; + if (!from_base32( + std::span(*encoded) + .first(encoded->size() - kChecksumSize), + payload) || + payload.size() != 33 || payload.front() != 1) { + return std::nullopt; + } + + Hash identifier{}; + std::copy(payload.begin() + 1, payload.end(), identifier.begin()); + const AccountId account_id(identifier); + const auto canonical = encode_address(account_id, expected_hrp); + if (!canonical || *canonical != address) return std::nullopt; + return account_id; +} + +} // namespace protocol::v1 diff --git a/src/v1/admission.cpp b/src/v1/admission.cpp new file mode 100644 index 0000000..1c7c4bd --- /dev/null +++ b/src/v1/admission.cpp @@ -0,0 +1,76 @@ +#include "protocol/v1/admission.hpp" + +#include "protocol/v1/crypto.hpp" + +#include +#include +#include + +namespace protocol::v1 { +namespace { + +constexpr std::size_t kUnsignedSize = 136; +constexpr std::size_t kSignedSize = 200; + +bool valid_shape(std::span raw) { + constexpr std::array magic{'P', 'S', 'T', 'X'}; + return raw.size() == kSignedSize && + std::equal(magic.begin(), magic.end(), raw.begin()) && + raw[4] == 0 && raw[5] == 1 && raw[6] == 1 && raw[39] == 1; +} + +Hash fixed_32(std::span raw, std::size_t offset) { + Hash result{}; + std::copy_n(raw.begin() + offset, result.size(), result.begin()); + return result; +} + +std::uint64_t read_u64(std::span raw, + std::size_t offset) { + std::uint64_t result = 0; + for (std::size_t index = 0; index < 8; ++index) { + result = (result << 8U) | raw[offset + index]; + } + return result; +} + +AccountId sender_id(std::span public_key) { + Bytes payload{1}; + payload.insert(payload.end(), public_key.begin(), public_key.end()); + return AccountId(hash("protocol-stack:v1:account", payload)); +} + +Bytes signing_message(std::span unsigned_transaction) { + constexpr std::string_view label = "protocol-stack:v1:tx-sign"; + Bytes message{static_cast(label.size())}; + message.insert(message.end(), label.begin(), label.end()); + message.insert(message.end(), unsigned_transaction.begin(), + unsigned_transaction.end()); + return message; +} + +} // namespace + +Admission admit_transfer(std::span raw, + const ChainId& expected_chain_id) { + if (!valid_shape(raw)) return AdmissionError::malformed_transaction; + if (ChainId(fixed_32(raw, 7)) != expected_chain_id) { + return AdmissionError::wrong_chain; + } + const auto public_key = raw.subspan(40, 32); + const auto message = signing_message(raw.first(kUnsignedSize)); + if (!strict_ed25519_verify(public_key, message, raw.subspan(136, 64))) { + return AdmissionError::invalid_signature; + } + return Transfer{ + sender_id(public_key), + TransactionId(hash("protocol-stack:v1:tx-id", raw)), + read_u64(raw, 72), + AccountId(fixed_32(raw, 80)), + read_u64(raw, 112), + read_u64(raw, 120), + read_u64(raw, 128), + }; +} + +} // namespace protocol::v1 diff --git a/src/v1/commitments.cpp b/src/v1/commitments.cpp new file mode 100644 index 0000000..aaba80f --- /dev/null +++ b/src/v1/commitments.cpp @@ -0,0 +1,182 @@ +#include "commitments.hpp" + +#include "protocol/v1/crypto.hpp" + +#include +#include +#include +#include + +namespace protocol::v1::internal { +namespace { + +void append(Bytes& target, std::span value) { + target.insert(target.end(), value.begin(), value.end()); +} + +void append_u16(Bytes& target, std::uint16_t value) { + target.push_back(static_cast(value >> 8U)); + target.push_back(static_cast(value)); +} + +void append_u32(Bytes& target, std::uint32_t value) { + for (int shift = 24; shift >= 0; shift -= 8) { + target.push_back(static_cast(value >> shift)); + } +} + +void append_u64(Bytes& target, std::uint64_t value) { + for (int shift = 56; shift >= 0; shift -= 8) { + target.push_back(static_cast(value >> shift)); + } +} + +std::size_t merkle_split(std::size_t count) { + std::size_t split = 1; + while (split < count - split) split <<= 1U; + return split; +} + +template +std::span item_bytes(const TaggedHash& value) { + return {value.data(), value.size()}; +} + +std::span item_bytes(const Bytes& value) { + return {value.data(), value.size()}; +} + +template +Hash merkle(std::span items, std::string_view empty_label, + std::string_view leaf_label, std::string_view node_label) { + if (items.empty()) return protocol::v1::hash(empty_label); + if (items.size() == 1) { + return protocol::v1::hash(leaf_label, item_bytes(items.front())); + } + const auto split = merkle_split(items.size()); + const auto left = + merkle(items.first(split), empty_label, leaf_label, node_label); + const auto right = + merkle(items.subspan(split), empty_label, leaf_label, node_label); + Bytes children; + children.reserve(left.size() + right.size()); + append(children, left); + append(children, right); + return protocol::v1::hash(node_label, children); +} + +Bytes account_entry(const AccountId& identifier, const Account& account) { + Bytes entry; + entry.reserve(48); + append(entry, identifier); + append_u64(entry, account.balance); + append_u64(entry, account.nonce); + return entry; +} + +bool valid_result(TransferResult result) { + return static_cast(result) <= + static_cast(TransferResult::insufficient_balance); +} + +} // namespace + +StateCommitment state_root(const State& state) { + const auto& parameters = state.parameters; + if (parameters.supply_limit == 0 || parameters.fixed_fee == 0 || + parameters.total_supply > parameters.supply_limit) { + return StateError::invalid_parameters; + } + + auto conserved_supply = state.fee_pool; + std::vector entries; + entries.reserve(state.accounts.size()); + for (const auto& [identifier, account] : state.accounts) { + if (account.balance > + std::numeric_limits::max() - conserved_supply) { + return StateError::supply_overflow; + } + conserved_supply += account.balance; + entries.push_back(account_entry(identifier, account)); + } + if (conserved_supply != parameters.total_supply) { + return StateError::supply_mismatch; + } + + const auto accounts_root = + merkle(entries, "protocol-stack:v1:state-empty", + "protocol-stack:v1:state-leaf", + "protocol-stack:v1:state-node"); + Bytes payload; + payload.reserve(106); + append_u16(payload, 1); + append(payload, parameters.chain_id); + append_u64(payload, state.height); + append_u64(payload, parameters.supply_limit); + append_u64(payload, parameters.total_supply); + append_u64(payload, state.fee_pool); + if constexpr (sizeof(std::size_t) > sizeof(std::uint64_t)) { + if (entries.size() > std::numeric_limits::max()) { + return StateError::invalid_parameters; + } + } + append_u64(payload, static_cast(entries.size())); + append(payload, accounts_root); + return StateRoot( + protocol::v1::hash("protocol-stack:v1:state-root", payload)); +} + +TransactionRoot transaction_root( + std::span transaction_ids) { + return TransactionRoot( + merkle(transaction_ids, "protocol-stack:v1:tx-empty", + "protocol-stack:v1:tx-leaf", + "protocol-stack:v1:tx-node")); +} + +std::optional encode_receipt(const Receipt& receipt, + std::uint64_t fixed_fee) { + if (!valid_result(receipt.result)) return std::nullopt; + const bool success = receipt.result == TransferResult::success; + if ((success && receipt.fee_charged != fixed_fee) || + (!success && receipt.fee_charged != 0)) { + return std::nullopt; + } + + Bytes encoded{'P', 'S', 'R', 'C'}; + encoded.reserve(47); + append_u16(encoded, 1); + append(encoded, receipt.transaction_id); + encoded.push_back(static_cast(receipt.result)); + append_u64(encoded, receipt.fee_charged); + return encoded; +} + +Bytes encode_block_header(const ChainId& chain_id, std::uint64_t height, + const StateRoot& previous_state_root, + const TransactionRoot& transaction_root_value, + const StateRoot& resulting_state_root, + std::uint32_t transaction_count) { + Bytes encoded{'P', 'S', 'B', 'L'}; + encoded.reserve(146); + append_u16(encoded, 1); + append(encoded, chain_id); + append_u64(encoded, height); + append(encoded, previous_state_root); + append(encoded, transaction_root_value); + append(encoded, resulting_state_root); + append_u32(encoded, transaction_count); + return encoded; +} + +std::optional block_id(std::span header) { + constexpr std::size_t kHeaderSize = 146; + if (header.size() != kHeaderSize || header[0] != 'P' || + header[1] != 'S' || header[2] != 'B' || header[3] != 'L' || + header[4] != 0 || header[5] != 1) { + return std::nullopt; + } + return BlockId(protocol::v1::hash("protocol-stack:v1:block-id", header)); +} + +} // namespace protocol::v1::internal diff --git a/src/v1/commitments.hpp b/src/v1/commitments.hpp new file mode 100644 index 0000000..074e8d4 --- /dev/null +++ b/src/v1/commitments.hpp @@ -0,0 +1,36 @@ +#pragma once + +#include "protocol/v1/types.hpp" + +#include +#include +#include +#include + +namespace protocol::v1::internal { + +enum class StateError : std::uint8_t { + invalid_parameters = 1, + supply_overflow = 2, + supply_mismatch = 3, +}; + +using StateCommitment = std::variant; + +StateCommitment state_root(const State& state); + +TransactionRoot transaction_root( + std::span transaction_ids); + +std::optional encode_receipt(const Receipt& receipt, + std::uint64_t fixed_fee); + +Bytes encode_block_header(const ChainId& chain_id, std::uint64_t height, + const StateRoot& previous_state_root, + const TransactionRoot& transaction_root, + const StateRoot& resulting_state_root, + std::uint32_t transaction_count); + +std::optional block_id(std::span header); + +} // namespace protocol::v1::internal diff --git a/src/v1/crypto.cpp b/src/v1/crypto.cpp new file mode 100644 index 0000000..79b2f36 --- /dev/null +++ b/src/v1/crypto.cpp @@ -0,0 +1,53 @@ +#include "protocol/v1/crypto.hpp" + +#include + +#include +#include + +namespace protocol::v1 { +namespace { + +Bytes domain(std::string_view label) { + if (label.size() > std::numeric_limits::max()) { + throw std::invalid_argument("domain label exceeds u8"); + } + Bytes encoded{static_cast(label.size())}; + encoded.insert(encoded.end(), label.begin(), label.end()); + return encoded; +} + +void require_sodium() { + static const int initialization_result = sodium_init(); + if (initialization_result < 0) { + throw std::runtime_error("libsodium initialization failure"); + } +} + +} // namespace + +Hash hash(std::string_view domain_label, + std::span payload) { + require_sodium(); + auto input = domain(domain_label); + input.insert(input.end(), payload.begin(), payload.end()); + Hash output{}; + if (crypto_hash_sha256(output.data(), input.data(), input.size()) != 0) { + throw std::runtime_error("libsodium SHA-256 failure"); + } + return output; +} + +bool strict_ed25519_verify(std::span public_key, + std::span message, + std::span signature) { + require_sodium(); + if (public_key.size() != crypto_sign_PUBLICKEYBYTES || + signature.size() != crypto_sign_BYTES) { + return false; + } + return crypto_sign_verify_detached(signature.data(), message.data(), + message.size(), public_key.data()) == 0; +} + +} // namespace protocol::v1 diff --git a/src/v1/encoding.hpp b/src/v1/encoding.hpp new file mode 100644 index 0000000..8e029fc --- /dev/null +++ b/src/v1/encoding.hpp @@ -0,0 +1,58 @@ +#pragma once + +#include +#include +#include +#include +#include + +namespace protocol::v1::internal { + +template +std::optional> read_fixed( + std::span input, std::size_t offset) { + if (offset > input.size() || Size > input.size() - offset) { + return std::nullopt; + } + std::array result{}; + for (std::size_t index = 0; index < Size; ++index) { + result[index] = input[offset + index]; + } + return result; +} + +inline std::optional read_u16( + std::span input, std::size_t offset) { + if (offset > input.size() || 2 > input.size() - offset) { + return std::nullopt; + } + return static_cast( + (static_cast(input[offset]) << 8U) | + static_cast(input[offset + 1])); +} + +inline std::optional read_u32( + std::span input, std::size_t offset) { + if (offset > input.size() || 4 > input.size() - offset) { + return std::nullopt; + } + std::uint32_t result = 0; + for (std::size_t index = 0; index < 4; ++index) { + result = (result << 8U) | input[offset + index]; + } + return result; +} + +inline std::optional read_u64( + std::span input, std::size_t offset) { + if (offset > input.size() || 8 > input.size() - offset) { + return std::nullopt; + } + std::uint64_t result = 0; + for (std::size_t index = 0; index < 8; ++index) { + result = (result << 8U) | input[offset + index]; + } + return result; +} + +} // namespace protocol::v1::internal diff --git a/src/v1/execution.cpp b/src/v1/execution.cpp new file mode 100644 index 0000000..bdb3ac7 --- /dev/null +++ b/src/v1/execution.cpp @@ -0,0 +1,80 @@ +#include "execution.hpp" + +#include + +namespace protocol::v1::internal { +namespace { + +Receipt receipt(const Transfer& transfer, TransferResult result, + std::uint64_t fee) { + return Receipt{transfer.transaction_id, result, fee}; +} + +Execution failure(const Transfer& transfer, TransferResult result) { + return receipt(transfer, result, 0); +} + +} // namespace + +Execution execute_transfer(const Transfer& transfer, State& state, + std::uint64_t block_height) { + const auto fixed_fee = state.parameters.fixed_fee; + if (transfer.amount == 0) { + return failure(transfer, TransferResult::zero_amount); + } + if (transfer.fee_limit < fixed_fee) { + return failure(transfer, TransferResult::fee_limit_too_low); + } + if (transfer.valid_until < block_height) { + return failure(transfer, TransferResult::expired); + } + + const auto sender_it = state.accounts.find(transfer.sender_id); + if (sender_it == state.accounts.end()) { + return failure(transfer, TransferResult::sender_not_found); + } + const auto& sender = sender_it->second; + if (sender.nonce == std::numeric_limits::max()) { + return failure(transfer, TransferResult::nonce_exhausted); + } + if (transfer.nonce != sender.nonce + 1) { + return failure(transfer, TransferResult::nonce_mismatch); + } + if (transfer.amount > + std::numeric_limits::max() - fixed_fee) { + return failure(transfer, TransferResult::debit_overflow); + } + + const auto debit = transfer.amount + fixed_fee; + if (sender.balance < debit) { + return failure(transfer, TransferResult::insufficient_balance); + } + + const bool self_transfer = transfer.sender_id == transfer.recipient; + const auto recipient_it = state.accounts.find(transfer.recipient); + if (!self_transfer && recipient_it != state.accounts.end() && + recipient_it->second.balance > + std::numeric_limits::max() - transfer.amount) { + return ExecutionError::recipient_balance_overflow; + } + if (state.fee_pool > + std::numeric_limits::max() - fixed_fee) { + return ExecutionError::fee_pool_overflow; + } + + if (self_transfer) { + sender_it->second.balance -= fixed_fee; + } else { + if (recipient_it == state.accounts.end()) { + state.accounts.emplace(transfer.recipient, Account{transfer.amount, 0}); + } else { + recipient_it->second.balance += transfer.amount; + } + sender_it->second.balance -= debit; + } + sender_it->second.nonce = transfer.nonce; + state.fee_pool += fixed_fee; + return receipt(transfer, TransferResult::success, fixed_fee); +} + +} // namespace protocol::v1::internal diff --git a/src/v1/execution.hpp b/src/v1/execution.hpp new file mode 100644 index 0000000..95f50cd --- /dev/null +++ b/src/v1/execution.hpp @@ -0,0 +1,19 @@ +#pragma once + +#include "protocol/v1/types.hpp" + +#include + +namespace protocol::v1::internal { + +enum class ExecutionError : std::uint8_t { + recipient_balance_overflow = 1, + fee_pool_overflow = 2, +}; + +using Execution = std::variant; + +Execution execute_transfer(const Transfer& transfer, State& state, + std::uint64_t block_height); + +} // namespace protocol::v1::internal diff --git a/src/v1/genesis.cpp b/src/v1/genesis.cpp new file mode 100644 index 0000000..6731516 --- /dev/null +++ b/src/v1/genesis.cpp @@ -0,0 +1,144 @@ +#include "genesis.hpp" + +#include "encoding.hpp" +#include "protocol/v1/crypto.hpp" + +#include +#include +#include +#include +#include +#include +#include + +namespace protocol::v1::internal { +namespace { + +constexpr std::size_t kGenesisPrefixSize = 46; +constexpr std::size_t kAccountSize = 48; +constexpr std::uint32_t kMaximumGenesisAccounts = 21'844; +constexpr std::array kGenesisMagic{'P', 'S', 'G', 'N'}; + +struct GenesisFields { + std::uint64_t supply_limit; + std::uint64_t total_supply; + std::uint64_t fixed_fee; + std::uint64_t initial_fee_pool; + std::uint32_t account_count; +}; + +std::variant decode_fields( + std::span canonical_genesis) { + if (canonical_genesis.size() < kGenesisPrefixSize) { + return GenesisError::malformed; + } + const auto magic = read_fixed<4>(canonical_genesis, 0); + const auto version = read_u16(canonical_genesis, 4); + const auto network = read_u32(canonical_genesis, 6); + const auto supply_limit = read_u64(canonical_genesis, 10); + const auto total_supply = read_u64(canonical_genesis, 18); + const auto fixed_fee = read_u64(canonical_genesis, 26); + const auto initial_fee_pool = read_u64(canonical_genesis, 34); + const auto account_count = read_u32(canonical_genesis, 42); + if (!magic || !version || !network || !supply_limit || !total_supply || + !fixed_fee || !initial_fee_pool || !account_count) { + return GenesisError::malformed; + } + if (*magic != kGenesisMagic || *version != 1) { + return GenesisError::malformed; + } + if (*network != 1) { + return GenesisError::unsupported_network; + } + if (*supply_limit == 0 || *total_supply == 0 || *fixed_fee == 0) { + return GenesisError::invalid_parameters; + } + if (*total_supply > *supply_limit) { + return GenesisError::invalid_supply; + } + if (*account_count == 0 || *account_count > kMaximumGenesisAccounts) { + return GenesisError::invalid_accounts; + } + return GenesisFields{*supply_limit, *total_supply, *fixed_fee, + *initial_fee_pool, *account_count}; +} + +std::optional validate_accounts( + std::span canonical_genesis, + const GenesisFields& fields) { + std::optional previous_identifier; + std::uint64_t conserved_supply = fields.initial_fee_pool; + for (std::size_t index = 0; index < fields.account_count; ++index) { + const auto offset = kGenesisPrefixSize + index * kAccountSize; + const auto raw_identifier = read_fixed<32>(canonical_genesis, offset); + const auto balance = read_u64(canonical_genesis, offset + 32); + const auto nonce = read_u64(canonical_genesis, offset + 40); + if (!raw_identifier || !balance || !nonce) { + return GenesisError::malformed; + } + const AccountId identifier(*raw_identifier); + if (*balance == 0 || *nonce != 0 || + (previous_identifier && !(*previous_identifier < identifier))) { + return GenesisError::invalid_accounts; + } + if (*balance > + std::numeric_limits::max() - conserved_supply) { + return GenesisError::invalid_supply; + } + conserved_supply += *balance; + previous_identifier = identifier; + } + if (conserved_supply != fields.total_supply) { + return GenesisError::invalid_supply; + } + return std::nullopt; +} + +std::map decode_accounts( + std::span canonical_genesis, + std::uint32_t account_count) { + std::map accounts; + for (std::size_t index = 0; index < account_count; ++index) { + const auto offset = kGenesisPrefixSize + index * kAccountSize; + const AccountId identifier(*read_fixed<32>(canonical_genesis, offset)); + const auto balance = *read_u64(canonical_genesis, offset + 32); + const auto nonce = *read_u64(canonical_genesis, offset + 40); + accounts.emplace(identifier, Account{balance, nonce}); + } + return accounts; +} + +} // namespace + +GenesisDecode decode_genesis( + std::span canonical_genesis) { + const auto decoded_fields = decode_fields(canonical_genesis); + if (std::holds_alternative(decoded_fields)) { + return std::get(decoded_fields); + } + const auto fields = std::get(decoded_fields); + const auto expected_size = + kGenesisPrefixSize + + static_cast(fields.account_count) * kAccountSize; + if (canonical_genesis.size() != expected_size) { + return GenesisError::malformed; + } + const auto account_error = validate_accounts(canonical_genesis, fields); + if (account_error) { + return *account_error; + } + auto accounts = decode_accounts(canonical_genesis, fields.account_count); + return State{ + Parameters{ + ChainId(hash("protocol-stack:v1:chain-id", canonical_genesis)), + fields.supply_limit, + fields.total_supply, + fields.fixed_fee, + }, + 0, + fields.initial_fee_pool, + std::move(accounts), + }; +} + +} // namespace protocol::v1::internal diff --git a/src/v1/genesis.hpp b/src/v1/genesis.hpp new file mode 100644 index 0000000..4404db2 --- /dev/null +++ b/src/v1/genesis.hpp @@ -0,0 +1,16 @@ +#pragma once + +#include "protocol/v1/types.hpp" + +#include +#include +#include + +namespace protocol::v1::internal { + +using GenesisDecode = std::variant; + +GenesisDecode decode_genesis( + std::span canonical_genesis); + +} // namespace protocol::v1::internal diff --git a/src/v1/ledger.cpp b/src/v1/ledger.cpp new file mode 100644 index 0000000..04ab7f5 --- /dev/null +++ b/src/v1/ledger.cpp @@ -0,0 +1,146 @@ +#include "protocol/v1/ledger.hpp" + +#include "commitments.hpp" +#include "execution.hpp" +#include "genesis.hpp" +#include "protocol/v1/admission.hpp" + +#include +#include +#include +#include +#include + +namespace protocol::v1 { +namespace { + +constexpr std::size_t kMaximumBlockInputs = 65'535; + +} // namespace + +static_assert(std::is_nothrow_move_constructible_v); +static_assert(std::is_nothrow_move_assignable_v); +static_assert(std::is_nothrow_move_constructible_v); + +Ledger::Ledger(State state) noexcept : state_(std::move(state)) {} + +LedgerLoad load_genesis( + std::span canonical_genesis) { + auto decoded = internal::decode_genesis(canonical_genesis); + if (std::holds_alternative(decoded)) { + return LedgerLoad{ + std::variant( + std::in_place_type, + std::get(decoded)), + }; + } + return LedgerLoad{ + std::variant( + std::in_place_type, + Ledger(std::get(std::move(decoded)))), + }; +} + +std::variant Ledger::current_state_root() const { + auto commitment = internal::state_root(state_); + if (std::holds_alternative(commitment)) { + return BlockError::invalid_state; + } + return std::get(std::move(commitment)); +} + +std::variant Ledger::apply_block( + std::uint64_t height, + std::span raw_transactions) { + if (raw_transactions.size() > kMaximumBlockInputs) { + return BlockError::too_many_inputs; + } + + auto previous_commitment = internal::state_root(state_); + if (std::holds_alternative(previous_commitment)) { + return BlockError::invalid_state; + } + if (state_.height == std::numeric_limits::max()) { + return BlockError::height_exhausted; + } + if (height != state_.height + 1) { + return BlockError::invalid_height; + } + + State tentative = state_; + std::vector> admissions; + std::vector transaction_ids; + std::vector receipts; + std::vector encoded_receipts; + admissions.reserve(raw_transactions.size()); + transaction_ids.reserve(raw_transactions.size()); + receipts.reserve(raw_transactions.size()); + encoded_receipts.reserve(raw_transactions.size()); + + for (const auto& raw_transaction : raw_transactions) { + auto admission = + admit_transfer(raw_transaction, tentative.parameters.chain_id); + if (std::holds_alternative(admission)) { + admissions.emplace_back(std::get(admission)); + continue; + } + admissions.emplace_back(std::nullopt); + + const auto& transfer = std::get(admission); + transaction_ids.push_back(transfer.transaction_id); + auto execution = internal::execute_transfer(transfer, tentative, height); + if (std::holds_alternative(execution)) { + return BlockError::invariant_failure; + } + + auto receipt = std::get(std::move(execution)); + auto encoded = + internal::encode_receipt(receipt, tentative.parameters.fixed_fee); + if (!encoded) { + return BlockError::invariant_failure; + } + receipts.push_back(std::move(receipt)); + encoded_receipts.push_back(std::move(*encoded)); + } + + tentative.height = height; + auto resulting_commitment = internal::state_root(tentative); + if (std::holds_alternative(resulting_commitment)) { + return BlockError::invariant_failure; + } + + const auto previous_state_root = + std::get(std::move(previous_commitment)); + const auto resulting_state_root = + std::get(std::move(resulting_commitment)); + const auto committed_transaction_root = + internal::transaction_root(transaction_ids); + auto header = internal::encode_block_header( + tentative.parameters.chain_id, height, previous_state_root, + committed_transaction_root, resulting_state_root, + static_cast(transaction_ids.size())); + auto committed_block_id = internal::block_id(header); + if (!committed_block_id) { + return BlockError::invariant_failure; + } + + std::variant result( + std::in_place_type, + BlockCommit{ + height, + std::move(admissions), + std::move(transaction_ids), + std::move(receipts), + std::move(encoded_receipts), + previous_state_root, + committed_transaction_root, + resulting_state_root, + std::move(header), + std::move(*committed_block_id), + }); + + state_ = std::move(tentative); + return result; +} + +} // namespace protocol::v1 diff --git a/tests/differential/cases.py b/tests/differential/cases.py new file mode 100644 index 0000000..c045d52 --- /dev/null +++ b/tests/differential/cases.py @@ -0,0 +1,200 @@ +"""Stable fixtures and directed cases for differential ledger testing.""" + +from __future__ import annotations + +import hashlib +from dataclasses import dataclass + +from model import ( + MAX_U64, + Account, + Sodium, + account_id, + encode_genesis, + signed_transfer, +) +from protocol_bytes import require + +DEFAULT_RANDOM_SCENARIOS = 10_000 +DEFAULT_SEED = 0x5053444946465631 +SUPPLY_LIMIT = 1_000_000_000_000_000_000 +TOTAL_SUPPLY = 100_000_000_000_000_000 +FIXED_FEE = 1_000 +DIRECTED_SCENARIOS = 11 +MASK_U64 = (1 << 64) - 1 + + +class SplitMix64: + """Repository-specified deterministic generator for corpus stability.""" + + def __init__(self, seed: int) -> None: + self.state = seed & MASK_U64 + + def next_u64(self) -> int: + self.state = (self.state + 0x9E3779B97F4A7C15) & MASK_U64 + value = self.state + value = ( + (value ^ (value >> 30)) * 0xBF58476D1CE4E5B9 + ) & MASK_U64 + value = ( + (value ^ (value >> 27)) * 0x94D049BB133111EB + ) & MASK_U64 + return (value ^ (value >> 31)) & MASK_U64 + + def below(self, upper: int) -> int: + require(0 < upper <= 1 << 64, "invalid random bound") + limit = (1 << 64) - ((1 << 64) % upper) + while True: + value = self.next_u64() + if value < limit: + return value % upper + + def bytes(self, size: int) -> bytes: + require(size >= 0, "negative random byte count") + encoded = bytearray() + while len(encoded) < size: + encoded.extend(self.next_u64().to_bytes(8, "big")) + return bytes(encoded[:size]) + + +def verify_prng() -> None: + generator = SplitMix64(0) + observed = tuple(generator.next_u64() for _ in range(3)) + expected = ( + 0xE220A8397B1DCDAF, + 0x6E789E6AA1B965F4, + 0x06C45D188009454F, + ) + require(observed == expected, "SplitMix64-v1 compatibility vector") + + +@dataclass(frozen=True) +class Fixture: + genesis: bytes + chain_id: bytes + seeds: tuple[bytes, ...] + identifiers: tuple[bytes, ...] + seed_by_identifier: dict[bytes, bytes] + + +def make_fixture(sodium: Sodium) -> Fixture: + seeds = tuple( + hashlib.sha256( + b"protocol-stack:v1:differential-key" + + index.to_bytes(4, "big") + ).digest() + for index in range(12) + ) + identifiers = tuple( + account_id(sodium.keypair(seed)[0]) for seed in seeds + ) + accounts = { + identifier: Account(TOTAL_SUPPLY // 4, 0) + for identifier in identifiers[:4] + } + genesis = encode_genesis( + accounts, SUPPLY_LIMIT, TOTAL_SUPPLY, FIXED_FEE + ) + chain_id = hashlib.sha256( + bytes([len("protocol-stack:v1:chain-id")]) + + b"protocol-stack:v1:chain-id" + + genesis + ).digest() + return Fixture( + genesis, + chain_id, + seeds, + identifiers, + dict(zip(identifiers, seeds, strict=True)), + ) + + +def transfer( + sodium: Sodium, + fixture: Fixture, + seed_index: int, + nonce: int, + recipient_index: int, + amount: int, + fee_limit: int = FIXED_FEE, + valid_until: int = 100, + chain_id: bytes | None = None, +) -> bytes: + return signed_transfer( + sodium, + fixture.seeds[seed_index], + fixture.chain_id if chain_id is None else chain_id, + nonce, + fixture.identifiers[recipient_index], + amount, + fee_limit, + valid_until, + ) + + +def corrupt_signature(raw: bytes) -> bytes: + changed = bytearray(raw) + changed[-1] ^= 1 + return bytes(changed) + + +def directed_blocks( + scenario: int, sodium: Sodium, fixture: Fixture +) -> list[list[bytes]]: + valid = transfer(sodium, fixture, 0, 1, 1, 10_000) + wrong_chain = hashlib.sha256(b"protocol-stack:wrong-chain").digest() + wrong = transfer( + sodium, fixture, 0, 1, 1, 10_000, chain_id=wrong_chain + ) + invalid = corrupt_signature(valid) + malformed = valid[:-1] + + if scenario == 0: + return [[]] + if scenario == 1: + return [[malformed, wrong, invalid]] + if scenario == 2: + return [[ + transfer(sodium, fixture, 0, 99, 1, 0, valid_until=0), + transfer(sodium, fixture, 0, 99, 1, 1, fee_limit=999), + transfer(sodium, fixture, 0, 99, 1, 1, valid_until=0), + transfer(sodium, fixture, 4, 1, 0, 1), + transfer(sodium, fixture, 0, 2, 1, 1), + transfer(sodium, fixture, 0, 1, 1, MAX_U64), + transfer( + sodium, + fixture, + 0, + 1, + 1, + TOTAL_SUPPLY // 4, + ), + ]] + if scenario == 3: + return [[transfer(sodium, fixture, 0, 1, 4, 50_000)]] + if scenario == 4: + return [[transfer(sodium, fixture, 0, 1, 0, 1)]] + if scenario == 5: + return [[valid, valid]] + if scenario == 6: + return [[ + valid, + transfer(sodium, fixture, 0, 2, 0, 1), + ]] + if scenario == 7: + return [[ + transfer(sodium, fixture, 0, 2, 0, 1), + valid, + ]] + if scenario == 8: + return [[ + malformed, + valid, + wrong, + transfer(sodium, fixture, 1, 1, 0, 0), + invalid, + ]] + if scenario == 9: + return [[valid], [valid]] + require(scenario == 10, "unknown directed scenario") + return [[], [valid], []] diff --git a/tests/differential/coverage.py b/tests/differential/coverage.py new file mode 100644 index 0000000..4fdc054 --- /dev/null +++ b/tests/differential/coverage.py @@ -0,0 +1,86 @@ +"""Coverage accounting for directed and randomized differential cases.""" + +from __future__ import annotations + +from dataclasses import dataclass, field + +from model import BlockCommit +from protocol_bytes import require + + +@dataclass +class Coverage: + admission_errors: set[int] = field(default_factory=set) + execution_results: set[int] = field(default_factory=set) + features: set[str] = field(default_factory=set) + blocks: int = 0 + raw_inputs: int = 0 + admitted: int = 0 + + def observe( + self, + raws: list[bytes], + commit: BlockCommit, + seen: dict[bytes, int], + ) -> None: + self.blocks += 1 + self.raw_inputs += len(raws) + self.admitted += len(commit.transactions) + self.admission_errors.update( + value for value in commit.admissions if value != 0 + ) + self.execution_results.update( + execution.result for execution in commit.executions + ) + if not raws: + self.features.add("empty_block") + if raws and all(value != 0 for value in commit.admissions): + self.features.add("all_unadmitted") + + for transaction, execution in zip( + commit.transactions, commit.executions, strict=True + ): + previous_result = seen.get(transaction.transaction_id) + if previous_result == 0 and execution.result == 6: + self.features.add("replay") + seen[transaction.transaction_id] = execution.result + if execution.result == 0 and execution.self_transfer: + self.features.add("self_transfer") + if execution.result == 0 and execution.created_recipient: + self.features.add("recipient_creation") + + for earlier_index, earlier in enumerate(commit.transactions): + later_indexes = range( + earlier_index + 1, len(commit.transactions) + ) + for later_index in later_indexes: + later = commit.transactions[later_index] + if ( + earlier.sender_id == later.sender_id + and earlier.nonce == later.nonce + 1 + and commit.executions[earlier_index].result == 6 + and commit.executions[later_index].result == 0 + ): + self.features.add("ordered_effect") + + def verify(self) -> None: + require( + self.admission_errors == {1, 2, 3}, + f"admission coverage: {sorted(self.admission_errors)}", + ) + require( + self.execution_results == {0, 1, 2, 3, 4, 6, 7, 8}, + f"execution coverage: {sorted(self.execution_results)}", + ) + required_features = { + "all_unadmitted", + "empty_block", + "ordered_effect", + "recipient_creation", + "replay", + "self_transfer", + } + require( + self.features == required_features, + f"feature coverage: {sorted(self.features)}", + ) diff --git a/tests/differential/kernel_runner.cpp b/tests/differential/kernel_runner.cpp new file mode 100644 index 0000000..e8424bd --- /dev/null +++ b/tests/differential/kernel_runner.cpp @@ -0,0 +1,232 @@ +#include "protocol/v1/ledger.hpp" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace { + +namespace pv1 = protocol::v1; + +constexpr std::size_t kMaximumScenarios = 100'000; +constexpr std::size_t kMaximumBlocksPerScenario = 64; +constexpr std::size_t kMaximumInputsPerBlock = 65'535; +constexpr std::size_t kMaximumEncodedObjectBytes = 1'048'576; + +void require(bool condition, std::string_view message) { + if (!condition) { + throw std::runtime_error(std::string(message)); + } +} + +std::string read_token(std::string_view name) { + std::string token; + require(static_cast(std::cin >> token), + std::string("missing ") + std::string(name)); + return token; +} + +std::uint64_t parse_u64(std::string_view value, std::string_view name) { + std::uint64_t parsed = 0; + const auto result = + std::from_chars(value.data(), value.data() + value.size(), parsed); + require(result.ec == std::errc{} && + result.ptr == value.data() + value.size(), + std::string("invalid ") + std::string(name)); + return parsed; +} + +std::size_t parse_count(std::string_view value, std::size_t maximum, + std::string_view name) { + const auto parsed = parse_u64(value, name); + require(parsed <= maximum, std::string(name) + " exceeds test bound"); + require(parsed <= std::numeric_limits::max(), + std::string(name) + " exceeds host size"); + return static_cast(parsed); +} + +std::uint8_t nibble(char value) { + if (value >= '0' && value <= '9') { + return static_cast(value - '0'); + } + if (value >= 'a' && value <= 'f') { + return static_cast(value - 'a' + 10); + } + throw std::runtime_error("invalid lowercase hexadecimal"); +} + +pv1::Bytes decode_hex(std::string_view encoded) { + if (encoded == "-") { + return {}; + } + require(encoded.size() % 2 == 0, "odd hexadecimal length"); + require(encoded.size() / 2 <= kMaximumEncodedObjectBytes, + "encoded object exceeds test bound"); + pv1::Bytes decoded; + decoded.reserve(encoded.size() / 2); + for (std::size_t offset = 0; offset < encoded.size(); offset += 2) { + decoded.push_back(static_cast( + (nibble(encoded[offset]) << 4U) | nibble(encoded[offset + 1]))); + } + return decoded; +} + +template +std::string encode_hex(const ByteRange& bytes) { + constexpr char kHex[] = "0123456789abcdef"; + std::string encoded; + encoded.reserve(bytes.size() * 2); + for (const auto byte : bytes) { + const auto value = static_cast(byte); + encoded.push_back(kHex[value >> 4U]); + encoded.push_back(kHex[value & 0x0FU]); + } + return encoded; +} + +template +void write_list(const Range& values, Encoder encode) { + if (values.empty()) { + std::cout << '-'; + return; + } + bool first = true; + for (const auto& value : values) { + if (!first) { + std::cout << ','; + } + first = false; + std::cout << encode(value); + } +} + +void write_accounts(const pv1::State& state) { + if (state.accounts.empty()) { + std::cout << '-'; + return; + } + bool first = true; + for (const auto& [identifier, account] : state.accounts) { + if (!first) { + std::cout << ','; + } + first = false; + std::cout << encode_hex(identifier) << ':' << account.balance << ':' + << account.nonce; + } +} + +void write_transcript(std::uint64_t scenario, std::size_t block_index, + const pv1::BlockCommit& commit, + const pv1::State& state) { + require(commit.admissions.size() <= kMaximumInputsPerBlock, + "invalid admission transcript"); + require(commit.transaction_ids.size() == commit.receipts.size() && + commit.receipts.size() == commit.encoded_receipts.size(), + "misaligned admitted transcript"); + + std::cout << "D\t" << scenario << '\t' << block_index << '\t' + << commit.height << '\t'; + write_list(commit.admissions, [](const auto& admission) { + return admission ? std::to_string(static_cast(*admission)) + : std::string("0"); + }); + std::cout << '\t'; + write_list(commit.transaction_ids, + [](const auto& identifier) { return encode_hex(identifier); }); + std::cout << '\t'; + write_list(commit.encoded_receipts, + [](const auto& receipt) { return encode_hex(receipt); }); + std::cout << '\t'; + write_list(commit.receipts, [](const auto& receipt) { + return encode_hex(receipt.transaction_id) + ":" + + std::to_string(static_cast(receipt.result)) + ":" + + std::to_string(receipt.fee_charged); + }); + std::cout << '\t' << encode_hex(commit.previous_state_root) << '\t' + << encode_hex(commit.transaction_root) << '\t' + << encode_hex(commit.resulting_state_root) << '\t' + << encode_hex(commit.header) << '\t' + << encode_hex(commit.block_id) << '\t' + << encode_hex(state.parameters.chain_id) << '\t' + << state.parameters.supply_limit << '\t' + << state.parameters.total_supply << '\t' + << state.parameters.fixed_fee << '\t' << state.height << '\t' + << state.fee_pool << '\t'; + write_accounts(state); + std::cout << '\n'; +} + +void run_scenario() { + require(read_token("scenario marker") == "S", "expected scenario marker"); + const auto scenario = parse_u64(read_token("scenario ID"), "scenario ID"); + auto genesis = decode_hex(read_token("genesis")); + const auto block_count = + parse_count(read_token("block count"), kMaximumBlocksPerScenario, + "block count"); + + auto loaded = pv1::load_genesis(genesis); + require(std::holds_alternative(loaded.result), + "generated genesis rejected"); + std::optional ledger; + ledger.emplace(std::get(std::move(loaded.result))); + + for (std::size_t block_index = 0; block_index < block_count; ++block_index) { + require(read_token("block marker") == "B", "expected block marker"); + const auto height = parse_u64(read_token("block height"), "block height"); + const auto raw_count = + parse_count(read_token("raw input count"), kMaximumInputsPerBlock, + "raw input count"); + std::vector raw_transactions; + raw_transactions.reserve(raw_count); + for (std::size_t index = 0; index < raw_count; ++index) { + raw_transactions.push_back(decode_hex(read_token("raw transaction"))); + } + + auto applied = ledger->apply_block(height, raw_transactions); + require(std::holds_alternative(applied), + "generated block rejected"); + const auto& commit = std::get(applied); + auto current_root = ledger->current_state_root(); + require(std::holds_alternative(current_root) && + std::get(current_root) == + commit.resulting_state_root, + "public current state root disagrees with block commit"); + write_transcript(scenario, block_index, commit, ledger->state()); + } +} + +} // namespace + +int main() { + try { + std::ios::sync_with_stdio(false); + std::cin.tie(nullptr); + + require(read_token("protocol magic") == "PSDIFF1", + "unsupported differential protocol"); + const auto scenario_count = + parse_count(read_token("scenario count"), kMaximumScenarios, + "scenario count"); + for (std::size_t index = 0; index < scenario_count; ++index) { + run_scenario(); + } + std::string trailing; + require(!(std::cin >> trailing), "trailing differential request"); + return 0; + } catch (const std::exception& error) { + std::cerr << "kernel differential runner: failed: " << error.what() + << '\n'; + return 1; + } +} diff --git a/tests/differential/model.py b/tests/differential/model.py new file mode 100644 index 0000000..b96004b --- /dev/null +++ b/tests/differential/model.py @@ -0,0 +1,361 @@ +#!/usr/bin/env python3 + +"""Independent version-one ledger model for randomized differential tests.""" + +from __future__ import annotations + +from dataclasses import dataclass + +from pinned_sodium import Sodium +from protocol_bytes import ( + MAX_BLOCK_INPUTS, + MAX_CANONICAL_BYTES, + MAX_GENESIS_ACCOUNTS, + MAX_U64, + account_id, + digest, + domain, + encode_receipt, + merkle, + require, +) + + +@dataclass +class Account: + balance: int + nonce: int + + +@dataclass(frozen=True) +class Transaction: + sender_id: bytes + transaction_id: bytes + nonce: int + recipient: bytes + amount: int + fee_limit: int + valid_until: int + + +@dataclass +class State: + chain_id: bytes + supply_limit: int + total_supply: int + fixed_fee: int + height: int + fee_pool: int + accounts: dict[bytes, Account] + + def clone(self) -> State: + return State( + self.chain_id, + self.supply_limit, + self.total_supply, + self.fixed_fee, + self.height, + self.fee_pool, + { + identifier: Account(account.balance, account.nonce) + for identifier, account in self.accounts.items() + }, + ) + + +@dataclass(frozen=True) +class Execution: + result: int + self_transfer: bool + created_recipient: bool + + +@dataclass +class BlockCommit: + height: int + admissions: list[int] + transactions: list[Transaction] + executions: list[Execution] + encoded_receipts: list[bytes] + previous_state_root: bytes + transaction_root: bytes + resulting_state_root: bytes + header: bytes + block_id: bytes + + +def account_entries(accounts: dict[bytes, Account]) -> list[bytes]: + return [ + identifier + + account.balance.to_bytes(8, "big") + + account.nonce.to_bytes(8, "big") + for identifier, account in sorted(accounts.items()) + ] + + +def state_root(state: State) -> bytes: + require(state.total_supply <= state.supply_limit, "supply limit") + conserved = state.fee_pool + for account in state.accounts.values(): + require(conserved <= MAX_U64 - account.balance, "supply overflow") + conserved += account.balance + require(conserved == state.total_supply, "supply mismatch") + entries = account_entries(state.accounts) + payload = ( + (1).to_bytes(2, "big") + + state.chain_id + + state.height.to_bytes(8, "big") + + state.supply_limit.to_bytes(8, "big") + + state.total_supply.to_bytes(8, "big") + + state.fee_pool.to_bytes(8, "big") + + len(entries).to_bytes(8, "big") + + merkle(entries, "state") + ) + return digest("protocol-stack:v1:state-root", payload) + + +def encode_genesis( + accounts: dict[bytes, Account], + supply_limit: int, + total_supply: int, + fixed_fee: int, + fee_pool: int = 0, +) -> bytes: + entries = account_entries(accounts) + require(0 < len(entries) <= MAX_GENESIS_ACCOUNTS, "genesis count") + require( + all(account.balance > 0 and account.nonce == 0 + for account in accounts.values()), + "invalid genesis account", + ) + encoded = ( + b"PSGN" + + (1).to_bytes(2, "big") + + (1).to_bytes(4, "big") + + supply_limit.to_bytes(8, "big") + + total_supply.to_bytes(8, "big") + + fixed_fee.to_bytes(8, "big") + + fee_pool.to_bytes(8, "big") + + len(entries).to_bytes(4, "big") + + b"".join(entries) + ) + require(len(encoded) <= MAX_CANONICAL_BYTES, "genesis size") + return encoded + + +def decode_genesis(encoded: bytes) -> State: + require(len(encoded) >= 46, "truncated genesis") + require(len(encoded) <= MAX_CANONICAL_BYTES, "oversized genesis") + require(encoded[:6] == b"PSGN\x00\x01", "genesis shape") + require(int.from_bytes(encoded[6:10], "big") == 1, "network") + supply_limit = int.from_bytes(encoded[10:18], "big") + total_supply = int.from_bytes(encoded[18:26], "big") + fixed_fee = int.from_bytes(encoded[26:34], "big") + fee_pool = int.from_bytes(encoded[34:42], "big") + count = int.from_bytes(encoded[42:46], "big") + require(0 < count <= MAX_GENESIS_ACCOUNTS, "genesis count") + require(len(encoded) == 46 + count * 48, "genesis length") + require( + supply_limit > 0 + and 0 < total_supply <= supply_limit + and fixed_fee > 0, + "genesis parameters", + ) + accounts: dict[bytes, Account] = {} + previous: bytes | None = None + for index in range(count): + offset = 46 + index * 48 + identifier = encoded[offset : offset + 32] + balance = int.from_bytes(encoded[offset + 32 : offset + 40], "big") + nonce = int.from_bytes(encoded[offset + 40 : offset + 48], "big") + require( + balance > 0 + and nonce == 0 + and (previous is None or previous < identifier), + "genesis accounts", + ) + accounts[identifier] = Account(balance, nonce) + previous = identifier + state = State( + digest("protocol-stack:v1:chain-id", encoded), + supply_limit, + total_supply, + fixed_fee, + 0, + fee_pool, + accounts, + ) + state_root(state) + return state + + +def signed_transfer( + sodium: Sodium, + seed: bytes, + chain_id: bytes, + nonce: int, + recipient: bytes, + amount: int, + fee_limit: int, + valid_until: int, +) -> bytes: + public_key, _ = sodium.keypair(seed) + unsigned = ( + b"PSTX" + + (1).to_bytes(2, "big") + + b"\x01" + + chain_id + + b"\x01" + + public_key + + nonce.to_bytes(8, "big") + + recipient + + amount.to_bytes(8, "big") + + fee_limit.to_bytes(8, "big") + + valid_until.to_bytes(8, "big") + ) + signature = sodium.sign( + seed, domain("protocol-stack:v1:tx-sign") + unsigned + ) + return unsigned + signature + + +def admit( + sodium: Sodium, raw: bytes, chain_id: bytes +) -> tuple[int, Transaction | None]: + if ( + len(raw) != 200 + or raw[:4] != b"PSTX" + or raw[4:7] != b"\x00\x01\x01" + or raw[39] != 1 + ): + return 1, None + if raw[7:39] != chain_id: + return 2, None + public_key = raw[40:72] + signing_message = domain("protocol-stack:v1:tx-sign") + raw[:136] + if not sodium.verify(public_key, signing_message, raw[136:]): + return 3, None + return 0, Transaction( + account_id(public_key), + digest("protocol-stack:v1:tx-id", raw), + int.from_bytes(raw[72:80], "big"), + raw[80:112], + int.from_bytes(raw[112:120], "big"), + int.from_bytes(raw[120:128], "big"), + int.from_bytes(raw[128:136], "big"), + ) + + +def execute(transaction: Transaction, state: State, height: int) -> Execution: + if transaction.amount == 0: + return Execution(1, False, False) + if transaction.fee_limit < state.fixed_fee: + return Execution(2, False, False) + if transaction.valid_until < height: + return Execution(3, False, False) + sender = state.accounts.get(transaction.sender_id) + if sender is None: + return Execution(4, False, False) + if sender.nonce == MAX_U64: + return Execution(5, False, False) + if transaction.nonce != sender.nonce + 1: + return Execution(6, False, False) + if transaction.amount > MAX_U64 - state.fixed_fee: + return Execution(7, False, False) + debit = transaction.amount + state.fixed_fee + if sender.balance < debit: + return Execution(8, False, False) + + self_transfer = transaction.sender_id == transaction.recipient + created_recipient = ( + not self_transfer and transaction.recipient not in state.accounts + ) + if not self_transfer: + recipient = state.accounts.get(transaction.recipient) + recipient_balance = 0 if recipient is None else recipient.balance + require( + recipient_balance <= MAX_U64 - transaction.amount, + "recipient invariant", + ) + require( + state.fee_pool <= MAX_U64 - state.fixed_fee, + "fee-pool invariant", + ) + if self_transfer: + sender.balance -= state.fixed_fee + else: + recipient = state.accounts.get(transaction.recipient) + sender.balance -= debit + if recipient is None: + state.accounts[transaction.recipient] = Account( + transaction.amount, 0 + ) + else: + recipient.balance += transaction.amount + sender.nonce = transaction.nonce + state.fee_pool += state.fixed_fee + return Execution(0, self_transfer, created_recipient) + + +class ReferenceLedger: + def __init__(self, genesis: bytes, sodium: Sodium) -> None: + self.state = decode_genesis(genesis) + self.sodium = sodium + + def apply_block(self, height: int, raws: list[bytes]) -> BlockCommit: + require(len(raws) <= MAX_BLOCK_INPUTS, "block input count") + require(self.state.height < MAX_U64, "height exhausted") + require(height == self.state.height + 1, "invalid height") + previous_root = state_root(self.state) + tentative = self.state.clone() + admissions: list[int] = [] + transactions: list[Transaction] = [] + executions: list[Execution] = [] + receipts: list[bytes] = [] + for raw in raws: + admission, transaction = admit( + self.sodium, raw, tentative.chain_id + ) + admissions.append(admission) + if transaction is None: + continue + execution = execute(transaction, tentative, height) + transactions.append(transaction) + executions.append(execution) + receipts.append( + encode_receipt( + transaction.transaction_id, + execution.result, + tentative.fixed_fee, + ) + ) + tentative.height = height + resulting_root = state_root(tentative) + transaction_root = merkle( + [transaction.transaction_id for transaction in transactions], + "tx", + ) + header = ( + b"PSBL" + + (1).to_bytes(2, "big") + + tentative.chain_id + + height.to_bytes(8, "big") + + previous_root + + transaction_root + + resulting_root + + len(transactions).to_bytes(4, "big") + ) + commit = BlockCommit( + height, + admissions, + transactions, + executions, + receipts, + previous_root, + transaction_root, + resulting_root, + header, + digest("protocol-stack:v1:block-id", header), + ) + self.state = tentative + return commit diff --git a/tests/differential/pinned_sodium.py b/tests/differential/pinned_sodium.py new file mode 100644 index 0000000..4b0e31d --- /dev/null +++ b/tests/differential/pinned_sodium.py @@ -0,0 +1,99 @@ +"""Minimal ctypes binding to the repository-pinned Ed25519 provider.""" + +from __future__ import annotations + +import ctypes +import ctypes.util +import os + +from protocol_bytes import require + + +class Sodium: + """Expose only the Ed25519 operations used by the differential harness.""" + + def __init__(self, library_name: str | None = None) -> None: + selected = ( + library_name + or os.environ.get("PROTOCOL_STACK_LIBSODIUM") + or ctypes.util.find_library("sodium") + ) + require(selected is not None, "libsodium runtime not found") + self.library = ctypes.CDLL(selected) + self.library.sodium_init.restype = ctypes.c_int + self.library.sodium_version_string.restype = ctypes.c_char_p + require(self.library.sodium_init() >= 0, "libsodium init failed") + require( + self.library.sodium_version_string().decode("ascii") == "1.0.22", + "differential model requires pinned libsodium 1.0.22", + ) + self.library.crypto_sign_seed_keypair.argtypes = ( + ctypes.c_void_p, + ctypes.c_void_p, + ctypes.c_void_p, + ) + self.library.crypto_sign_seed_keypair.restype = ctypes.c_int + self.library.crypto_sign_detached.argtypes = ( + ctypes.c_void_p, + ctypes.POINTER(ctypes.c_ulonglong), + ctypes.c_void_p, + ctypes.c_ulonglong, + ctypes.c_void_p, + ) + self.library.crypto_sign_detached.restype = ctypes.c_int + self.library.crypto_sign_verify_detached.argtypes = ( + ctypes.c_void_p, + ctypes.c_void_p, + ctypes.c_ulonglong, + ctypes.c_void_p, + ) + self.library.crypto_sign_verify_detached.restype = ctypes.c_int + self._keypairs: dict[bytes, tuple[bytes, bytes]] = {} + + def keypair(self, seed: bytes) -> tuple[bytes, bytes]: + require(len(seed) == 32, "Ed25519 seed size") + cached = self._keypairs.get(seed) + if cached is not None: + return cached + public_key = ctypes.create_string_buffer(32) + secret_key = ctypes.create_string_buffer(64) + require( + self.library.crypto_sign_seed_keypair( + public_key, secret_key, seed + ) + == 0, + "Ed25519 key derivation failed", + ) + result = (public_key.raw, secret_key.raw) + self._keypairs[seed] = result + return result + + def sign(self, seed: bytes, message: bytes) -> bytes: + _, secret_key = self.keypair(seed) + signature = ctypes.create_string_buffer(64) + signature_size = ctypes.c_ulonglong() + require( + self.library.crypto_sign_detached( + signature, + ctypes.byref(signature_size), + message, + len(message), + secret_key, + ) + == 0 + and signature_size.value == 64, + "Ed25519 signing failed", + ) + return signature.raw + + def verify( + self, public_key: bytes, message: bytes, signature: bytes + ) -> bool: + if len(public_key) != 32 or len(signature) != 64: + return False + return ( + self.library.crypto_sign_verify_detached( + signature, message, len(message), public_key + ) + == 0 + ) diff --git a/tests/differential/protocol_bytes.py b/tests/differential/protocol_bytes.py new file mode 100644 index 0000000..20a28af --- /dev/null +++ b/tests/differential/protocol_bytes.py @@ -0,0 +1,55 @@ +"""Canonical byte and hash helpers for the independent Python model.""" + +from __future__ import annotations + +import hashlib + +MAX_U64 = (1 << 64) - 1 +MAX_GENESIS_ACCOUNTS = 21_844 +MAX_CANONICAL_BYTES = 1_048_576 +MAX_BLOCK_INPUTS = 65_535 + + +def require(condition: bool, message: str) -> None: + if not condition: + raise ValueError(message) + + +def domain(label: str) -> bytes: + encoded = label.encode("ascii") + require(len(encoded) < 256, "domain label too long") + return bytes([len(encoded)]) + encoded + + +def digest(label: str, payload: bytes = b"") -> bytes: + return hashlib.sha256(domain(label) + payload).digest() + + +def merkle(leaves: list[bytes], kind: str) -> bytes: + prefix = f"protocol-stack:v1:{kind}" + if not leaves: + return digest(f"{prefix}-empty") + if len(leaves) == 1: + return digest(f"{prefix}-leaf", leaves[0]) + split = 1 << ((len(leaves) - 1).bit_length() - 1) + return digest( + f"{prefix}-node", + merkle(leaves[:split], kind) + merkle(leaves[split:], kind), + ) + + +def account_id(public_key: bytes) -> bytes: + require(len(public_key) == 32, "public key size") + return digest("protocol-stack:v1:account", b"\x01" + public_key) + + +def encode_receipt( + transaction_id: bytes, result: int, fixed_fee: int +) -> bytes: + return ( + b"PSRC" + + (1).to_bytes(2, "big") + + transaction_id + + bytes([result]) + + (fixed_fee if result == 0 else 0).to_bytes(8, "big") + ) diff --git a/tests/differential/random_cases.py b/tests/differential/random_cases.py new file mode 100644 index 0000000..54e48df --- /dev/null +++ b/tests/differential/random_cases.py @@ -0,0 +1,199 @@ +"""Seeded random sequence generation for differential ledger testing.""" + +from __future__ import annotations + +import hashlib + +from cases import Fixture, SplitMix64, corrupt_signature +from model import ( + MAX_U64, + Account, + ReferenceLedger, + Sodium, + State, + account_id, + admit, + execute, + signed_transfer, +) +from protocol_bytes import require + + +def choose_sender( + rng: SplitMix64, state: State, fixture: Fixture, funded: bool +) -> tuple[bytes, bytes, Account]: + candidates = [ + (identifier, fixture.seed_by_identifier[identifier], account) + for identifier, account in state.accounts.items() + if identifier in fixture.seed_by_identifier + and (not funded or account.balance > state.fixed_fee) + ] + require(bool(candidates), "no generated sender candidate") + return candidates[rng.below(len(candidates))] + + +def absent_seed( + rng: SplitMix64, + state: State, + fixture: Fixture, + sodium: Sodium, +) -> bytes: + candidates = [ + fixture.seed_by_identifier[identifier] + for identifier in fixture.identifiers + if identifier not in state.accounts + ] + if candidates: + return candidates[rng.below(len(candidates))] + seed = hashlib.sha256( + b"protocol-stack:v1:differential-absent" + + rng.bytes(32) + ).digest() + require( + account_id(sodium.keypair(seed)[0]) not in state.accounts, + "derived absent sender collision", + ) + return seed + + +def make_random_raw( + rng: SplitMix64, + scenario: int, + input_index: int, + height: int, + planning: State, + sodium: Sodium, + fixture: Fixture, + replay_pool: list[bytes], +) -> bytes: + category = rng.below(15) + sender_id, seed, sender = choose_sender( + rng, planning, fixture, funded=category in {0, 1, 2, 13} + ) + nonce = sender.nonce + 1 + recipient = fixture.identifiers[rng.below(len(fixture.identifiers))] + maximum_amount = ( + sender.balance - planning.fixed_fee + if category in {0, 1, 2, 13} + else sender.balance + ) + amount = max(1, min(1 + rng.below(1_000_000), maximum_amount)) + fee_limit = planning.fixed_fee + valid_until = height + rng.below(6) + chain_id = planning.chain_id + + if category == 1: + recipient = sender_id + amount = 1 + elif category == 2: + missing = [ + identifier + for identifier in fixture.identifiers + if identifier not in planning.accounts + ] + if missing: + recipient = missing[rng.below(len(missing))] + elif category == 3: + amount = 0 + elif category == 4: + fee_limit = planning.fixed_fee - 1 + elif category == 5: + valid_until = height - 1 + elif category == 6: + seed = absent_seed(rng, planning, fixture, sodium) + nonce = 1 + elif category == 7: + nonce += 1 + elif category == 8: + amount = MAX_U64 + elif category == 9: + amount = sender.balance + elif category == 11: + chain_id = hashlib.sha256( + scenario.to_bytes(8, "big") + + input_index.to_bytes(8, "big") + + b"wrong-chain" + ).digest() + elif category == 14 and replay_pool: + return replay_pool[rng.below(len(replay_pool))] + + raw = signed_transfer( + sodium, + seed, + chain_id, + nonce, + recipient, + amount, + fee_limit, + valid_until, + ) + if category == 10: + mutation = rng.below(5) + if mutation == 0: + return raw[:-1] + if mutation == 1: + return raw + b"\x00" + changed = bytearray(raw) + if mutation == 2: + changed[0] ^= 1 + elif mutation == 3: + changed[6] = 2 + else: + changed[39] = 2 + return bytes(changed) + if category == 12: + return corrupt_signature(raw) + return raw + + +def random_blocks( + rng: SplitMix64, + scenario: int, + count: int, + ledger: ReferenceLedger, + sodium: Sodium, + fixture: Fixture, +) -> list[list[bytes]]: + blocks: list[list[bytes]] = [] + replay_pool: list[bytes] = [] + has_raw_input = False + for block_index in range(count): + height = ledger.state.height + 1 + planning = ledger.state.clone() + raws: list[bytes] = [] + raw_count = rng.below(7) + if block_index + 1 == count and not has_raw_input and raw_count == 0: + raw_count = 1 + for input_index in range(raw_count): + raw = make_random_raw( + rng, + scenario, + input_index, + height, + planning, + sodium, + fixture, + replay_pool, + ) + raws.append(raw) + admission, transaction = admit( + sodium, raw, planning.chain_id + ) + if admission == 0 and transaction is not None: + execution = execute(transaction, planning, height) + if execution.result == 0: + replay_pool.append(raw) + has_raw_input |= bool(raws) + planning.height = height + ledger.state = planning + blocks.append(raws) + require(has_raw_input, "randomized sequence has no raw input") + return blocks + + +def scenario_rng(seed: int, scenario: int) -> SplitMix64: + derived = hashlib.sha256( + seed.to_bytes(32, "big", signed=False) + + scenario.to_bytes(8, "big") + ).digest() + return SplitMix64(int.from_bytes(derived[:8], "big")) diff --git a/tests/differential/run.py b/tests/differential/run.py new file mode 100644 index 0000000..821e897 --- /dev/null +++ b/tests/differential/run.py @@ -0,0 +1,166 @@ +#!/usr/bin/env python3 + +"""Generate seeded ledger sequences and compare the Python and C++ kernels.""" + +from __future__ import annotations + +import argparse +import subprocess +import sys +from pathlib import Path + +from cases import ( + DEFAULT_RANDOM_SCENARIOS, + DEFAULT_SEED, + DIRECTED_SCENARIOS, + directed_blocks, + make_fixture, + verify_prng, +) +from coverage import Coverage +from model import ReferenceLedger +from pinned_sodium import Sodium +from protocol_bytes import require +from random_cases import random_blocks, scenario_rng +from transcript import compare, format_block + + +def run( + runner: Path, + random_count: int, + seed: int, + library_name: str | None, +) -> Coverage: + require(random_count >= 0, "random scenario count cannot be negative") + require(0 <= seed < (1 << 256), "seed must fit 256 bits") + require(runner.is_file(), f"runner not found: {runner}") + verify_prng() + sodium = Sodium(library_name) + fixture = make_fixture(sodium) + coverage = Coverage() + randomized_coverage = Coverage() + total_count = DIRECTED_SCENARIOS + random_count + require(total_count <= 100_000, "scenario count exceeds runner bound") + request = [f"PSDIFF1 {total_count}"] + expected: list[str] = [] + + for scenario in range(total_count): + reference = ReferenceLedger(fixture.genesis, sodium) + if scenario < DIRECTED_SCENARIOS: + blocks = directed_blocks(scenario, sodium, fixture) + else: + rng = scenario_rng(seed, scenario) + block_count = 1 + rng.below(3) + generation = ReferenceLedger(fixture.genesis, sodium) + blocks = random_blocks( + rng, scenario, block_count, generation, sodium, fixture + ) + request.append( + f"S {scenario} {fixture.genesis.hex()} {len(blocks)}" + ) + seen: dict[bytes, int] = {} + randomized_seen: dict[bytes, int] = {} + for block_index, raws in enumerate(blocks): + height = reference.state.height + 1 + encoded_raws = [ + raw.hex() if raw else "-" for raw in raws + ] + request.append( + " ".join( + ["B", str(height), str(len(raws)), *encoded_raws] + ) + ) + commit = reference.apply_block(height, raws) + coverage.observe(raws, commit, seen) + if scenario >= DIRECTED_SCENARIOS: + randomized_coverage.observe( + raws, commit, randomized_seen + ) + expected.append( + format_block( + scenario, block_index, commit, reference.state + ) + ) + + coverage.verify() + require( + randomized_coverage.blocks >= random_count, + "randomized block coverage below sequence count", + ) + require( + randomized_coverage.raw_inputs >= random_count, + "randomized transaction coverage below sequence count", + ) + if random_count >= 1_000: + randomized_coverage.verify() + completed = subprocess.run( + [str(runner)], + input="\n".join(request) + "\n", + text=True, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + check=False, + ) + require( + completed.returncode == 0, + "C++ differential runner failed:\n" + completed.stderr, + ) + require( + not completed.stderr, + "C++ differential runner wrote stderr:\n" + completed.stderr, + ) + compare(expected, completed.stdout) + return coverage + + +def parse_integer(value: str) -> int: + return int(value, 0) + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("runner", type=Path, help="kernel runner executable") + parser.add_argument( + "--count", + type=int, + default=DEFAULT_RANDOM_SCENARIOS, + help=( + "seeded randomized sequences, in addition to directed coverage " + f"(default: {DEFAULT_RANDOM_SCENARIOS})" + ), + ) + parser.add_argument( + "--seed", + type=parse_integer, + default=DEFAULT_SEED, + help=f"root seed (default: {DEFAULT_SEED:#x})", + ) + parser.add_argument( + "--libsodium", + help="path to the pinned libsodium 1.0.22 shared library", + ) + arguments = parser.parse_args() + coverage = run( + arguments.runner.resolve(), + arguments.count, + arguments.seed, + arguments.libsodium, + ) + print( + "kernel differential: passed " + f"{arguments.count} seeded randomized sequences plus " + f"{DIRECTED_SCENARIOS} directed sequences, {coverage.blocks} blocks, " + f"{coverage.raw_inputs} raw inputs, {coverage.admitted} admitted; " + "admission=1,2,3 execution=0,1,2,3,4,6,7,8 " + "(nonce-exhausted remains unit-boundary-only); " + f"prng=splitmix64-v1 seed={arguments.seed:#x}" + ) + return 0 + + +if __name__ == "__main__": + try: + sys.exit(main()) + except (OSError, ValueError) as error: + print(f"kernel differential: failed: {error}", file=sys.stderr) + sys.exit(1) diff --git a/tests/differential/transcript.py b/tests/differential/transcript.py new file mode 100644 index 0000000..1282a87 --- /dev/null +++ b/tests/differential/transcript.py @@ -0,0 +1,84 @@ +"""Typed transcript formatting and comparison for the C++ runner protocol.""" + +from __future__ import annotations + +from model import BlockCommit, State + + +def format_block( + scenario: int, + block_index: int, + commit: BlockCommit, + state: State, +) -> str: + def joined(values: list[str]) -> str: + return ",".join(values) if values else "-" + + accounts = joined( + [ + f"{identifier.hex()}:{account.balance}:{account.nonce}" + for identifier, account in sorted(state.accounts.items()) + ] + ) + fields = [ + "D", + str(scenario), + str(block_index), + str(commit.height), + joined([str(value) for value in commit.admissions]), + joined( + [ + transaction.transaction_id.hex() + for transaction in commit.transactions + ] + ), + joined([receipt.hex() for receipt in commit.encoded_receipts]), + joined( + [ + f"{transaction.transaction_id.hex()}:" + f"{execution.result}:" + f"{state.fixed_fee if execution.result == 0 else 0}" + for transaction, execution in zip( + commit.transactions, commit.executions, strict=True + ) + ] + ), + commit.previous_state_root.hex(), + commit.transaction_root.hex(), + commit.resulting_state_root.hex(), + commit.header.hex(), + commit.block_id.hex(), + state.chain_id.hex(), + str(state.supply_limit), + str(state.total_supply), + str(state.fixed_fee), + str(state.height), + str(state.fee_pool), + accounts, + ] + return "\t".join(fields) + + +def compare(expected: list[str], actual_text: str) -> None: + actual = actual_text.splitlines() + if expected == actual: + return + limit = max(len(expected), len(actual)) + mismatch = next( + ( + index + for index in range(limit) + if index >= len(expected) + or index >= len(actual) + or expected[index] != actual[index] + ), + 0, + ) + expected_line = ( + expected[mismatch] if mismatch < len(expected) else "" + ) + actual_line = actual[mismatch] if mismatch < len(actual) else "" + raise ValueError( + f"transcript mismatch at block record {mismatch}\n" + f"expected: {expected_line}\nactual: {actual_line}" + ) diff --git a/tests/fuzz/address_fuzz.cpp b/tests/fuzz/address_fuzz.cpp new file mode 100644 index 0000000..a27bf00 --- /dev/null +++ b/tests/fuzz/address_fuzz.cpp @@ -0,0 +1,84 @@ +#include "protocol/v1/address.hpp" + +#include +#include +#include +#include +#include +#include +#include + +namespace p = protocol::v1; + +namespace { + +constexpr std::size_t kMaximumRawSize = 256; +constexpr std::size_t kMaximumMutations = 256; +constexpr std::string_view kHrpCharacters = + "abcdefghijklmnopqrstuvwxyz0123456789"; + +void require_deterministic(std::string_view address, + std::string_view hrp) { + const auto first = p::decode_address(address, hrp); + const auto second = p::decode_address(address, hrp); + if (first != second) std::abort(); +} + +std::string_view selected_hrp(const std::uint8_t* data, std::size_t size, + std::array& storage) { + if (size == 0) return "psdev"; + const auto length = static_cast(data[0] % storage.size()) + 1; + for (std::size_t index = 0; index < length; ++index) { + storage[index] = + kHrpCharacters[data[index % size] % kHrpCharacters.size()]; + } + return {storage.data(), length}; +} + +p::AccountId selected_account(const std::uint8_t* data, std::size_t size) { + p::Hash identifier{}; + const auto copied = std::min(size, identifier.size()); + if (copied != 0) { + std::copy_n(data, copied, identifier.begin()); + } + return p::AccountId{identifier}; +} + +std::string require_valid_round_trip(const p::AccountId& account_id, + std::string_view hrp) { + const auto first_encoding = p::encode_address(account_id, hrp); + const auto second_encoding = p::encode_address(account_id, hrp); + if (!first_encoding || first_encoding != second_encoding) std::abort(); + + require_deterministic(*first_encoding, hrp); + const auto decoded = p::decode_address(*first_encoding, hrp); + if (!decoded || *decoded != account_id) std::abort(); + + const auto canonical = p::encode_address(*decoded, hrp); + if (!canonical || *canonical != *first_encoding) std::abort(); + return *first_encoding; +} + +} // namespace + +extern "C" int LLVMFuzzerTestOneInput(const std::uint8_t* data, + std::size_t size) { + const auto bounded_size = std::min(size, kMaximumRawSize); + const char* characters = + bounded_size == 0 ? "" : reinterpret_cast(data); + std::array hrp_storage{}; + const auto hrp = selected_hrp(data, bounded_size, hrp_storage); + require_deterministic({characters, bounded_size}, hrp); + + auto structured = + require_valid_round_trip(selected_account(data, bounded_size), hrp); + const auto mutations = + std::min(bounded_size / 2, kMaximumMutations); + for (std::size_t index = 0; index < mutations; ++index) { + const auto offset = static_cast(data[index * 2]) % + structured.size(); + structured[offset] = static_cast(data[index * 2 + 1]); + } + require_deterministic(structured, hrp); + return 0; +} diff --git a/tests/fuzz/admission_fuzz.cpp b/tests/fuzz/admission_fuzz.cpp new file mode 100644 index 0000000..9dcf21a --- /dev/null +++ b/tests/fuzz/admission_fuzz.cpp @@ -0,0 +1,108 @@ +#include "protocol/v1/admission.hpp" + +#include + +#include +#include +#include +#include +#include +#include +#include +#include + +namespace p = protocol::v1; + +namespace { + +p::ChainId selected_chain(std::span input) { + p::Hash bytes{}; + if (input.size() == 200 && (input.back() & 1U) != 0) { + std::copy_n(input.begin() + 7, bytes.size(), bytes.begin()); + } + return p::ChainId{bytes}; +} + +void require_deterministic(std::span input, + const p::ChainId& chain_id) { + const auto first = p::admit_transfer(input, chain_id); + const auto second = p::admit_transfer(input, chain_id); + if (first.index() != second.index()) std::abort(); + if (const auto* first_error = std::get_if(&first)) { + const auto* second_error = std::get_if(&second); + if (second_error == nullptr || *first_error != *second_error) { + std::abort(); + } + return; + } + const auto& first_transfer = std::get(first); + const auto& second_transfer = std::get(second); + if (first_transfer.sender_id != second_transfer.sender_id || + first_transfer.transaction_id != second_transfer.transaction_id || + first_transfer.nonce != second_transfer.nonce || + first_transfer.recipient != second_transfer.recipient || + first_transfer.amount != second_transfer.amount || + first_transfer.fee_limit != second_transfer.fee_limit || + first_transfer.valid_until != second_transfer.valid_until) { + std::abort(); + } +} + +void require_valid_structured(std::span input) { + std::array seed{}; + std::copy_n(input.begin(), std::min(input.size(), seed.size()), + seed.begin()); + std::array public_key{}; + std::array secret_key{}; + if (sodium_init() < 0 || + crypto_sign_seed_keypair(public_key.data(), secret_key.data(), + seed.data()) != 0) { + std::abort(); + } + + p::Bytes shaped(200); + const auto copied = std::min(input.size(), shaped.size()); + if (copied != 0) { + std::copy_n(input.begin(), copied, shaped.begin()); + } + shaped[0] = 'P'; + shaped[1] = 'S'; + shaped[2] = 'T'; + shaped[3] = 'X'; + shaped[4] = 0; + shaped[5] = 1; + shaped[6] = 1; + shaped[39] = 1; + std::copy(public_key.begin(), public_key.end(), shaped.begin() + 40); + + constexpr std::string_view label = "protocol-stack:v1:tx-sign"; + p::Bytes message{static_cast(label.size())}; + message.insert(message.end(), label.begin(), label.end()); + message.insert(message.end(), shaped.begin(), shaped.begin() + 136); + unsigned long long signature_size = 0; + if (crypto_sign_detached(shaped.data() + 136, &signature_size, + message.data(), message.size(), + secret_key.data()) != 0 || + signature_size != crypto_sign_BYTES) { + std::abort(); + } + + p::Hash chain_bytes{}; + std::copy_n(shaped.begin() + 7, chain_bytes.size(), + chain_bytes.begin()); + const p::ChainId chain_id{chain_bytes}; + require_deterministic(shaped, chain_id); + const auto admission = + p::admit_transfer(shaped, chain_id); + if (!std::holds_alternative(admission)) std::abort(); +} + +} // namespace + +extern "C" int LLVMFuzzerTestOneInput(const std::uint8_t* data, + std::size_t size) { + const std::span input{data, size}; + require_deterministic(input, selected_chain(input)); + require_valid_structured(input); + return 0; +} diff --git a/tests/fuzz/genesis_fuzz.cpp b/tests/fuzz/genesis_fuzz.cpp new file mode 100644 index 0000000..f9d38d0 --- /dev/null +++ b/tests/fuzz/genesis_fuzz.cpp @@ -0,0 +1,76 @@ +#include "protocol/v1/ledger.hpp" + +#include "../../tools/protocol-vectors/vector_common.hpp" + +#include +#include +#include +#include +#include +#include + +namespace pv = protocol_vectors; +namespace p = protocol::v1; + +namespace { + +p::Bytes minimal_genesis() { + p::Bytes encoded{'P', 'S', 'G', 'N', 0, 1, 0, 0, 0, 1}; + pv::append_u64(encoded, 1'000); + pv::append_u64(encoded, 1'000); + pv::append_u64(encoded, 1); + pv::append_u64(encoded, 0); + encoded.insert(encoded.end(), {0, 0, 0, 1}); + encoded.push_back(1); + encoded.resize(encoded.size() + 31); + pv::append_u64(encoded, 1'000); + pv::append_u64(encoded, 0); + return encoded; +} + +void require_deterministic(std::span input, + bool require_success = false) { + auto first = p::load_genesis(input).result; + auto second = p::load_genesis(input).result; + if (first.index() != second.index()) std::abort(); + if (const auto* first_error = std::get_if(&first)) { + if (require_success) std::abort(); + const auto* second_error = std::get_if(&second); + if (second_error == nullptr || *first_error != *second_error) { + std::abort(); + } + return; + } + const auto& first_ledger = std::get(first); + const auto& second_ledger = std::get(second); + const auto first_root = first_ledger.current_state_root(); + const auto second_root = second_ledger.current_state_root(); + if (!std::holds_alternative(first_root) || + !std::holds_alternative(second_root) || + first_ledger.state() != second_ledger.state() || + first_root != second_root) { + std::abort(); + } +} + +} // namespace + +extern "C" int LLVMFuzzerTestOneInput(const std::uint8_t* data, + std::size_t size) { + const std::span input{data, size}; + require_deterministic(input); + + const auto baseline = minimal_genesis(); + require_deterministic(baseline, true); + auto structured = baseline; + constexpr std::size_t kMaximumMutations = 256; + const auto mutations = + std::min(size / 2, kMaximumMutations); + for (std::size_t index = 0; index < mutations; ++index) { + const auto offset = static_cast(data[index * 2]) % + structured.size(); + structured[offset] = data[index * 2 + 1]; + } + require_deterministic(structured); + return 0; +} diff --git a/tests/kernel/admission_test.cpp b/tests/kernel/admission_test.cpp new file mode 100644 index 0000000..c5eb6c1 --- /dev/null +++ b/tests/kernel/admission_test.cpp @@ -0,0 +1,53 @@ +#include "protocol/v1/admission.hpp" + +#include "../../tools/protocol-vectors/vector_common.hpp" + +#include +#include +#include + +namespace pv = protocol_vectors; +namespace p = protocol::v1; + +template +Tagged tagged_hash(const pv::Bytes& bytes) { + pv::require(bytes.size() == 32, "hash size"); + p::Hash result{}; + std::copy(bytes.begin(), bytes.end(), result.begin()); + return Tagged{result}; +} + +void verify_admission_vectors(const pv::Values& values) { + const auto chain_id = + tagged_hash(pv::hex_decode(values.at("chain_id"))); + const auto raw_count = std::stoull(values.at("raw_count")); + for (std::size_t index = 0; index < raw_count; ++index) { + const auto key = "raw" + std::to_string(index); + const auto raw = pv::hex_decode(values.at(key)); + const auto admission = p::admit_transfer(raw, chain_id); + const auto expected = std::stoull(values.at(key + ".admission")); + if (expected == 0) { + pv::require(std::holds_alternative(admission), + "expected admitted transfer"); + } else { + pv::require( + std::holds_alternative(admission) && + static_cast( + std::get(admission)) == expected, + "admission error mismatch"); + } + } +} + +int main(int argc, char** argv) { + try { + pv::require(argc == 2, "usage: kernel_admission_test VECTOR_FILE"); + pv::require(sodium_init() >= 0, "libsodium initialization"); + verify_admission_vectors(pv::load_values(argv[1])); + std::cout << "Kernel admission vectors: passed\n"; + return 0; + } catch (const std::exception& error) { + std::cerr << "Kernel admission vectors: failed: " << error.what() << '\n'; + return 1; + } +} diff --git a/tests/kernel/block_test.cpp b/tests/kernel/block_test.cpp new file mode 100644 index 0000000..d13972a --- /dev/null +++ b/tests/kernel/block_test.cpp @@ -0,0 +1,520 @@ +#include "protocol/v1/admission.hpp" +#include "protocol/v1/ledger.hpp" + +#include "../../src/v1/execution.hpp" +#include "../../tools/protocol-vectors/vector_common.hpp" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace pv = protocol_vectors; +namespace p = protocol::v1; + +namespace { + +template +Tagged tagged_hash(const pv::Bytes& bytes, std::size_t offset = 0) { + pv::require(offset + 32 <= bytes.size(), "tagged hash size"); + p::Hash raw{}; + std::copy_n(bytes.begin() + offset, raw.size(), raw.begin()); + return Tagged{raw}; +} + +template +void append_hash(pv::Bytes& target, const Tagged& value) { + target.insert(target.end(), value.begin(), value.end()); +} + +std::vector raw_transactions(const pv::Values& values) { + const auto count = std::stoull(values.at("raw_count")); + std::vector result; + result.reserve(count); + for (std::size_t index = 0; index < count; ++index) { + result.push_back( + pv::hex_decode(values.at("raw" + std::to_string(index)))); + } + return result; +} + +p::Ledger load_ledger(const p::Bytes& genesis) { + auto loaded = p::load_genesis(genesis); + pv::require(std::holds_alternative(loaded.result), + "expected loaded ledger"); + return std::get(std::move(loaded.result)); +} + +p::Ledger load_frozen_ledger(const pv::Values& values) { + return load_ledger(pv::hex_decode(values.at("genesis"))); +} + +void require_genesis_error(const p::Bytes& genesis, + p::GenesisError expected, + std::string_view message) { + const auto loaded = p::load_genesis(genesis); + pv::require(std::holds_alternative(loaded.result), + message); + pv::require(std::get(loaded.result) == expected, + message); +} + +p::StateRoot require_current_root(const p::Ledger& ledger) { + const auto root = ledger.current_state_root(); + pv::require(std::holds_alternative(root), + "expected current state root"); + return std::get(root); +} + +p::BlockCommit require_commit(p::Ledger& ledger, std::uint64_t height, + const std::vector& transactions, + std::string_view message) { + auto result = ledger.apply_block(height, transactions); + pv::require(std::holds_alternative(result), message); + return std::get(std::move(result)); +} + +void require_block_error(p::Ledger& ledger, std::uint64_t height, + const std::vector& transactions, + p::BlockError expected, + std::string_view message) { + const auto before = ledger.state(); + const auto result = ledger.apply_block(height, transactions); + pv::require(std::holds_alternative(result), message); + pv::require(std::get(result) == expected, message); + pv::require(ledger.state() == before, "block rejection atomicity"); +} + +pv::Bytes account_entry(const p::AccountId& identifier, + const p::Account& account) { + pv::Bytes encoded; + append_hash(encoded, identifier); + pv::append_u64(encoded, account.balance); + pv::append_u64(encoded, account.nonce); + return encoded; +} + +void verify_frozen_state(const p::State& state, + const pv::Values& values) { + pv::require(state.height == 1, "frozen resulting height"); + pv::require(state.fee_pool == std::stoull(values.at("fee_pool")), + "frozen resulting fee pool"); + pv::require(state.accounts.size() == + std::stoull(values.at("final_account_count")), + "frozen resulting account count"); + std::size_t index = 0; + for (const auto& [identifier, account] : state.accounts) { + pv::require( + account_entry(identifier, account) == + pv::hex_decode( + values.at("final.account" + std::to_string(index))), + "frozen resulting account"); + ++index; + } +} + +void verify_frozen_block(const pv::Values& values) { + auto ledger = load_frozen_ledger(values); + const auto expected_previous = + tagged_hash( + pv::hex_decode(values.at("previous_state_root"))); + pv::require(require_current_root(ledger) == expected_previous, + "public genesis state root"); + + const auto transactions = raw_transactions(values); + const auto commit = + require_commit(ledger, 1, transactions, "frozen block rejected"); + pv::require(commit.height == 1, "frozen block height"); + pv::require(commit.admissions.size() == transactions.size(), + "raw-aligned admission count"); + + std::size_t admitted_index = 0; + for (std::size_t raw_index = 0; raw_index < transactions.size(); + ++raw_index) { + const auto raw_key = "raw" + std::to_string(raw_index); + const auto expected_admission = + std::stoull(values.at(raw_key + ".admission")); + if (expected_admission != 0) { + pv::require( + commit.admissions[raw_index] == + std::optional( + static_cast(expected_admission)), + "raw-order admission error"); + continue; + } + pv::require(!commit.admissions[raw_index], + "raw-order admitted marker"); + const auto receipt_key = + "receipt" + std::to_string(admitted_index); + const auto expected_receipt = + pv::hex_decode(values.at(receipt_key)); + const auto expected_id = + tagged_hash(expected_receipt, 6); + pv::require(commit.transaction_ids[admitted_index] == expected_id, + "admitted-order transaction ID"); + pv::require( + commit.receipts[admitted_index] == + p::Receipt{ + expected_id, + static_cast(expected_receipt[38]), + pv::read_u64(expected_receipt, 39), + }, + "admitted-order typed receipt"); + pv::require(commit.encoded_receipts[admitted_index] == + expected_receipt, + "admitted-order encoded receipt"); + ++admitted_index; + } + const auto expected_admitted = + std::stoull(values.at("admitted_count")); + pv::require(admitted_index == expected_admitted, + "frozen admitted count"); + pv::require(commit.transaction_ids.size() == expected_admitted && + commit.receipts.size() == expected_admitted && + commit.encoded_receipts.size() == expected_admitted, + "admitted output alignment"); + + const auto expected_transaction_root = + tagged_hash( + pv::hex_decode(values.at("transaction_root"))); + const auto expected_resulting = + tagged_hash( + pv::hex_decode(values.at("resulting_state_root"))); + pv::require(commit.previous_state_root == expected_previous, + "frozen previous state root"); + pv::require(commit.transaction_root == expected_transaction_root, + "frozen transaction root"); + pv::require(commit.resulting_state_root == expected_resulting, + "frozen resulting state root"); + pv::require(commit.header == + pv::hex_decode(values.at("block_header")), + "frozen block header"); + pv::require( + commit.block_id == + tagged_hash(pv::hex_decode(values.at("block_id"))), + "frozen block ID"); + pv::require(require_current_root(ledger) == expected_resulting, + "public committed state root"); + verify_frozen_state(ledger.state(), values); +} + +void zero_u64(p::Bytes& bytes, std::size_t offset) { + std::fill_n(bytes.begin() + offset, 8, 0); +} + +void verify_public_genesis_errors(const pv::Values& values) { + const auto frozen = pv::hex_decode(values.at("genesis")); + + auto malformed = frozen; + malformed.pop_back(); + require_genesis_error(malformed, p::GenesisError::malformed, + "public malformed genesis"); + + auto unsupported = frozen; + unsupported[9] = 2; + require_genesis_error(unsupported, + p::GenesisError::unsupported_network, + "public unsupported network"); + + auto invalid_parameters = frozen; + zero_u64(invalid_parameters, 26); + require_genesis_error(invalid_parameters, + p::GenesisError::invalid_parameters, + "public invalid parameters"); + + auto invalid_accounts = frozen; + zero_u64(invalid_accounts, 46 + 32); + require_genesis_error(invalid_accounts, + p::GenesisError::invalid_accounts, + "public invalid accounts"); + + auto invalid_supply = frozen; + invalid_supply[46 + 39] ^= 1U; + require_genesis_error(invalid_supply, + p::GenesisError::invalid_supply, + "public invalid supply"); +} + +void verify_block_boundaries(const pv::Values& values) { + const std::vector no_transactions; + + auto invalid_height = load_frozen_ledger(values); + const auto invalid_height_root = require_current_root(invalid_height); + require_block_error(invalid_height, 0, no_transactions, + p::BlockError::invalid_height, + "current height accepted"); + require_block_error(invalid_height, 2, no_transactions, + p::BlockError::invalid_height, + "skipped height accepted"); + pv::require(require_current_root(invalid_height) == invalid_height_root, + "invalid height root atomicity"); + + auto exhausted = load_frozen_ledger(values); + auto& exhausted_state = const_cast(exhausted.state()); + exhausted_state.height = std::numeric_limits::max(); + require_block_error(exhausted, + std::numeric_limits::max(), + no_transactions, p::BlockError::height_exhausted, + "height exhaustion not rejected"); + + auto invalid_state = load_frozen_ledger(values); + auto& corrupted = const_cast(invalid_state.state()); + --corrupted.parameters.total_supply; + const auto invalid_root = invalid_state.current_state_root(); + pv::require(std::holds_alternative(invalid_root) && + std::get(invalid_root) == + p::BlockError::invalid_state, + "invalid current state root"); + require_block_error(invalid_state, 1, no_transactions, + p::BlockError::invalid_state, + "invalid state not rejected"); + + auto too_many_ledger = load_frozen_ledger(values); + const std::vector too_many(65'536); + require_block_error(too_many_ledger, 1, too_many, + p::BlockError::too_many_inputs, + "oversized input list accepted"); + + auto maximum_ledger = load_frozen_ledger(values); + const std::vector maximum(65'535); + const auto maximum_commit = + require_commit(maximum_ledger, 1, maximum, + "maximum input list rejected"); + pv::require(maximum_commit.admissions.size() == maximum.size(), + "maximum admission count"); + pv::require( + std::all_of( + maximum_commit.admissions.begin(), + maximum_commit.admissions.end(), + [](const auto& error) { + return error == + std::optional( + p::AdmissionError::malformed_transaction); + }), + "maximum admission alignment"); + pv::require(maximum_commit.transaction_ids.empty() && + maximum_commit.receipts.empty() && + maximum_commit.encoded_receipts.empty(), + "maximum malformed outputs"); + + auto precedence = load_frozen_ledger(values); + auto& precedence_state = const_cast(precedence.state()); + --precedence_state.parameters.total_supply; + precedence_state.height = std::numeric_limits::max(); + require_block_error(precedence, 0, too_many, + p::BlockError::too_many_inputs, + "input bound precedence"); + require_block_error(precedence, 0, no_transactions, + p::BlockError::invalid_state, + "invalid state precedence"); + + auto height_precedence = load_frozen_ledger(values); + auto& maximum_height = + const_cast(height_precedence.state()); + maximum_height.height = std::numeric_limits::max(); + require_block_error(height_precedence, 0, no_transactions, + p::BlockError::height_exhausted, + "height exhaustion precedence"); +} + +void verify_empty_and_unadmitted_blocks(const pv::Values& values) { + const std::vector empty; + auto empty_ledger = load_frozen_ledger(values); + const auto empty_commit = + require_commit(empty_ledger, 1, empty, "empty block rejected"); + pv::require(empty_commit.admissions.empty() && + empty_commit.transaction_ids.empty() && + empty_commit.receipts.empty() && + empty_commit.encoded_receipts.empty(), + "empty block outputs"); + pv::require(empty_ledger.state().height == 1 && + empty_ledger.state().fee_pool == 0, + "empty block height-only transition"); + + const auto all_raw = raw_transactions(values); + const std::vector unadmitted{ + p::Bytes{}, + all_raw.at(11), + all_raw.at(13), + }; + auto unadmitted_ledger = load_frozen_ledger(values); + const auto unadmitted_commit = + require_commit(unadmitted_ledger, 1, unadmitted, + "all-unadmitted block rejected"); + const std::vector> expected{ + p::AdmissionError::malformed_transaction, + p::AdmissionError::invalid_signature, + p::AdmissionError::wrong_chain, + }; + pv::require(unadmitted_commit.admissions == expected, + "all-unadmitted raw alignment"); + pv::require(unadmitted_commit.transaction_ids.empty() && + unadmitted_commit.receipts.empty() && + unadmitted_commit.encoded_receipts.empty(), + "all-unadmitted output omission"); + pv::require(unadmitted_ledger.state() == empty_ledger.state(), + "admission failures changed state"); + pv::require(unadmitted_commit.transaction_root == + empty_commit.transaction_root && + unadmitted_commit.resulting_state_root == + empty_commit.resulting_state_root && + unadmitted_commit.header == empty_commit.header && + unadmitted_commit.block_id == empty_commit.block_id, + "admission failures changed commitments"); +} + +void verify_duplicates_and_ordering(const pv::Values& values) { + const auto raw = raw_transactions(values); + + auto duplicate_ledger = load_frozen_ledger(values); + const std::vector duplicates{raw.at(0), raw.at(0)}; + const auto duplicate_commit = + require_commit(duplicate_ledger, 1, duplicates, + "duplicate block rejected"); + pv::require(duplicate_commit.admissions == + std::vector>(2), + "duplicate admissions"); + pv::require(duplicate_commit.transaction_ids.size() == 2 && + duplicate_commit.transaction_ids[0] == + duplicate_commit.transaction_ids[1], + "duplicate transaction IDs not retained"); + pv::require( + duplicate_commit.encoded_receipts == + std::vector{ + pv::hex_decode(values.at("receipt0")), + pv::hex_decode(values.at("receipt1")), + }, + "duplicate ordered receipts"); + pv::require(duplicate_ledger.state().fee_pool == 1'000, + "duplicate replay charged"); + + auto forward_ledger = load_frozen_ledger(values); + auto reverse_ledger = load_frozen_ledger(values); + const std::vector forward{raw.at(0), raw.at(2)}; + const std::vector reverse{raw.at(2), raw.at(0)}; + const auto forward_commit = + require_commit(forward_ledger, 1, forward, + "forward-order block rejected"); + const auto reverse_commit = + require_commit(reverse_ledger, 1, reverse, + "reverse-order block rejected"); + pv::require(forward_commit.receipts[0].result == + p::TransferResult::success && + forward_commit.receipts[1].result == + p::TransferResult::success, + "forward ordered execution"); + pv::require(reverse_commit.receipts[0].result == + p::TransferResult::nonce_mismatch && + reverse_commit.receipts[1].result == + p::TransferResult::success, + "reverse ordered execution"); + pv::require(forward_ledger.state() != reverse_ledger.state() && + forward_commit.transaction_root != + reverse_commit.transaction_root && + forward_commit.resulting_state_root != + reverse_commit.resulting_state_root && + forward_commit.block_id != reverse_commit.block_id, + "transaction order not committed"); +} + +void verify_tentative_failure_atomicity(const pv::Values& values) { + auto ledger = load_frozen_ledger(values); + const auto original = ledger.state(); + auto tentative = original; + const auto raw = raw_transactions(values); + const auto admitted = + p::admit_transfer(raw.at(0), tentative.parameters.chain_id); + pv::require(std::holds_alternative(admitted), + "tentative success admission"); + const auto first = p::internal::execute_transfer( + std::get(admitted), tentative, 1); + pv::require(std::holds_alternative(first) && + std::get(first).result == + p::TransferResult::success, + "tentative first success"); + pv::require(tentative != original, "tentative state did not change"); + + auto sender = tentative.accounts.begin(); + auto recipient = std::next(sender); + sender->second = + p::Account{tentative.parameters.fixed_fee + 1, 0}; + recipient->second.balance = + std::numeric_limits::max(); + const p::Transfer overflow{ + sender->first, + p::TransactionId{}, + 1, + recipient->first, + 1, + tentative.parameters.fixed_fee, + 1, + }; + const auto before_failure = tentative; + const auto failed = + p::internal::execute_transfer(overflow, tentative, 1); + pv::require( + std::holds_alternative(failed) && + std::get(failed) == + p::internal::ExecutionError::recipient_balance_overflow, + "tentative invariant failure"); + pv::require(tentative == before_failure, + "tentative invariant failure not atomic"); + pv::require(ledger.state() == original, + "tentative processing changed ledger"); +} + +void verify_determinism(const pv::Values& values) { + auto first_ledger = load_frozen_ledger(values); + auto second_ledger = load_frozen_ledger(values); + const auto transactions = raw_transactions(values); + const auto first = + require_commit(first_ledger, 1, transactions, + "first deterministic block rejected"); + const auto second = + require_commit(second_ledger, 1, transactions, + "second deterministic block rejected"); + pv::require(first_ledger.state() == second_ledger.state(), + "deterministic state"); + pv::require( + first.height == second.height && + first.admissions == second.admissions && + first.transaction_ids == second.transaction_ids && + first.receipts == second.receipts && + first.encoded_receipts == second.encoded_receipts && + first.previous_state_root == second.previous_state_root && + first.transaction_root == second.transaction_root && + first.resulting_state_root == second.resulting_state_root && + first.header == second.header && + first.block_id == second.block_id, + "deterministic block outputs"); +} + +} // namespace + +int main(int argc, char** argv) { + try { + pv::require(argc == 2, "usage: kernel_block_test VECTOR_FILE"); + pv::require(sodium_init() >= 0, "libsodium initialization"); + const auto values = pv::load_values(argv[1]); + verify_frozen_block(values); + verify_public_genesis_errors(values); + verify_block_boundaries(values); + verify_empty_and_unadmitted_blocks(values); + verify_duplicates_and_ordering(values); + verify_tentative_failure_atomicity(values); + verify_determinism(values); + std::cout << "Kernel block tests: passed\n"; + return 0; + } catch (const std::exception& error) { + std::cerr << "Kernel block tests: failed: " << error.what() << '\n'; + return 1; + } +} diff --git a/tests/kernel/commitments_test.cpp b/tests/kernel/commitments_test.cpp new file mode 100644 index 0000000..e03773d --- /dev/null +++ b/tests/kernel/commitments_test.cpp @@ -0,0 +1,372 @@ +#include "../../src/v1/commitments.hpp" +#include "../../tools/protocol-vectors/vector_common.hpp" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace pv = protocol_vectors; +namespace p = protocol::v1; +namespace pc = protocol::v1::internal; + +namespace { + +template +Tagged tagged_hash(const pv::Bytes& bytes, std::size_t offset = 0) { + pv::require(offset + 32 <= bytes.size(), "hash size"); + p::Hash result{}; + std::copy_n(bytes.begin() + offset, result.size(), result.begin()); + return Tagged{result}; +} + +template +pv::Bytes bytes(const Tagged& value) { + return {value.begin(), value.end()}; +} + +template +To retag(const From& value) { + p::Hash raw{}; + std::copy(value.begin(), value.end(), raw.begin()); + return To{raw}; +} + +std::pair decode_account( + std::string_view encoded) { + const auto entry = pv::hex_decode(encoded); + pv::require(entry.size() == 48, "account entry size"); + return { + tagged_hash(entry), + p::Account{pv::read_u64(entry, 32), pv::read_u64(entry, 40)}, + }; +} + +std::map load_accounts( + const pv::Values& values, std::string_view prefix, std::size_t count) { + std::map accounts; + for (std::size_t index = 0; index < count; ++index) { + const auto key = std::string(prefix) + std::to_string(index); + pv::require(accounts.emplace(decode_account(values.at(key))).second, + "duplicate account"); + } + return accounts; +} + +std::size_t genesis_account_count(const pv::Values& values) { + std::size_t count = 0; + while (values.find("genesis.account" + std::to_string(count)) != + values.end()) { + ++count; + } + return count; +} + +p::Parameters parameters(const pv::Values& values) { + return p::Parameters{ + tagged_hash( + pv::hex_decode(values.at("chain_id"))), + std::stoull(values.at("supply_limit")), + std::stoull(values.at("total_supply")), + std::stoull(values.at("fixed_fee")), + }; +} + +p::State initial_state(const pv::Values& values) { + return p::State{ + parameters(values), + 0, + 0, + load_accounts(values, "genesis.account", genesis_account_count(values)), + }; +} + +p::State final_state(const pv::Values& values) { + const auto count = std::stoull(values.at("final_account_count")); + return p::State{ + parameters(values), + 1, + std::stoull(values.at("fee_pool")), + load_accounts(values, "final.account", count), + }; +} + +std::vector account_entries(const p::State& state) { + std::vector entries; + entries.reserve(state.accounts.size()); + for (const auto& [identifier, account] : state.accounts) { + auto entry = bytes(identifier); + pv::append_u64(entry, account.balance); + pv::append_u64(entry, account.nonce); + entries.push_back(std::move(entry)); + } + return entries; +} + +p::StateRoot expected_state_root(const p::State& state) { + const auto entries = account_entries(state); + pv::Bytes payload; + pv::append_u16(payload, 1); + pv::append(payload, bytes(state.parameters.chain_id)); + pv::append_u64(payload, state.height); + pv::append_u64(payload, state.parameters.supply_limit); + pv::append_u64(payload, state.parameters.total_supply); + pv::append_u64(payload, state.fee_pool); + pv::append_u64(payload, entries.size()); + pv::append(payload, pv::merkle(entries, "state")); + return tagged_hash( + pv::hash("protocol-stack:v1:state-root", payload)); +} + +p::StateRoot require_state_root(const p::State& state) { + const auto commitment = pc::state_root(state); + pv::require(std::holds_alternative(commitment), + "expected state root"); + return std::get(commitment); +} + +void require_state_error(const p::State& state, pc::StateError expected) { + const auto commitment = pc::state_root(state); + pv::require(std::holds_alternative(commitment), + "expected state error"); + pv::require(std::get(commitment) == expected, + "state error"); +} + +void verify_final_entries(const p::State& state, const pv::Values& values) { + const auto entries = account_entries(state); + pv::require(entries.size() == + std::stoull(values.at("final_account_count")), + "final entry count"); + for (std::size_t index = 0; index < entries.size(); ++index) { + pv::require(entries[index] == + pv::hex_decode(values.at("final.account" + + std::to_string(index))), + "final account entry"); + } +} + +std::vector verify_receipts( + const pv::Values& values) { + const auto count = std::stoull(values.at("admitted_count")); + const auto fixed_fee = std::stoull(values.at("fixed_fee")); + std::vector transaction_ids; + transaction_ids.reserve(count); + for (std::size_t index = 0; index < count; ++index) { + const auto key = "receipt" + std::to_string(index); + const auto expected = pv::hex_decode(values.at(key)); + pv::require(expected.size() == 47, "receipt vector size"); + const auto transaction_id = + tagged_hash(expected, 6); + const p::Receipt receipt{ + transaction_id, + static_cast(expected[38]), + pv::read_u64(expected, 39), + }; + const auto encoded = pc::encode_receipt(receipt, fixed_fee); + pv::require(encoded.has_value() && *encoded == expected, + "receipt encoding"); + transaction_ids.push_back(transaction_id); + } + return transaction_ids; +} + +void verify_frozen_commitments(const pv::Values& values) { + const auto previous_state = initial_state(values); + const auto resulting_state = final_state(values); + const auto previous_root = require_state_root(previous_state); + const auto resulting_root = require_state_root(resulting_state); + pv::require(previous_root == + tagged_hash( + pv::hex_decode(values.at("previous_state_root"))), + "previous state root"); + pv::require(resulting_root == + tagged_hash( + pv::hex_decode(values.at("resulting_state_root"))), + "resulting state root"); + verify_final_entries(resulting_state, values); + + const auto transaction_ids = verify_receipts(values); + const auto tx_root = pc::transaction_root(transaction_ids); + pv::require(tx_root == + tagged_hash( + pv::hex_decode(values.at("transaction_root"))), + "transaction root"); + const auto header = pc::encode_block_header( + previous_state.parameters.chain_id, 1, previous_root, tx_root, + resulting_root, static_cast(transaction_ids.size())); + pv::require(header.size() == 146, "block header size"); + pv::require(header == pv::hex_decode(values.at("block_header")), + "block header"); + const auto encoded_block_id = pc::block_id(header); + pv::require(encoded_block_id && + *encoded_block_id == + tagged_hash( + pv::hex_decode(values.at("block_id"))), + "block ID"); +} + +std::vector sample_ids(std::size_t count = 5) { + std::vector ids(count); + for (std::size_t index = 0; index < ids.size(); ++index) { + for (std::size_t offset = 0; offset < ids[index].size(); ++offset) { + ids[index].data()[offset] = + static_cast((index + 1) * 17 + offset); + } + } + return ids; +} + +p::TransactionRoot expected_transaction_root( + std::span ids) { + std::vector encoded; + encoded.reserve(ids.size()); + for (const auto& identifier : ids) encoded.push_back(bytes(identifier)); + return tagged_hash(pv::merkle(encoded, "tx")); +} + +void verify_merkle_shapes() { + const auto ids = sample_ids(65'535); + constexpr std::array counts{ + 0, 1, 2, 3, 4, 5, 7, 8, 9, 15, 16, 17, 65'535}; + for (const auto count : counts) { + const auto view = + std::span(ids).first(count); + pv::require(pc::transaction_root(view) == + expected_transaction_root(view), + "transaction Merkle shape"); + } + + auto reordered = sample_ids(); + std::swap(reordered[1], reordered[3]); + const auto first_five = + std::span(ids).first(5); + pv::require(pc::transaction_root(first_five) != + pc::transaction_root(reordered), + "transaction ordering"); + auto mutated = sample_ids(); + mutated[2].data()[7] ^= 1U; + pv::require(pc::transaction_root(first_five) != + pc::transaction_root(mutated), + "transaction mutation"); + + for (std::size_t count = 0; count <= 5; ++count) { + p::State state{ + p::Parameters{retag(ids.front()), 100, count + 1, 1}, + 7, + 1, + {}, + }; + for (std::size_t index = count; index > 0; --index) { + state.accounts.emplace( + retag(ids[index - 1]), + p::Account{1, index - 1}); + } + pv::require(require_state_root(state) == expected_state_root(state), + "state Merkle shape"); + } +} + +void verify_state_errors() { + auto ids = sample_ids(); + p::State state{ + p::Parameters{retag(ids.front()), 100, 10, 1}, + 0, + 0, + {{retag(ids[1]), p::Account{10, 0}}}, + }; + + auto invalid = state; + invalid.parameters.supply_limit = 0; + require_state_error(invalid, pc::StateError::invalid_parameters); + invalid = state; + invalid.parameters.total_supply = 0; + require_state_error(invalid, pc::StateError::supply_mismatch); + invalid = state; + invalid.parameters.fixed_fee = 0; + require_state_error(invalid, pc::StateError::invalid_parameters); + invalid = state; + invalid.parameters.supply_limit = 9; + require_state_error(invalid, pc::StateError::invalid_parameters); + + auto overflow = state; + overflow.parameters.supply_limit = + std::numeric_limits::max(); + overflow.parameters.total_supply = + std::numeric_limits::max(); + overflow.fee_pool = std::numeric_limits::max(); + overflow.accounts.begin()->second.balance = 1; + require_state_error(overflow, pc::StateError::supply_overflow); + + auto mismatch = state; + mismatch.accounts.begin()->second.balance = 9; + require_state_error(mismatch, pc::StateError::supply_mismatch); +} + +void verify_invalid_receipts(const pv::Values& values) { + const auto fixed_fee = std::stoull(values.at("fixed_fee")); + const p::TransactionId transaction_id{}; + pv::require( + !pc::encode_receipt( + p::Receipt{transaction_id, static_cast(9), 0}, + fixed_fee) + .has_value(), + "unknown receipt result"); + pv::require( + !pc::encode_receipt( + p::Receipt{transaction_id, p::TransferResult::success, + fixed_fee - 1}, + fixed_fee) + .has_value(), + "invalid successful fee"); + pv::require( + !pc::encode_receipt( + p::Receipt{transaction_id, p::TransferResult::zero_amount, 1}, + fixed_fee) + .has_value(), + "invalid failed fee"); + + auto header = pc::encode_block_header( + p::ChainId{}, 1, p::StateRoot{}, p::TransactionRoot{}, + p::StateRoot{}, 0); + header.pop_back(); + pv::require(!pc::block_id(header), "short block header"); + header = pc::encode_block_header( + p::ChainId{}, 1, p::StateRoot{}, p::TransactionRoot{}, + p::StateRoot{}, 0); + header[0] = 'X'; + pv::require(!pc::block_id(header), "block header magic"); + header = pc::encode_block_header( + p::ChainId{}, 1, p::StateRoot{}, p::TransactionRoot{}, + p::StateRoot{}, 0); + header[5] = 2; + pv::require(!pc::block_id(header), "block header version"); +} + +} // namespace + +int main(int argc, char** argv) { + try { + pv::require(argc == 2, "usage: kernel_commitments_test VECTOR_FILE"); + pv::require(sodium_init() >= 0, "libsodium initialization"); + const auto values = pv::load_values(argv[1]); + verify_frozen_commitments(values); + verify_merkle_shapes(); + verify_state_errors(); + verify_invalid_receipts(values); + std::cout << "Kernel commitment vectors: passed\n"; + return 0; + } catch (const std::exception& error) { + std::cerr << "Kernel commitment vectors: failed: " << error.what() + << '\n'; + return 1; + } +} diff --git a/tests/kernel/execution_test.cpp b/tests/kernel/execution_test.cpp new file mode 100644 index 0000000..34992ca --- /dev/null +++ b/tests/kernel/execution_test.cpp @@ -0,0 +1,348 @@ +#include "protocol/v1/admission.hpp" + +#include "../../src/v1/execution.hpp" +#include "../../tools/protocol-vectors/vector_common.hpp" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace pv = protocol_vectors; +namespace p = protocol::v1; + +namespace { + +constexpr std::uint64_t kBlockHeight = 1; + +template +Tagged tagged_hash(const pv::Bytes& bytes, std::size_t offset = 0) { + pv::require(offset + 32 <= bytes.size(), "hash size"); + p::Hash result{}; + std::copy_n(bytes.begin() + offset, result.size(), result.begin()); + return Tagged{result}; +} + +template +void append_hash(pv::Bytes& target, const Tagged& value) { + target.insert(target.end(), value.begin(), value.end()); +} + +std::pair decode_account( + std::string_view encoded) { + const auto bytes = pv::hex_decode(encoded); + pv::require(bytes.size() == 48, "account entry size"); + return { + tagged_hash(bytes), + p::Account{pv::read_u64(bytes, 32), pv::read_u64(bytes, 40)}, + }; +} + +p::State initial_state(const pv::Values& values) { + std::map accounts; + for (std::size_t index = 0;; ++index) { + const auto entry = values.find("genesis.account" + std::to_string(index)); + if (entry == values.end()) break; + pv::require(accounts.emplace(decode_account(entry->second)).second, + "duplicate genesis account"); + } + pv::require(!accounts.empty(), "missing genesis accounts"); + const auto genesis = pv::hex_decode(values.at("genesis")); + pv::require(genesis.size() >= 42, "truncated genesis"); + return p::State{ + p::Parameters{ + tagged_hash( + pv::hex_decode(values.at("chain_id"))), + std::stoull(values.at("supply_limit")), + std::stoull(values.at("total_supply")), + std::stoull(values.at("fixed_fee")), + }, + 0, + pv::read_u64(genesis, 34), + std::move(accounts), + }; +} + +pv::Bytes state_bytes(const p::State& state) { + pv::Bytes result; + append_hash(result, state.parameters.chain_id); + pv::append_u64(result, state.parameters.supply_limit); + pv::append_u64(result, state.parameters.total_supply); + pv::append_u64(result, state.parameters.fixed_fee); + pv::append_u64(result, state.height); + pv::append_u64(result, state.fee_pool); + pv::append_u64(result, state.accounts.size()); + for (const auto& [identifier, account] : state.accounts) { + append_hash(result, identifier); + pv::append_u64(result, account.balance); + pv::append_u64(result, account.nonce); + } + return result; +} + +void require_conservation(const p::State& state) { + auto sum = state.fee_pool; + for (const auto& [identifier, account] : state.accounts) { + static_cast(identifier); + pv::require( + account.balance <= std::numeric_limits::max() - sum, + "conservation sum overflow"); + sum += account.balance; + } + pv::require(sum == state.parameters.total_supply, "supply conservation"); + pv::require(state.parameters.total_supply <= state.parameters.supply_limit, + "supply limit"); +} + +void verify_receipt(const p::Receipt& receipt, const p::Transfer& transfer, + const pv::Values& values, std::size_t receipt_index) { + const auto key = "receipt" + std::to_string(receipt_index); + const auto expected_bytes = pv::hex_decode(values.at(key)); + pv::require(expected_bytes.size() == 47, "receipt vector size"); + const auto expected_code = std::stoull(values.at(key + ".result")); + pv::require(expected_bytes[38] == expected_code, "receipt result vector"); + pv::require(receipt.transaction_id == transfer.transaction_id, + "receipt transaction ID"); + pv::require(receipt.transaction_id == + tagged_hash(expected_bytes, 6), + "receipt transaction ID vector"); + pv::require(static_cast(receipt.result) == expected_code, + "transfer result"); + pv::require(receipt.fee_charged == pv::read_u64(expected_bytes, 39), + "receipt fee vector"); + const auto expected_fee = + receipt.result == p::TransferResult::success + ? values.at("fixed_fee") + : std::string("0"); + pv::require(receipt.fee_charged == std::stoull(expected_fee), + "receipt fee"); +} + +void verify_success(const p::State& before, const p::State& after, + const p::Transfer& transfer) { + const auto fixed_fee = before.parameters.fixed_fee; + const auto sender_before = before.accounts.at(transfer.sender_id); + const auto sender_after = after.accounts.at(transfer.sender_id); + pv::require(after.fee_pool == before.fee_pool + fixed_fee, + "successful fee routing"); + pv::require(sender_after.nonce == transfer.nonce, + "successful nonce advancement"); + if (transfer.sender_id == transfer.recipient) { + pv::require(sender_after.balance == sender_before.balance - fixed_fee, + "self-transfer balance"); + pv::require(after.accounts.size() == before.accounts.size(), + "self-transfer account count"); + return; + } + + const auto recipient_before = before.accounts.find(transfer.recipient); + const auto recipient_after = after.accounts.find(transfer.recipient); + pv::require(recipient_after != after.accounts.end(), "recipient exists"); + pv::require(sender_after.balance == + sender_before.balance - transfer.amount - fixed_fee, + "sender debit"); + if (recipient_before == before.accounts.end()) { + pv::require(recipient_after->second == + p::Account{transfer.amount, 0}, + "created recipient"); + pv::require(after.accounts.size() == before.accounts.size() + 1, + "recipient creation count"); + } else { + pv::require(recipient_after->second.balance == + recipient_before->second.balance + transfer.amount, + "recipient credit"); + pv::require(recipient_after->second.nonce == + recipient_before->second.nonce, + "recipient nonce"); + } +} + +void verify_final_state(const p::State& state, const pv::Values& values) { + pv::require(state.height == 0, "single transfer does not advance height"); + pv::require(state.fee_pool == std::stoull(values.at("fee_pool")), + "final fee pool"); + pv::require(state.accounts.size() == + std::stoull(values.at("final_account_count")), + "final account count"); + std::size_t index = 0; + for (const auto& [identifier, account] : state.accounts) { + pv::Bytes entry; + append_hash(entry, identifier); + pv::append_u64(entry, account.balance); + pv::append_u64(entry, account.nonce); + pv::require( + entry == pv::hex_decode( + values.at("final.account" + std::to_string(index))), + "final account entry"); + ++index; + } + require_conservation(state); +} + +void verify_nonce_exhaustion(const pv::Values& values, + std::array& results_seen) { + auto state = initial_state(values); + auto sender = state.accounts.begin(); + const auto recipient = std::next(sender); + sender->second.nonce = std::numeric_limits::max(); + const p::Transfer transfer{ + sender->first, + p::TransactionId{}, + 0, + recipient->first, + 1, + state.parameters.fixed_fee, + kBlockHeight, + }; + const auto before = state_bytes(state); + const auto execution = + p::internal::execute_transfer(transfer, state, kBlockHeight); + pv::require(std::holds_alternative(execution), + "nonce exhaustion receipt"); + const auto& receipt = std::get(execution); + pv::require(receipt.result == p::TransferResult::nonce_exhausted, + "nonce exhaustion result"); + pv::require(receipt.fee_charged == 0, "nonce exhaustion fee"); + pv::require(state_bytes(state) == before, "nonce exhaustion atomicity"); + require_conservation(state); + results_seen[static_cast( + p::TransferResult::nonce_exhausted)] = true; +} + +void require_execution_error(const p::internal::Execution& execution, + p::internal::ExecutionError expected, + std::string_view message) { + pv::require(std::holds_alternative(execution), + message); + pv::require(std::get(execution) == expected, + message); +} + +void verify_internal_overflow_atomicity(const pv::Values& values) { + auto recipient_state = initial_state(values); + auto sender = recipient_state.accounts.begin(); + auto recipient = std::next(sender); + sender->second = p::Account{recipient_state.parameters.fixed_fee + 1, 0}; + recipient->second.balance = std::numeric_limits::max(); + const p::Transfer recipient_overflow{ + sender->first, + p::TransactionId{}, + 1, + recipient->first, + 1, + recipient_state.parameters.fixed_fee, + kBlockHeight, + }; + const auto recipient_before = state_bytes(recipient_state); + require_execution_error( + p::internal::execute_transfer(recipient_overflow, recipient_state, + kBlockHeight), + p::internal::ExecutionError::recipient_balance_overflow, + "recipient overflow error"); + pv::require(state_bytes(recipient_state) == recipient_before, + "recipient overflow atomicity"); + + auto fee_state = initial_state(values); + fee_state.fee_pool = std::numeric_limits::max(); + const auto fee_sender = fee_state.accounts.begin(); + const p::Transfer fee_overflow{ + fee_sender->first, + p::TransactionId{}, + 1, + fee_sender->first, + 1, + fee_state.parameters.fixed_fee, + kBlockHeight, + }; + const auto fee_before = state_bytes(fee_state); + require_execution_error( + p::internal::execute_transfer(fee_overflow, fee_state, kBlockHeight), + p::internal::ExecutionError::fee_pool_overflow, + "fee-pool overflow error"); + pv::require(state_bytes(fee_state) == fee_before, + "fee-pool overflow atomicity"); +} + +void verify_frozen_sequence(const pv::Values& values) { + auto state = initial_state(values); + require_conservation(state); + std::array results_seen{}; + bool saw_self_transfer = false; + bool saw_recipient_creation = false; + std::size_t receipt_index = 0; + const auto raw_count = std::stoull(values.at("raw_count")); + for (std::size_t raw_index = 0; raw_index < raw_count; ++raw_index) { + const auto key = "raw" + std::to_string(raw_index); + const auto admission = + p::admit_transfer(pv::hex_decode(values.at(key)), + state.parameters.chain_id); + const auto expected_admission = std::stoull(values.at(key + ".admission")); + if (expected_admission != 0) { + pv::require(std::holds_alternative(admission), + "expected admission failure"); + continue; + } + pv::require(std::holds_alternative(admission), + "expected admitted transfer"); + const auto& transfer = std::get(admission); + const auto before = state; + const auto before_bytes = state_bytes(state); + const auto recipient_missing = + state.accounts.find(transfer.recipient) == state.accounts.end(); + const auto execution = + p::internal::execute_transfer(transfer, state, kBlockHeight); + pv::require(std::holds_alternative(execution), + "frozen transfer receipt"); + const auto& receipt = std::get(execution); + verify_receipt(receipt, transfer, values, receipt_index); + const auto result_index = static_cast(receipt.result); + pv::require(result_index < results_seen.size(), "known transfer result"); + results_seen[result_index] = true; + if (receipt.result == p::TransferResult::success) { + verify_success(before, state, transfer); + saw_self_transfer |= transfer.sender_id == transfer.recipient; + saw_recipient_creation |= + transfer.sender_id != transfer.recipient && recipient_missing; + } else { + pv::require(state_bytes(state) == before_bytes, + "failed transfer atomicity"); + } + require_conservation(state); + ++receipt_index; + } + + pv::require(receipt_index == std::stoull(values.at("admitted_count")), + "admitted receipt count"); + verify_nonce_exhaustion(values, results_seen); + pv::require(std::all_of(results_seen.begin(), results_seen.end(), + [](bool seen) { return seen; }), + "all transfer results covered"); + pv::require(saw_self_transfer, "self-transfer covered"); + pv::require(saw_recipient_creation, "recipient creation covered"); + verify_final_state(state, values); +} + +} // namespace + +int main(int argc, char** argv) { + try { + pv::require(argc == 2, "usage: kernel_execution_test VECTOR_FILE"); + pv::require(sodium_init() >= 0, "libsodium initialization"); + const auto values = pv::load_values(argv[1]); + verify_frozen_sequence(values); + verify_internal_overflow_atomicity(values); + std::cout << "Kernel execution vectors: passed\n"; + return 0; + } catch (const std::exception& error) { + std::cerr << "Kernel execution vectors: failed: " << error.what() << '\n'; + return 1; + } +} diff --git a/tests/kernel/genesis_test.cpp b/tests/kernel/genesis_test.cpp new file mode 100644 index 0000000..ffb9d2f --- /dev/null +++ b/tests/kernel/genesis_test.cpp @@ -0,0 +1,262 @@ +#include "../../src/v1/genesis.hpp" +#include "../../tools/protocol-vectors/vector_common.hpp" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace pv = protocol_vectors; +namespace p = protocol::v1; + +namespace { + +constexpr std::size_t kGenesisPrefixSize = 46; +constexpr std::size_t kAccountSize = 48; +constexpr std::uint32_t kMaximumAccounts = 21'844; + +struct GenesisAccount { + p::AccountId identifier; + std::uint64_t balance; + std::uint64_t nonce; +}; + +void append_u32(p::Bytes& target, std::uint32_t value) { + for (int shift = 24; shift >= 0; shift -= 8) { + target.push_back(static_cast(value >> shift)); + } +} + +template +void append_hash(p::Bytes& target, const Tagged& value) { + target.insert(target.end(), value.begin(), value.end()); +} + +p::AccountId identifier(std::uint64_t value) { + p::Hash result{}; + for (std::size_t index = 0; index < 8; ++index) { + result[result.size() - 1 - index] = + static_cast(value >> (index * 8U)); + } + return p::AccountId{result}; +} + +p::Bytes genesis_prefix(std::uint64_t supply_limit, + std::uint64_t total_supply, + std::uint64_t fixed_fee, + std::uint64_t initial_fee_pool, + std::uint32_t account_count) { + p::Bytes encoded{'P', 'S', 'G', 'N'}; + pv::append_u16(encoded, 1); + append_u32(encoded, 1); + pv::append_u64(encoded, supply_limit); + pv::append_u64(encoded, total_supply); + pv::append_u64(encoded, fixed_fee); + pv::append_u64(encoded, initial_fee_pool); + append_u32(encoded, account_count); + pv::require(encoded.size() == kGenesisPrefixSize, "genesis prefix size"); + return encoded; +} + +p::Bytes encode_genesis(std::uint64_t supply_limit, + std::uint64_t total_supply, + std::uint64_t fixed_fee, + std::uint64_t initial_fee_pool, + const std::vector& accounts) { + auto encoded = genesis_prefix( + supply_limit, total_supply, fixed_fee, initial_fee_pool, + static_cast(accounts.size())); + for (const auto& account : accounts) { + append_hash(encoded, account.identifier); + pv::append_u64(encoded, account.balance); + pv::append_u64(encoded, account.nonce); + } + return encoded; +} + +const p::State& require_state(const p::internal::GenesisDecode& decoded, + std::string_view message) { + pv::require(std::holds_alternative(decoded), message); + return std::get(decoded); +} + +void require_error(const p::Bytes& encoded, p::GenesisError expected, + std::string_view message) { + const auto decoded = p::internal::decode_genesis(encoded); + pv::require(std::holds_alternative(decoded), message); + pv::require(std::get(decoded) == expected, message); +} + +void zero_field(p::Bytes& encoded, std::size_t offset) { + std::fill_n(encoded.begin() + offset, 8, 0); +} + +void verify_frozen_genesis(const pv::Values& values) { + const auto encoded = pv::hex_decode(values.at("genesis")); + const auto decoded = p::internal::decode_genesis(encoded); + const auto& state = require_state(decoded, "frozen genesis rejected"); + p::Hash expected_chain_bytes{}; + const auto chain_bytes = pv::hex_decode(values.at("chain_id")); + pv::require(chain_bytes.size() == expected_chain_bytes.size(), + "frozen chain ID size"); + std::copy(chain_bytes.begin(), chain_bytes.end(), + expected_chain_bytes.begin()); + const p::ChainId expected_chain{expected_chain_bytes}; + pv::require(state.parameters.chain_id == expected_chain, "frozen chain ID"); + pv::require( + state.parameters.supply_limit == std::stoull(values.at("supply_limit")), + "frozen supply limit"); + pv::require( + state.parameters.total_supply == std::stoull(values.at("total_supply")), + "frozen total supply"); + pv::require( + state.parameters.fixed_fee == std::stoull(values.at("fixed_fee")), + "frozen fixed fee"); + pv::require(state.height == 0, "genesis height"); + pv::require(state.fee_pool == pv::read_u64(encoded, 34), + "genesis fee pool"); + pv::require(state.accounts.size() == 2, "frozen account count"); + + std::size_t index = 0; + for (const auto& [account_id, account] : state.accounts) { + p::Bytes entry; + append_hash(entry, account_id); + pv::append_u64(entry, account.balance); + pv::append_u64(entry, account.nonce); + pv::require( + entry == pv::hex_decode( + values.at("genesis.account" + std::to_string(index))), + "frozen account"); + ++index; + } +} + +void verify_header_and_parameter_rejection(const pv::Values& values) { + const auto frozen = pv::hex_decode(values.at("genesis")); + auto malformed = frozen; + malformed[0] = 'X'; + require_error(malformed, p::GenesisError::malformed, "genesis magic"); + malformed = frozen; + malformed[5] = 2; + require_error(malformed, p::GenesisError::malformed, "genesis version"); + malformed = frozen; + malformed[9] = 2; + require_error(malformed, p::GenesisError::unsupported_network, + "genesis network"); + malformed = frozen; + malformed.pop_back(); + require_error(malformed, p::GenesisError::malformed, + "truncated genesis account"); + malformed = frozen; + malformed.resize(kGenesisPrefixSize - 1); + require_error(malformed, p::GenesisError::malformed, + "truncated genesis prefix"); + malformed = frozen; + malformed.push_back(0); + require_error(malformed, p::GenesisError::malformed, "trailing genesis"); + + for (const auto offset : {std::size_t{10}, std::size_t{18}, + std::size_t{26}}) { + malformed = frozen; + zero_field(malformed, offset); + require_error(malformed, p::GenesisError::invalid_parameters, + "zero genesis parameter"); + } + require_error(encode_genesis(1, 2, 1, 0, {{identifier(1), 2, 0}}), + p::GenesisError::invalid_supply, "supply exceeds limit"); +} + +void verify_account_rejection() { + require_error(genesis_prefix(1, 1, 1, 0, 0), + p::GenesisError::invalid_accounts, "zero account count"); + require_error(genesis_prefix(1, 1, 1, 0, kMaximumAccounts + 1), + p::GenesisError::invalid_accounts, + "oversized account count rejected before length"); + require_error( + encode_genesis(2, 2, 1, 0, + {{identifier(1), 1, 0}, {identifier(1), 1, 0}}), + p::GenesisError::invalid_accounts, "duplicate account IDs"); + require_error( + encode_genesis(2, 2, 1, 0, + {{identifier(2), 1, 0}, {identifier(1), 1, 0}}), + p::GenesisError::invalid_accounts, "unordered account IDs"); + require_error(encode_genesis(1, 1, 1, 0, {{identifier(1), 0, 0}}), + p::GenesisError::invalid_accounts, "zero account balance"); + require_error(encode_genesis(1, 1, 1, 0, {{identifier(1), 1, 1}}), + p::GenesisError::invalid_accounts, "nonzero account nonce"); +} + +void verify_supply_and_pool() { + const auto maximum = std::numeric_limits::max(); + const auto ceiling = encode_genesis( + maximum, maximum, 1, maximum - 1, + {{identifier(1), 1, 0}}); + const auto ceiling_decoded = p::internal::decode_genesis(ceiling); + const auto& ceiling_state = + require_state(ceiling_decoded, "u64 supply ceiling rejected"); + pv::require(ceiling_state.fee_pool == maximum - 1 && + ceiling_state.accounts.begin()->second.balance == 1, + "u64 supply ceiling"); + + require_error( + encode_genesis(maximum, maximum, 1, maximum, + {{identifier(1), 1, 0}}), + p::GenesisError::invalid_supply, "genesis sum overflow"); + require_error(encode_genesis(2, 2, 1, 0, {{identifier(1), 1, 0}}), + p::GenesisError::invalid_supply, "genesis sum mismatch"); + require_error( + encode_genesis(10, 5, 1, 3, + {{identifier(1), 1, 0}, {identifier(2), 2, 0}}), + p::GenesisError::invalid_supply, "initial fee pool sum mismatch"); + + const auto encoded = + encode_genesis(10, 6, 1, 3, + {{identifier(1), 1, 0}, {identifier(2), 2, 0}}); + const auto decoded = p::internal::decode_genesis(encoded); + const auto& state = require_state(decoded, "initial fee pool rejected"); + pv::require(state.fee_pool == 3 && state.accounts.size() == 2, + "initial fee pool state"); +} + +void verify_maximum_account_count() { + std::vector accounts; + accounts.reserve(kMaximumAccounts); + for (std::uint32_t index = 1; index <= kMaximumAccounts; ++index) { + accounts.push_back(GenesisAccount{identifier(index), 1, 0}); + } + const auto encoded = + encode_genesis(kMaximumAccounts, kMaximumAccounts, 1, 0, accounts); + pv::require(encoded.size() == + kGenesisPrefixSize + + static_cast(kMaximumAccounts) * kAccountSize, + "maximum genesis byte length"); + const auto decoded = p::internal::decode_genesis(encoded); + const auto& state = require_state(decoded, "maximum genesis rejected"); + pv::require(state.accounts.size() == kMaximumAccounts, + "maximum genesis account count"); +} + +} // namespace + +int main(int argc, char** argv) { + try { + pv::require(argc == 2, "usage: kernel_genesis_test VECTOR_FILE"); + const auto values = pv::load_values(argv[1]); + verify_frozen_genesis(values); + verify_header_and_parameter_rejection(values); + verify_account_rejection(); + verify_supply_and_pool(); + verify_maximum_account_count(); + std::cout << "Kernel genesis tests: passed\n"; + return 0; + } catch (const std::exception& error) { + std::cerr << "Kernel genesis tests: failed: " << error.what() << '\n'; + return 1; + } +} diff --git a/tests/kernel/primitives_test.cpp b/tests/kernel/primitives_test.cpp new file mode 100644 index 0000000..b1f179a --- /dev/null +++ b/tests/kernel/primitives_test.cpp @@ -0,0 +1,364 @@ +#include "protocol/v1/address.hpp" +#include "protocol/v1/admission.hpp" +#include "protocol/v1/crypto.hpp" + +#include "../../src/v1/commitments.hpp" +#include "../../tools/protocol-vectors/vector_common.hpp" + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace pv = protocol_vectors; +namespace p = protocol::v1; +namespace pc = protocol::v1::internal; + +namespace { + +template +Tagged tagged_hash(const pv::Bytes& bytes, std::size_t offset = 0) { + pv::require(offset + 32 <= bytes.size(), "hash size"); + p::Hash result{}; + std::copy_n(bytes.begin() + offset, result.size(), result.begin()); + return Tagged{result}; +} + +void require_admission_error(const p::Admission& admission, + p::AdmissionError expected, + std::string_view message) { + pv::require(std::holds_alternative(admission), message); + pv::require(std::get(admission) == expected, message); +} + +std::string bech32m_values( + std::string_view hrp, std::vector data) { + static constexpr std::string_view charset = + "qpzry9x8gf2tvdw0s3jn54khce6mua7l"; + auto checksum_input = pv::hrp_expand(hrp); + checksum_input.insert(checksum_input.end(), data.begin(), data.end()); + checksum_input.resize(checksum_input.size() + 6, 0); + const auto checksum = pv::bech32_polymod(checksum_input) ^ 0x2bc830a3U; + + std::string result(hrp); + result.push_back('1'); + for (const auto value : data) result.push_back(charset[value]); + for (int index = 0; index < 6; ++index) { + result.push_back( + charset[(checksum >> (5 * (5 - index))) & 31U]); + } + return result; +} + +void verify_hash_and_signatures(const pv::Values& values) { + const auto public_key = + pv::hex_decode(values.at("rfc8032.public_key")); + const auto empty_signature = + pv::hex_decode(values.at("rfc8032.empty_signature")); + const pv::Bytes empty; + pv::require( + p::strict_ed25519_verify(public_key, empty, empty_signature), + "production verifier rejected RFC 8032 vector"); + + auto short_key = public_key; + short_key.pop_back(); + pv::require( + !p::strict_ed25519_verify(short_key, empty, empty_signature), + "production verifier accepted short public key"); + auto short_signature = empty_signature; + short_signature.pop_back(); + pv::require( + !p::strict_ed25519_verify(public_key, empty, short_signature), + "production verifier accepted short signature"); + + const auto signing_message = + pv::hex_decode(values.at("signing_message")); + const auto signature = pv::hex_decode(values.at("signature")); + pv::require( + p::strict_ed25519_verify(public_key, signing_message, signature), + "production verifier rejected transaction signature"); + pv::require( + !p::strict_ed25519_verify( + public_key, signing_message, + pv::hex_decode(values.at("invalid.signature"))), + "production verifier accepted mutated signature"); + pv::require( + !p::strict_ed25519_verify( + public_key, signing_message, + pv::hex_decode(values.at("invalid.noncanonical_s_signature"))), + "production verifier accepted non-canonical S"); + const auto small_order_signature = + pv::hex_decode(values.at("invalid.small_order_signature")); + pv::require( + !p::strict_ed25519_verify(public_key, signing_message, + small_order_signature), + "production verifier accepted small-order R"); + pv::require( + !p::strict_ed25519_verify( + pv::hex_decode(values.at("invalid.small_order_public_key")), + signing_message, small_order_signature), + "production verifier accepted small-order forgery"); + + pv::Bytes account_payload{1}; + pv::append(account_payload, public_key); + const auto account_hash = + p::hash("protocol-stack:v1:account", account_payload); + pv::require( + pv::Bytes(account_hash.begin(), account_hash.end()) == + pv::hex_decode(values.at("account_id")), + "production account hash mismatch"); + const auto signed_transaction = + pv::hex_decode(values.at("signed_tx")); + const auto transaction_hash = + p::hash("protocol-stack:v1:tx-id", signed_transaction); + pv::require( + pv::Bytes(transaction_hash.begin(), transaction_hash.end()) == + pv::hex_decode(values.at("tx_id")), + "production transaction hash mismatch"); +} + +void verify_addresses(const pv::Values& values) { + const auto account_id = + tagged_hash(pv::hex_decode(values.at("account_id"))); + const auto encoded = p::encode_address(account_id, "psdev"); + pv::require(encoded && *encoded == values.at("address"), + "production address encoding mismatch"); + const auto decoded = p::decode_address(values.at("address"), "psdev"); + pv::require(decoded && *decoded == account_id, + "production address decoder rejected vector"); + + pv::require( + !p::decode_address(values.at("invalid.address_checksum"), "psdev"), + "bad checksum accepted"); + pv::require(!p::decode_address(values.at("address"), ""), + "invalid configured HRP accepted"); + pv::require(!p::encode_address(account_id, "") && + !p::encode_address(account_id, "PSDEV") && + !p::encode_address(account_id, "ps-dev") && + !p::encode_address(account_id, + "abcdefghijklmnopqrstu"), + "invalid encoding HRP accepted"); + + const auto other_chain = p::encode_address(account_id, "psother"); + pv::require(other_chain.has_value(), "alternate HRP encoding"); + pv::require(!p::decode_address(*other_chain, "psdev"), + "wrong address HRP accepted"); + auto uppercase = values.at("address"); + uppercase.front() = 'P'; + pv::require(!p::decode_address(uppercase, "psdev"), + "uppercase address accepted"); + + auto version_two = pv::hex_decode(values.at("address_payload")); + version_two.front() = 2; + pv::require( + !p::decode_address(pv::bech32m("psdev", version_two), "psdev"), + "unknown address payload version accepted"); + + auto nonzero_padding = + pv::convert_bits(pv::hex_decode(values.at("address_payload"))); + nonzero_padding.back() |= 1U; + pv::require( + !p::decode_address(bech32m_values("psdev", nonzero_padding), + "psdev"), + "nonzero address padding accepted"); +} + +pv::Bytes transaction_with_signature(const pv::Values& values, + std::string_view signature_key) { + auto transaction = pv::hex_decode(values.at("signed_tx")); + const auto signature = pv::hex_decode(values.at(std::string(signature_key))); + pv::require(signature.size() == 64, "replacement signature size"); + std::copy(signature.begin(), signature.end(), transaction.begin() + 136); + return transaction; +} + +void verify_admission(const pv::Values& values) { + const auto chain_id = + tagged_hash(pv::hex_decode(values.at("chain_id"))); + const auto signed_transaction = + pv::hex_decode(values.at("signed_tx")); + const auto admission = p::admit_transfer(signed_transaction, chain_id); + pv::require(std::holds_alternative(admission), + "production admission rejected frozen transfer"); + const auto& transfer = std::get(admission); + pv::require( + transfer.sender_id == + tagged_hash(pv::hex_decode(values.at("account_id"))) && + transfer.transaction_id == + tagged_hash( + pv::hex_decode(values.at("tx_id"))) && + transfer.nonce == std::stoull(values.at("tx.nonce")) && + transfer.recipient == + tagged_hash( + pv::hex_decode(values.at("tx.recipient"))) && + transfer.amount == std::stoull(values.at("tx.amount")) && + transfer.fee_limit == + std::stoull(values.at("tx.fee_limit")) && + transfer.valid_until == + std::stoull(values.at("tx.valid_until")), + "production admitted transfer fields mismatch"); + + const auto invalid = + transaction_with_signature(values, "invalid.signature"); + require_admission_error( + p::admit_transfer(invalid, chain_id), + p::AdmissionError::invalid_signature, + "mutated transaction signature accepted"); + require_admission_error( + p::admit_transfer( + transaction_with_signature( + values, "invalid.noncanonical_s_signature"), + chain_id), + p::AdmissionError::invalid_signature, + "non-canonical transaction signature accepted"); + require_admission_error( + p::admit_transfer( + transaction_with_signature( + values, "invalid.small_order_signature"), + chain_id), + p::AdmissionError::invalid_signature, + "small-order transaction R accepted"); + + auto small_order = transaction_with_signature( + values, "invalid.small_order_signature"); + const auto small_order_key = + pv::hex_decode(values.at("invalid.small_order_public_key")); + std::copy(small_order_key.begin(), small_order_key.end(), + small_order.begin() + 40); + require_admission_error( + p::admit_transfer(small_order, chain_id), + p::AdmissionError::invalid_signature, + "small-order transaction forgery accepted"); + + auto truncated = signed_transaction; + truncated.resize( + truncated.size() - + std::stoull(values.at("invalid.signed_tx_truncated_bytes"))); + require_admission_error( + p::admit_transfer(truncated, chain_id), + p::AdmissionError::malformed_transaction, + "truncated transaction accepted"); + auto trailing = signed_transaction; + pv::append( + trailing, + pv::hex_decode(values.at("invalid.signed_tx_trailing_suffix"))); + require_admission_error( + p::admit_transfer(trailing, chain_id), + p::AdmissionError::malformed_transaction, + "transaction trailing byte accepted"); + + auto other_chain = chain_id; + other_chain.data()[0] ^= 1U; + require_admission_error( + p::admit_transfer(invalid, other_chain), + p::AdmissionError::wrong_chain, + "signature failure preceded wrong-chain rejection"); + require_admission_error( + p::admit_transfer(trailing, other_chain), + p::AdmissionError::malformed_transaction, + "wrong-chain check preceded shape rejection"); +} + +std::pair decode_account( + std::string_view encoded) { + const auto entry = pv::hex_decode(encoded); + pv::require(entry.size() == 48, "account vector size"); + return { + tagged_hash(entry), + p::Account{pv::read_u64(entry, 32), pv::read_u64(entry, 40)}, + }; +} + +void verify_commitments(const pv::Values& values) { + const auto chain_id = + tagged_hash(pv::hex_decode(values.at("chain_id"))); + std::map accounts; + for (std::size_t index = 0; index < 3; ++index) { + pv::require( + accounts + .emplace(decode_account( + values.at("state.account" + std::to_string(index)))) + .second, + "duplicate account vector"); + } + const p::State state{ + p::Parameters{ + chain_id, + std::stoull(values.at("state.supply_limit")), + std::stoull(values.at("state.total_supply")), + 1, + }, + std::stoull(values.at("state.height")), + std::stoull(values.at("state.fee_pool_balance")), + accounts, + }; + const auto state_commitment = pc::state_root(state); + pv::require(std::holds_alternative(state_commitment), + "production state commitment rejected frozen state"); + pv::require( + std::get(state_commitment) == + tagged_hash(pv::hex_decode(values.at("state.root"))), + "production state root mismatch"); + + const p::State empty_state{ + p::Parameters{chain_id, 1000, 0, 1}, + 0, + 0, + {}, + }; + const auto empty_state_commitment = pc::state_root(empty_state); + pv::require( + std::holds_alternative(empty_state_commitment) && + std::get(empty_state_commitment) == + tagged_hash( + pv::hex_decode(values.at("state.empty_root"))), + "production empty state root mismatch"); + + const auto empty_state_tree = + p::hash("protocol-stack:v1:state-empty"); + pv::require( + pv::Bytes(empty_state_tree.begin(), empty_state_tree.end()) == + pv::hex_decode(values.at("state.empty_tree_root")), + "production empty state-tree hash mismatch"); + + std::vector transaction_ids; + for (std::size_t index = 0; index < 3; ++index) { + transaction_ids.push_back(tagged_hash( + pv::hex_decode(values.at("tx.item" + std::to_string(index))))); + } + pv::require( + pc::transaction_root({}) == + tagged_hash( + pv::hex_decode(values.at("tx.empty_root"))), + "production empty transaction root mismatch"); + pv::require( + pc::transaction_root(transaction_ids) == + tagged_hash( + pv::hex_decode(values.at("tx.root"))), + "production transaction root mismatch"); +} + +} // namespace + +int main(int argc, char** argv) { + try { + pv::require(argc == 2, + "usage: kernel_primitives_test VECTOR_FILE"); + const auto values = pv::load_values(argv[1]); + verify_hash_and_signatures(values); + verify_addresses(values); + verify_admission(values); + verify_commitments(values); + std::cout << "Kernel protocol primitive vectors: passed\n"; + return 0; + } catch (const std::exception& error) { + std::cerr << "Kernel protocol primitive vectors: failed: " + << error.what() << '\n'; + return 1; + } +} diff --git a/tests/kernel/property_test.cpp b/tests/kernel/property_test.cpp new file mode 100644 index 0000000..d6bc326 --- /dev/null +++ b/tests/kernel/property_test.cpp @@ -0,0 +1,300 @@ +#include "../../src/v1/commitments.hpp" +#include "../../src/v1/execution.hpp" +#include "../../tools/protocol-vectors/vector_common.hpp" + +#include +#include +#include +#include +#include +#include +#include +#include + +namespace pv = protocol_vectors; +namespace p = protocol::v1; + +namespace { + +constexpr std::size_t kScenarioCount = 9'000; +constexpr std::uint64_t kTotalSupply = 10'000'000; + +class SplitMix64 { + public: + explicit SplitMix64(std::uint64_t state) : state_(state) {} + + std::uint64_t next() { + auto value = (state_ += 0x9e3779b97f4a7c15ULL); + value = (value ^ (value >> 30U)) * 0xbf58476d1ce4e5b9ULL; + value = (value ^ (value >> 27U)) * 0x94d049bb133111ebULL; + return value ^ (value >> 31U); + } + + private: + std::uint64_t state_; +}; + +template +Tagged tagged_value(SplitMix64& random, std::uint8_t discriminator) { + p::Hash bytes{}; + bytes[0] = discriminator; + for (std::size_t offset = 1; offset < bytes.size(); offset += 8) { + const auto value = random.next(); + const auto remaining = bytes.size() - offset; + const auto width = remaining < 8 ? remaining : 8; + for (std::size_t index = 0; index < width; ++index) { + bytes[offset + index] = + static_cast(value >> (index * 8U)); + } + } + return Tagged{bytes}; +} + +std::uint64_t conserved_supply(const p::State& state) { + auto sum = state.fee_pool; + for (const auto& [identifier, account] : state.accounts) { + static_cast(identifier); + pv::require( + account.balance <= std::numeric_limits::max() - sum, + "property supply addition overflow"); + sum += account.balance; + } + return sum; +} + +struct Scenario { + p::State state; + p::Transfer transfer; + p::TransferResult expected; +}; + +Scenario make_scenario(std::size_t index) { + SplitMix64 random{0x6c65646765722d31ULL ^ + static_cast(index)}; + std::array identifiers{ + tagged_value(random, 1), + tagged_value(random, 2), + tagged_value(random, 3), + tagged_value(random, 4), + }; + const auto absent = tagged_value(random, 8); + const auto new_recipient = tagged_value(random, 9); + const auto chain_id = tagged_value(random, 10); + const auto transaction_id = + tagged_value(random, 11); + const auto fixed_fee = 1 + random.next() % 10'000; + const auto fee_pool = random.next() % 10'000; + const auto first_balance = 1'000'000 + random.next() % 100'000; + const auto second_balance = 1'000'000 + random.next() % 100'000; + const auto third_balance = 1'000'000 + random.next() % 100'000; + const auto fourth_balance = + kTotalSupply - fee_pool - first_balance - second_balance - + third_balance; + const auto sender_nonce = random.next() % 10'000; + const auto height = random.next() % 1'000'000; + const auto block_height = height + 1; + + p::State state{ + p::Parameters{ + chain_id, + kTotalSupply + 1'000'000, + kTotalSupply, + fixed_fee, + }, + height, + fee_pool, + { + {identifiers[0], p::Account{first_balance, sender_nonce}}, + {identifiers[1], + p::Account{second_balance, random.next() % 10'000}}, + {identifiers[2], + p::Account{third_balance, random.next() % 10'000}}, + {identifiers[3], + p::Account{fourth_balance, random.next() % 10'000}}, + }, + }; + const auto success_kind = (index / 9) % 3; + auto recipient = identifiers[1]; + if (success_kind == 1) recipient = identifiers[0]; + if (success_kind == 2) recipient = new_recipient; + p::Transfer transfer{ + identifiers[0], + transaction_id, + sender_nonce + 1, + recipient, + 1 + random.next() % 100'000, + fixed_fee, + block_height + random.next() % 100, + }; + + const auto expected = + static_cast(index % 9); + switch (expected) { + case p::TransferResult::success: + break; + case p::TransferResult::zero_amount: + transfer.amount = 0; + transfer.fee_limit = 0; + transfer.valid_until = block_height - 1; + transfer.sender_id = absent; + break; + case p::TransferResult::fee_limit_too_low: + transfer.fee_limit = fixed_fee - 1; + transfer.valid_until = block_height - 1; + transfer.sender_id = absent; + break; + case p::TransferResult::expired: + transfer.valid_until = block_height - 1; + transfer.sender_id = absent; + break; + case p::TransferResult::sender_not_found: + transfer.sender_id = absent; + transfer.nonce = 0; + transfer.amount = std::numeric_limits::max(); + break; + case p::TransferResult::nonce_exhausted: + state.accounts.at(transfer.sender_id).nonce = + std::numeric_limits::max(); + transfer.nonce = 0; + transfer.amount = std::numeric_limits::max(); + break; + case p::TransferResult::nonce_mismatch: + transfer.nonce = sender_nonce + 2; + transfer.amount = std::numeric_limits::max(); + break; + case p::TransferResult::debit_overflow: + transfer.amount = std::numeric_limits::max(); + break; + case p::TransferResult::insufficient_balance: + transfer.amount = first_balance; + break; + } + return Scenario{std::move(state), transfer, expected}; +} + +void require_success_effects(const p::State& before, + const p::State& after, + const p::Transfer& transfer) { + const auto fee = before.parameters.fixed_fee; + const auto sender_before = before.accounts.at(transfer.sender_id); + const auto sender_after = after.accounts.at(transfer.sender_id); + auto expected = before; + auto& expected_sender = expected.accounts.at(transfer.sender_id); + expected.fee_pool += fee; + expected_sender.nonce = transfer.nonce; + pv::require(after.fee_pool == before.fee_pool + fee, + "property fee routing"); + pv::require(sender_after.nonce == transfer.nonce, + "property nonce advancement"); + if (transfer.sender_id == transfer.recipient) { + expected_sender.balance -= fee; + pv::require(sender_after.balance == sender_before.balance - fee, + "property self-transfer balance"); + pv::require(after.accounts.size() == before.accounts.size(), + "property self-transfer account count"); + pv::require(after == expected, "property exact self-transfer state"); + return; + } + + expected_sender.balance -= transfer.amount + fee; + const auto expected_recipient = + expected.accounts.find(transfer.recipient); + if (expected_recipient == expected.accounts.end()) { + expected.accounts.emplace( + transfer.recipient, p::Account{transfer.amount, 0}); + } else { + expected_recipient->second.balance += transfer.amount; + } + pv::require( + sender_after.balance == + sender_before.balance - transfer.amount - fee, + "property sender debit"); + const auto recipient_before = before.accounts.find(transfer.recipient); + const auto recipient_after = after.accounts.find(transfer.recipient); + pv::require(recipient_after != after.accounts.end(), + "property recipient exists"); + if (recipient_before == before.accounts.end()) { + pv::require( + recipient_after->second == p::Account{transfer.amount, 0}, + "property recipient creation"); + pv::require(after.accounts.size() == before.accounts.size() + 1, + "property recipient creation count"); + } else { + pv::require( + recipient_after->second.balance == + recipient_before->second.balance + transfer.amount, + "property recipient credit"); + pv::require( + recipient_after->second.nonce == recipient_before->second.nonce, + "property recipient nonce"); + } + pv::require(after == expected, "property exact transfer state"); +} + +void verify_properties() { + std::array coverage{}; + for (std::size_t index = 0; index < kScenarioCount; ++index) { + auto scenario = make_scenario(index); + const auto before = scenario.state; + auto repeated_state = scenario.state; + const auto execution = p::internal::execute_transfer( + scenario.transfer, scenario.state, scenario.state.height + 1); + const auto repeated = p::internal::execute_transfer( + scenario.transfer, repeated_state, repeated_state.height + 1); + pv::require(std::holds_alternative(execution), + "property unexpected invariant failure"); + pv::require(execution == repeated && scenario.state == repeated_state, + "property execution determinism"); + + const auto& receipt = std::get(execution); + pv::require(receipt.transaction_id == scenario.transfer.transaction_id, + "property receipt transaction ID"); + pv::require(receipt.result == scenario.expected, + "property result precedence"); + const auto result_index = static_cast(receipt.result); + pv::require(result_index < coverage.size(), "property result range"); + ++coverage[result_index]; + + const auto encoded = p::internal::encode_receipt( + receipt, scenario.state.parameters.fixed_fee); + pv::require(encoded.has_value() && encoded->size() == 47, + "property canonical receipt"); + if (receipt.result == p::TransferResult::success) { + pv::require( + receipt.fee_charged == before.parameters.fixed_fee, + "property successful fee"); + require_success_effects(before, scenario.state, scenario.transfer); + } else { + pv::require(receipt.fee_charged == 0, + "property failed fee"); + pv::require(scenario.state == before, + "property failure atomicity"); + } + pv::require(scenario.state.height == before.height, + "property execution height"); + pv::require(conserved_supply(scenario.state) == kTotalSupply, + "property supply conservation"); + pv::require( + std::holds_alternative( + p::internal::state_root(scenario.state)), + "property state commitment"); + } + for (const auto count : coverage) { + pv::require(count == kScenarioCount / coverage.size(), + "property result coverage"); + } +} + +} // namespace + +int main() { + try { + verify_properties(); + std::cout << "Kernel properties: passed " << kScenarioCount + << " deterministic scenarios\n"; + return 0; + } catch (const std::exception& error) { + std::cerr << "Kernel properties: failed: " << error.what() << '\n'; + return 1; + } +}