From 097bd69a40d2c8785ccfe708ae0a5c3cdd1176a6 Mon Sep 17 00:00:00 2001 From: Giorgi Chomakhashvili <133794518+kaikisegfault@users.noreply.github.com> Date: Thu, 23 Jul 2026 15:03:48 +0200 Subject: [PATCH 1/9] feat(kernel): implement strict transfer admission --- CMakeLists.txt | 24 ++++++++++ docs/project/current-state.md | 18 +++++--- include/protocol/v1/admission.hpp | 12 +++++ include/protocol/v1/crypto.hpp | 17 +++++++ include/protocol/v1/types.hpp | 72 +++++++++++++++++++++++++++++ src/v1/admission.cpp | 76 +++++++++++++++++++++++++++++++ src/v1/crypto.cpp | 53 +++++++++++++++++++++ tests/kernel/admission_test.cpp | 52 +++++++++++++++++++++ 8 files changed, 318 insertions(+), 6 deletions(-) create mode 100644 include/protocol/v1/admission.hpp create mode 100644 include/protocol/v1/crypto.hpp create mode 100644 include/protocol/v1/types.hpp create mode 100644 src/v1/admission.cpp create mode 100644 src/v1/crypto.cpp create mode 100644 tests/kernel/admission_test.cpp diff --git a/CMakeLists.txt b/CMakeLists.txt index 3e25f54..8823659 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -74,12 +74,30 @@ add_executable( ledger_transition_vectors tools/ledger-vectors/verify.cpp ) +add_library( + protocol_kernel + STATIC + src/v1/admission.cpp + src/v1/crypto.cpp +) +target_include_directories( + protocol_kernel + PUBLIC + "${PROJECT_SOURCE_DIR}/include" +) +add_executable( + kernel_admission_tests + tests/kernel/admission_test.cpp +) +target_link_libraries(kernel_admission_tests PRIVATE protocol_kernel) foreach( protocol_stack_target IN ITEMS protocol_primitive_vectors ledger_transition_vectors + protocol_kernel + kernel_admission_tests ) target_compile_features(${protocol_stack_target} PRIVATE cxx_std_20) target_compile_definitions( @@ -130,6 +148,12 @@ add_test( "${PROJECT_SOURCE_DIR}/tools/ledger-vectors/verify.py" "${PROJECT_SOURCE_DIR}/test-vectors/ledger-transition-v1.txt" ) +add_test( + NAME kernel-admission + COMMAND + kernel_admission_tests + "${PROJECT_SOURCE_DIR}/test-vectors/ledger-transition-v1.txt" +) set_tests_properties( protocol-primitives-python ledger-transition-python diff --git a/docs/project/current-state.md b/docs/project/current-state.md index 911162d..068b8b4 100644 --- a/docs/project/current-state.md +++ b/docs/project/current-state.md @@ -14,6 +14,8 @@ 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. - On 2026-07-23 the owner granted standing authority for autonomous project decisions and repository operations. A `proceed` instruction requires no follow-up approval. @@ -37,7 +39,8 @@ vectors. 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. + production deployment implementation beyond the issue #8 transaction + admission layer. ## Verification evidence @@ -63,15 +66,18 @@ 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. ## 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. +> Implement checked transfer execution, commitments, and atomic ordered block +> results against the frozen ledger vectors, then add fuzz/property and 10,000 +> seeded differential sequences. ## Open autonomous decisions diff --git a/include/protocol/v1/admission.hpp b/include/protocol/v1/admission.hpp new file mode 100644 index 0000000..643e913 --- /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 Hash& 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/types.hpp b/include/protocol/v1/types.hpp new file mode 100644 index 0000000..f63b31a --- /dev/null +++ b/include/protocol/v1/types.hpp @@ -0,0 +1,72 @@ +#pragma once + +#include +#include +#include +#include +#include +#include + +namespace protocol::v1 { + +using Bytes = std::vector; +using Hash = std::array; + +struct Account { + std::uint64_t balance; + std::uint64_t nonce; + + auto operator<=>(const Account&) const = default; +}; + +struct Parameters { + Hash chain_id; + std::uint64_t supply_limit; + std::uint64_t total_supply; + std::uint64_t fixed_fee; +}; + +struct State { + Parameters parameters; + std::uint64_t height; + std::uint64_t fee_pool; + std::map accounts; +}; + +enum class AdmissionError : std::uint8_t { + malformed_transaction = 1, + wrong_chain = 2, + invalid_signature = 3, +}; + +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 { + Hash sender_id; + Hash transaction_id; + std::uint64_t nonce; + Hash recipient; + std::uint64_t amount; + std::uint64_t fee_limit; + std::uint64_t valid_until; +}; + +using Admission = std::variant; + +struct Receipt { + Hash transaction_id; + TransferResult result; + std::uint64_t fee_charged; +}; + +} // namespace protocol::v1 diff --git a/src/v1/admission.cpp b/src/v1/admission.cpp new file mode 100644 index 0000000..60cbcc0 --- /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; +} + +Hash sender_id(std::span public_key) { + Bytes payload{1}; + payload.insert(payload.end(), public_key.begin(), public_key.end()); + return 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 Hash& expected_chain_id) { + if (!valid_shape(raw)) return AdmissionError::malformed_transaction; + if (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), + hash("protocol-stack:v1:tx-id", raw), + read_u64(raw, 72), + fixed_32(raw, 80), + read_u64(raw, 112), + read_u64(raw, 120), + read_u64(raw, 128), + }; +} + +} // namespace protocol::v1 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/tests/kernel/admission_test.cpp b/tests/kernel/admission_test.cpp new file mode 100644 index 0000000..4b3cfb0 --- /dev/null +++ b/tests/kernel/admission_test.cpp @@ -0,0 +1,52 @@ +#include "protocol/v1/admission.hpp" + +#include "../../tools/protocol-vectors/vector_common.hpp" + +#include +#include +#include + +namespace pv = protocol_vectors; +namespace p = protocol::v1; + +p::Hash hash_value(const pv::Bytes& bytes) { + pv::require(bytes.size() == 32, "hash size"); + p::Hash result{}; + std::copy(bytes.begin(), bytes.end(), result.begin()); + return result; +} + +void verify_admission_vectors(const pv::Values& values) { + const auto chain_id = + hash_value(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; + } +} From 92880aaa1f03cd1d7a37b5189ad1291ddfec404e Mon Sep 17 00:00:00 2001 From: Giorgi Chomakhashvili <133794518+kaikisegfault@users.noreply.github.com> Date: Thu, 23 Jul 2026 15:26:50 +0200 Subject: [PATCH 2/9] feat(kernel): execute checked native transfers Implement the accepted transfer-result ordering, checked monetary writes, fee routing, self-transfer handling, and internal invariant failures behind a kernel-private helper. Reproduce the frozen ledger scenario and assert conservation and failure atomicity across all result codes.\n\nProtocol behavior follows ADR 0006 and ledger-transition-v1 without compatibility changes.\n\nRefs #8 --- CMakeLists.txt | 13 ++ docs/project/current-state.md | 23 ++- include/protocol/v1/types.hpp | 6 + src/v1/execution.cpp | 80 ++++++++ src/v1/execution.hpp | 19 ++ tests/kernel/execution_test.cpp | 343 ++++++++++++++++++++++++++++++++ 6 files changed, 479 insertions(+), 5 deletions(-) create mode 100644 src/v1/execution.cpp create mode 100644 src/v1/execution.hpp create mode 100644 tests/kernel/execution_test.cpp diff --git a/CMakeLists.txt b/CMakeLists.txt index 8823659..7b6eecb 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -79,6 +79,7 @@ add_library( STATIC src/v1/admission.cpp src/v1/crypto.cpp + src/v1/execution.cpp ) target_include_directories( protocol_kernel @@ -90,6 +91,11 @@ add_executable( 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) foreach( protocol_stack_target @@ -98,6 +104,7 @@ foreach( ledger_transition_vectors protocol_kernel kernel_admission_tests + kernel_execution_tests ) target_compile_features(${protocol_stack_target} PRIVATE cxx_std_20) target_compile_definitions( @@ -154,6 +161,12 @@ add_test( 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" +) set_tests_properties( protocol-primitives-python ledger-transition-python diff --git a/docs/project/current-state.md b/docs/project/current-state.md index 068b8b4..5d2ff8e 100644 --- a/docs/project/current-state.md +++ b/docs/project/current-state.md @@ -38,9 +38,9 @@ vectors. - 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 beyond the issue #8 transaction - admission layer. +- The issue #8 in-memory kernel branch implements strict transaction admission + and checked transfer execution. It does not yet implement genesis loading, + commitments, ordered block commit, persistence, networking, or deployment. ## Verification evidence @@ -70,13 +70,26 @@ vectors. 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. +- 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 execution slice adds no raw-byte entry point; fuzzing remains required + when the variable-length production genesis decoder is introduced. ## Exact next action Continue issue #8: -> Implement checked transfer execution, commitments, and atomic ordered block -> results against the frozen ledger vectors, then add fuzz/property and 10,000 +> Reconcile the generic 1,048,576-byte canonical-object limit with the ledger +> genesis account-count bound and clarify signature-canonicality admission +> errors in the accepted specification. Then implement production genesis +> loading and commitments plus a public state owner that encapsulates immutable +> parameters, enforces the exact next height, and atomically commits ordered +> block results against the frozen vectors before fuzz/property and 10,000 > seeded differential sequences. ## Open autonomous decisions diff --git a/include/protocol/v1/types.hpp b/include/protocol/v1/types.hpp index f63b31a..3e09368 100644 --- a/include/protocol/v1/types.hpp +++ b/include/protocol/v1/types.hpp @@ -24,6 +24,8 @@ struct Parameters { std::uint64_t supply_limit; std::uint64_t total_supply; std::uint64_t fixed_fee; + + bool operator==(const Parameters&) const = default; }; struct State { @@ -31,6 +33,8 @@ struct State { std::uint64_t height; std::uint64_t fee_pool; std::map accounts; + + bool operator==(const State&) const = default; }; enum class AdmissionError : std::uint8_t { @@ -67,6 +71,8 @@ struct Receipt { Hash transaction_id; TransferResult result; std::uint64_t fee_charged; + + bool operator==(const Receipt&) const = default; }; } // namespace protocol::v1 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/tests/kernel/execution_test.cpp b/tests/kernel/execution_test.cpp new file mode 100644 index 0000000..0de09cd --- /dev/null +++ b/tests/kernel/execution_test.cpp @@ -0,0 +1,343 @@ +#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; + +p::Hash hash_value(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 result; +} + +void append_hash(pv::Bytes& target, const p::Hash& 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 { + hash_value(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{ + hash_value(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 == hash_value(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::Hash{}, + 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::Hash{}, + 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::Hash{}, + 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; + } +} From f732719a9af6db1b3521cc4db1a7b1d6631d0f42 Mon Sep 17 00:00:00 2001 From: Giorgi Chomakhashvili <133794518+kaikisegfault@users.noreply.github.com> Date: Thu, 23 Jul 2026 15:28:44 +0200 Subject: [PATCH 3/9] docs(protocol): bound canonical genesis size Narrow version-one genesis to 21,844 accounts so its 46-byte prefix and 48-byte entries remain within the accepted 1,048,576-byte canonical-object limit. Clarify that strict Ed25519 canonicality and equation failures collapse to INVALID_SIGNATURE after the chain check.\n\nThe existing canonical fixture bytes and transition meaning are unchanged.\n\nRefs #8 --- .../0006-m1-devnet-ledger-parameters.md | 19 ++++++++++++++++++- docs/project/current-state.md | 15 ++++++++------- docs/specifications/ledger-transition-v1.md | 15 ++++++++++++++- 3 files changed, 40 insertions(+), 9 deletions(-) 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/project/current-state.md b/docs/project/current-state.md index 5d2ff8e..b4c4a8d 100644 --- a/docs/project/current-state.md +++ b/docs/project/current-state.md @@ -35,6 +35,10 @@ 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. @@ -84,13 +88,10 @@ vectors. Continue issue #8: -> Reconcile the generic 1,048,576-byte canonical-object limit with the ledger -> genesis account-count bound and clarify signature-canonicality admission -> errors in the accepted specification. Then implement production genesis -> loading and commitments plus a public state owner that encapsulates immutable -> parameters, enforces the exact next height, and atomically commits ordered -> block results against the frozen vectors before fuzz/property and 10,000 -> seeded differential sequences. +> Implement production genesis loading and commitments plus a public state +> owner that encapsulates immutable parameters, enforces the exact next height, +> and atomically commits ordered block results against the frozen vectors +> before fuzz/property and 10,000 seeded differential sequences. ## 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 | From 9559a36988f4f05499000186d8ab6eb20f7ce953 Mon Sep 17 00:00:00 2001 From: Giorgi Chomakhashvili <133794518+kaikisegfault@users.noreply.github.com> Date: Thu, 23 Jul 2026 15:47:57 +0200 Subject: [PATCH 4/9] feat(kernel): decode canonical genesis Parse exact version-one genesis bytes with bounded account counts, ordered identifiers, checked supply conservation, and domain-separated chain IDs. Cover malformed, parameter, ordering, overflow, and 21,844/21,845 size boundaries under the full compiler and sanitizer matrix.\n\nRefs #8 --- CMakeLists.txt | 13 ++ include/protocol/v1/types.hpp | 8 ++ src/v1/encoding.hpp | 58 ++++++++ src/v1/genesis.cpp | 143 +++++++++++++++++++ src/v1/genesis.hpp | 16 +++ tests/kernel/genesis_test.cpp | 259 ++++++++++++++++++++++++++++++++++ 6 files changed, 497 insertions(+) create mode 100644 src/v1/encoding.hpp create mode 100644 src/v1/genesis.cpp create mode 100644 src/v1/genesis.hpp create mode 100644 tests/kernel/genesis_test.cpp diff --git a/CMakeLists.txt b/CMakeLists.txt index 7b6eecb..e69ce6a 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -80,6 +80,7 @@ add_library( src/v1/admission.cpp src/v1/crypto.cpp src/v1/execution.cpp + src/v1/genesis.cpp ) target_include_directories( protocol_kernel @@ -96,6 +97,11 @@ add_executable( 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) foreach( protocol_stack_target @@ -105,6 +111,7 @@ foreach( protocol_kernel kernel_admission_tests kernel_execution_tests + kernel_genesis_tests ) target_compile_features(${protocol_stack_target} PRIVATE cxx_std_20) target_compile_definitions( @@ -167,6 +174,12 @@ add_test( 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" +) set_tests_properties( protocol-primitives-python ledger-transition-python diff --git a/include/protocol/v1/types.hpp b/include/protocol/v1/types.hpp index 3e09368..7e070dd 100644 --- a/include/protocol/v1/types.hpp +++ b/include/protocol/v1/types.hpp @@ -43,6 +43,14 @@ enum class AdmissionError : std::uint8_t { 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, 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/genesis.cpp b/src/v1/genesis.cpp new file mode 100644 index 0000000..5a92dca --- /dev/null +++ b/src/v1/genesis.cpp @@ -0,0 +1,143 @@ +#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 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 (!identifier || !balance || !nonce) { + return GenesisError::malformed; + } + 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 auto 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{ + 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/tests/kernel/genesis_test.cpp b/tests/kernel/genesis_test.cpp new file mode 100644 index 0000000..e3fa515 --- /dev/null +++ b/tests/kernel/genesis_test.cpp @@ -0,0 +1,259 @@ +#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::Hash 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)); + } +} + +void append_hash(p::Bytes& target, const p::Hash& value) { + target.insert(target.end(), value.begin(), value.end()); +} + +p::Hash 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 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{}; + const auto chain_bytes = pv::hex_decode(values.at("chain_id")); + pv::require(chain_bytes.size() == expected_chain.size(), + "frozen chain ID size"); + std::copy(chain_bytes.begin(), chain_bytes.end(), expected_chain.begin()); + 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; + } +} From 7d92382afb1f0386ce098dba58f3fb2c7c62464a Mon Sep 17 00:00:00 2001 From: Giorgi Chomakhashvili <133794518+kaikisegfault@users.noreply.github.com> Date: Thu, 23 Jul 2026 15:49:44 +0200 Subject: [PATCH 5/9] feat(kernel): compute canonical commitments Implement checked state conservation, ordered RFC 9162-style state and transaction trees, exact receipt and block-header encodings, and validated block identifiers. Reproduce every frozen commitment and exercise Merkle power-of-two and 65,535-leaf boundaries under all compiler and sanitizer presets.\n\nRefs #8 --- CMakeLists.txt | 13 ++ src/v1/commitments.cpp | 179 +++++++++++++++ src/v1/commitments.hpp | 35 +++ tests/kernel/commitments_test.cpp | 347 ++++++++++++++++++++++++++++++ 4 files changed, 574 insertions(+) create mode 100644 src/v1/commitments.cpp create mode 100644 src/v1/commitments.hpp create mode 100644 tests/kernel/commitments_test.cpp diff --git a/CMakeLists.txt b/CMakeLists.txt index e69ce6a..240a534 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -78,6 +78,7 @@ add_library( protocol_kernel STATIC src/v1/admission.cpp + src/v1/commitments.cpp src/v1/crypto.cpp src/v1/execution.cpp src/v1/genesis.cpp @@ -102,6 +103,11 @@ add_executable( 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) foreach( protocol_stack_target @@ -112,6 +118,7 @@ foreach( kernel_admission_tests kernel_execution_tests kernel_genesis_tests + kernel_commitment_tests ) target_compile_features(${protocol_stack_target} PRIVATE cxx_std_20) target_compile_definitions( @@ -180,6 +187,12 @@ add_test( 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" +) set_tests_properties( protocol-primitives-python ledger-transition-python diff --git a/src/v1/commitments.cpp b/src/v1/commitments.cpp new file mode 100644 index 0000000..33b9bd2 --- /dev/null +++ b/src/v1/commitments.cpp @@ -0,0 +1,179 @@ +#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; +} + +std::span item_bytes(const Hash& 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 Hash& 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.total_supply == 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 protocol::v1::hash("protocol-stack:v1:state-root", payload); +} + +Hash transaction_root(std::span transaction_ids) { + return 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 Hash& chain_id, std::uint64_t height, + const Hash& previous_state_root, + const Hash& transaction_root_value, + const Hash& 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 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..3db498e --- /dev/null +++ b/src/v1/commitments.hpp @@ -0,0 +1,35 @@ +#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); + +Hash transaction_root(std::span transaction_ids); + +std::optional encode_receipt(const Receipt& receipt, + std::uint64_t fixed_fee); + +Bytes encode_block_header(const Hash& chain_id, std::uint64_t height, + const Hash& previous_state_root, + const Hash& transaction_root, + const Hash& resulting_state_root, + std::uint32_t transaction_count); + +std::optional block_id(std::span header); + +} // namespace protocol::v1::internal diff --git a/tests/kernel/commitments_test.cpp b/tests/kernel/commitments_test.cpp new file mode 100644 index 0000000..56cc53b --- /dev/null +++ b/tests/kernel/commitments_test.cpp @@ -0,0 +1,347 @@ +#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 { + +p::Hash hash_value(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 result; +} + +pv::Bytes bytes(const p::Hash& value) { + return {value.begin(), value.end()}; +} + +std::pair decode_account(std::string_view encoded) { + const auto entry = pv::hex_decode(encoded); + pv::require(entry.size() == 48, "account entry size"); + return { + hash_value(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{ + hash_value(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::Hash 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 hash_value(pv::hash("protocol-stack:v1:state-root", payload)); +} + +p::Hash 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 = hash_value(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 == + hash_value(pv::hex_decode(values.at("previous_state_root"))), + "previous state root"); + pv::require(resulting_root == + hash_value(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 == + hash_value(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 == + hash_value(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][offset] = + static_cast((index + 1) * 17 + offset); + } + } + return ids; +} + +p::Hash expected_transaction_root(std::span ids) { + std::vector encoded; + encoded.reserve(ids.size()); + for (const auto& identifier : ids) encoded.push_back(bytes(identifier)); + return hash_value(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][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{ids.front(), 100, count + 1, 1}, + 7, + 1, + {}, + }; + for (std::size_t index = count; index > 0; --index) { + state.accounts.emplace(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{ids.front(), 100, 10, 1}, + 0, + 0, + {{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::invalid_parameters); + 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::Hash 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( + transaction_id, 1, transaction_id, transaction_id, transaction_id, 0); + header.pop_back(); + pv::require(!pc::block_id(header), "short block header"); + header = pc::encode_block_header( + transaction_id, 1, transaction_id, transaction_id, transaction_id, 0); + header[0] = 'X'; + pv::require(!pc::block_id(header), "block header magic"); + header = pc::encode_block_header( + transaction_id, 1, transaction_id, transaction_id, transaction_id, 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; + } +} From 5ddcdd44530b6e0229702c75ccb50558dc391413 Mon Sep 17 00:00:00 2001 From: Giorgi Chomakhashvili <133794518+kaikisegfault@users.noreply.github.com> Date: Thu, 23 Jul 2026 17:32:12 +0200 Subject: [PATCH 6/9] feat(kernel): commit ordered blocks atomically Add distinct protocol digest types and an owning public ledger that loads canonical genesis, enforces exact block height and input bounds, executes admitted transfers against tentative state, and publishes canonical receipts and commitments at one no-throw commit point. Cover frozen outputs, public error boundaries, ordering, duplicates, resource limits, failure precedence, determinism, and ownership semantics across the full compiler and sanitizer matrix. Document adapter lifetimes and operational exception handling. Refs #8 --- CMakeLists.txt | 13 + docs/README.md | 2 + docs/architecture/ledger-kernel.md | 133 ++++++++ docs/project/current-state.md | 38 ++- include/protocol/v1/admission.hpp | 2 +- include/protocol/v1/ledger.hpp | 67 ++++ include/protocol/v1/types.hpp | 48 ++- src/v1/admission.cpp | 12 +- src/v1/commitments.cpp | 30 +- src/v1/commitments.hpp | 15 +- src/v1/genesis.cpp | 19 +- src/v1/ledger.cpp | 146 ++++++++ tests/kernel/admission_test.cpp | 7 +- tests/kernel/block_test.cpp | 520 +++++++++++++++++++++++++++++ tests/kernel/commitments_test.cpp | 101 +++--- tests/kernel/execution_test.cpp | 27 +- tests/kernel/genesis_test.cpp | 17 +- 17 files changed, 1087 insertions(+), 110 deletions(-) create mode 100644 docs/architecture/ledger-kernel.md create mode 100644 include/protocol/v1/ledger.hpp create mode 100644 src/v1/ledger.cpp create mode 100644 tests/kernel/block_test.cpp diff --git a/CMakeLists.txt b/CMakeLists.txt index 240a534..fe0f5db 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -82,6 +82,7 @@ add_library( src/v1/crypto.cpp src/v1/execution.cpp src/v1/genesis.cpp + src/v1/ledger.cpp ) target_include_directories( protocol_kernel @@ -108,6 +109,11 @@ add_executable( 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) foreach( protocol_stack_target @@ -119,6 +125,7 @@ foreach( kernel_execution_tests kernel_genesis_tests kernel_commitment_tests + kernel_block_tests ) target_compile_features(${protocol_stack_target} PRIVATE cxx_std_20) target_compile_definitions( @@ -193,6 +200,12 @@ add_test( 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" +) set_tests_properties( protocol-primitives-python ledger-transition-python 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..b5cc16c --- /dev/null +++ b/docs/architecture/ledger-kernel.md @@ -0,0 +1,133 @@ +# 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. + +## 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/project/current-state.md b/docs/project/current-state.md index b4c4a8d..1d6df94 100644 --- a/docs/project/current-state.md +++ b/docs/project/current-state.md @@ -42,9 +42,14 @@ vectors. - 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 issue #8 in-memory kernel branch implements strict transaction admission - and checked transfer execution. It does not yet implement genesis loading, - commitments, ordered block commit, persistence, networking, or deployment. +- 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 @@ -81,17 +86,32 @@ vectors. 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 execution slice adds no raw-byte entry point; fuzzing remains required - when the variable-length production genesis decoder is introduced. +- 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. +- Variable-length genesis and transaction byte entry points are now present; + bounded fuzz smoke coverage is required before issue #8 is complete. ## Exact next action Continue issue #8: -> Implement production genesis loading and commitments plus a public state -> owner that encapsulates immutable parameters, enforces the exact next height, -> and atomically commits ordered block results against the frozen vectors -> before fuzz/property and 10,000 seeded differential sequences. +> Add deterministic property/invariant coverage, bounded transaction/genesis +> fuzz targets with Clang sanitizer CI smoke, and an independent Python model +> that differentially checks at least 10,000 seeded ordered transaction +> sequences against the public C++ ledger; then run every repository gate and +> prepare the coherent issue #8 pull request. ## Open autonomous decisions diff --git a/include/protocol/v1/admission.hpp b/include/protocol/v1/admission.hpp index 643e913..b4d5f1d 100644 --- a/include/protocol/v1/admission.hpp +++ b/include/protocol/v1/admission.hpp @@ -7,6 +7,6 @@ namespace protocol::v1 { Admission admit_transfer(std::span raw_transaction, - const Hash& expected_chain_id); + const ChainId& expected_chain_id); } // 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 index 7e070dd..4c6cc0e 100644 --- a/include/protocol/v1/types.hpp +++ b/include/protocol/v1/types.hpp @@ -2,8 +2,10 @@ #include #include +#include #include #include +#include #include #include @@ -12,6 +14,40 @@ 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; @@ -20,7 +56,7 @@ struct Account { }; struct Parameters { - Hash chain_id; + ChainId chain_id; std::uint64_t supply_limit; std::uint64_t total_supply; std::uint64_t fixed_fee; @@ -32,7 +68,7 @@ struct State { Parameters parameters; std::uint64_t height; std::uint64_t fee_pool; - std::map accounts; + std::map accounts; bool operator==(const State&) const = default; }; @@ -64,10 +100,10 @@ enum class TransferResult : std::uint8_t { }; struct Transfer { - Hash sender_id; - Hash transaction_id; + AccountId sender_id; + TransactionId transaction_id; std::uint64_t nonce; - Hash recipient; + AccountId recipient; std::uint64_t amount; std::uint64_t fee_limit; std::uint64_t valid_until; @@ -76,7 +112,7 @@ struct Transfer { using Admission = std::variant; struct Receipt { - Hash transaction_id; + TransactionId transaction_id; TransferResult result; std::uint64_t fee_charged; diff --git a/src/v1/admission.cpp b/src/v1/admission.cpp index 60cbcc0..1c7c4bd 100644 --- a/src/v1/admission.cpp +++ b/src/v1/admission.cpp @@ -34,10 +34,10 @@ std::uint64_t read_u64(std::span raw, return result; } -Hash sender_id(std::span public_key) { +AccountId sender_id(std::span public_key) { Bytes payload{1}; payload.insert(payload.end(), public_key.begin(), public_key.end()); - return hash("protocol-stack:v1:account", payload); + return AccountId(hash("protocol-stack:v1:account", payload)); } Bytes signing_message(std::span unsigned_transaction) { @@ -52,9 +52,9 @@ Bytes signing_message(std::span unsigned_transaction) { } // namespace Admission admit_transfer(std::span raw, - const Hash& expected_chain_id) { + const ChainId& expected_chain_id) { if (!valid_shape(raw)) return AdmissionError::malformed_transaction; - if (fixed_32(raw, 7) != expected_chain_id) { + if (ChainId(fixed_32(raw, 7)) != expected_chain_id) { return AdmissionError::wrong_chain; } const auto public_key = raw.subspan(40, 32); @@ -64,9 +64,9 @@ Admission admit_transfer(std::span raw, } return Transfer{ sender_id(public_key), - hash("protocol-stack:v1:tx-id", raw), + TransactionId(hash("protocol-stack:v1:tx-id", raw)), read_u64(raw, 72), - fixed_32(raw, 80), + AccountId(fixed_32(raw, 80)), read_u64(raw, 112), read_u64(raw, 120), read_u64(raw, 128), diff --git a/src/v1/commitments.cpp b/src/v1/commitments.cpp index 33b9bd2..ab20400 100644 --- a/src/v1/commitments.cpp +++ b/src/v1/commitments.cpp @@ -37,7 +37,8 @@ std::size_t merkle_split(std::size_t count) { return split; } -std::span item_bytes(const Hash& value) { +template +std::span item_bytes(const TaggedHash& value) { return {value.data(), value.size()}; } @@ -64,7 +65,7 @@ Hash merkle(std::span items, std::string_view empty_label, return protocol::v1::hash(node_label, children); } -Bytes account_entry(const Hash& identifier, const Account& account) { +Bytes account_entry(const AccountId& identifier, const Account& account) { Bytes entry; entry.reserve(48); append(entry, identifier); @@ -122,13 +123,16 @@ StateCommitment state_root(const State& state) { } append_u64(payload, static_cast(entries.size())); append(payload, accounts_root); - return protocol::v1::hash("protocol-stack:v1:state-root", payload); + return StateRoot( + protocol::v1::hash("protocol-stack:v1:state-root", payload)); } -Hash transaction_root(std::span transaction_ids) { - return merkle(transaction_ids, "protocol-stack:v1:tx-empty", - "protocol-stack:v1:tx-leaf", - "protocol-stack:v1:tx-node"); +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, @@ -149,10 +153,10 @@ std::optional encode_receipt(const Receipt& receipt, return encoded; } -Bytes encode_block_header(const Hash& chain_id, std::uint64_t height, - const Hash& previous_state_root, - const Hash& transaction_root_value, - const Hash& resulting_state_root, +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); @@ -166,14 +170,14 @@ Bytes encode_block_header(const Hash& chain_id, std::uint64_t height, return encoded; } -std::optional block_id(std::span header) { +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 protocol::v1::hash("protocol-stack:v1:block-id", header); + 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 index 3db498e..074e8d4 100644 --- a/src/v1/commitments.hpp +++ b/src/v1/commitments.hpp @@ -15,21 +15,22 @@ enum class StateError : std::uint8_t { supply_mismatch = 3, }; -using StateCommitment = std::variant; +using StateCommitment = std::variant; StateCommitment state_root(const State& state); -Hash transaction_root(std::span transaction_ids); +TransactionRoot transaction_root( + std::span transaction_ids); std::optional encode_receipt(const Receipt& receipt, std::uint64_t fixed_fee); -Bytes encode_block_header(const Hash& chain_id, std::uint64_t height, - const Hash& previous_state_root, - const Hash& transaction_root, - const Hash& resulting_state_root, +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); +std::optional block_id(std::span header); } // namespace protocol::v1::internal diff --git a/src/v1/genesis.cpp b/src/v1/genesis.cpp index 5a92dca..6731516 100644 --- a/src/v1/genesis.cpp +++ b/src/v1/genesis.cpp @@ -66,18 +66,19 @@ std::variant decode_fields( std::optional validate_accounts( std::span canonical_genesis, const GenesisFields& fields) { - std::optional previous_identifier; + 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 identifier = read_fixed<32>(canonical_genesis, offset); + 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 (!identifier || !balance || !nonce) { + if (!raw_identifier || !balance || !nonce) { return GenesisError::malformed; } + const AccountId identifier(*raw_identifier); if (*balance == 0 || *nonce != 0 || - (previous_identifier && !(*previous_identifier < *identifier))) { + (previous_identifier && !(*previous_identifier < identifier))) { return GenesisError::invalid_accounts; } if (*balance > @@ -85,7 +86,7 @@ std::optional validate_accounts( return GenesisError::invalid_supply; } conserved_supply += *balance; - previous_identifier = *identifier; + previous_identifier = identifier; } if (conserved_supply != fields.total_supply) { return GenesisError::invalid_supply; @@ -93,13 +94,13 @@ std::optional validate_accounts( return std::nullopt; } -std::map decode_accounts( +std::map decode_accounts( std::span canonical_genesis, std::uint32_t account_count) { - std::map accounts; + std::map accounts; for (std::size_t index = 0; index < account_count; ++index) { const auto offset = kGenesisPrefixSize + index * kAccountSize; - const auto identifier = *read_fixed<32>(canonical_genesis, offset); + 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}); @@ -129,7 +130,7 @@ GenesisDecode decode_genesis( auto accounts = decode_accounts(canonical_genesis, fields.account_count); return State{ Parameters{ - hash("protocol-stack:v1:chain-id", canonical_genesis), + ChainId(hash("protocol-stack:v1:chain-id", canonical_genesis)), fields.supply_limit, fields.total_supply, fields.fixed_fee, 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/kernel/admission_test.cpp b/tests/kernel/admission_test.cpp index 4b3cfb0..c5eb6c1 100644 --- a/tests/kernel/admission_test.cpp +++ b/tests/kernel/admission_test.cpp @@ -9,16 +9,17 @@ namespace pv = protocol_vectors; namespace p = protocol::v1; -p::Hash hash_value(const pv::Bytes& bytes) { +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 result; + return Tagged{result}; } void verify_admission_vectors(const pv::Values& values) { const auto chain_id = - hash_value(pv::hex_decode(values.at("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); 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 index 56cc53b..c7b53ed 100644 --- a/tests/kernel/commitments_test.cpp +++ b/tests/kernel/commitments_test.cpp @@ -20,30 +20,39 @@ namespace pc = protocol::v1::internal; namespace { -p::Hash hash_value(const pv::Bytes& bytes, std::size_t offset = 0) { +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 result; + return Tagged{result}; } -pv::Bytes bytes(const p::Hash& value) { +template +pv::Bytes bytes(const Tagged& value) { return {value.begin(), value.end()}; } -std::pair decode_account(std::string_view encoded) { +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 { - hash_value(entry), + 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; +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, @@ -63,7 +72,8 @@ std::size_t genesis_account_count(const pv::Values& values) { p::Parameters parameters(const pv::Values& values) { return p::Parameters{ - hash_value(pv::hex_decode(values.at("chain_id"))), + 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")), @@ -101,7 +111,7 @@ std::vector account_entries(const p::State& state) { return entries; } -p::Hash expected_state_root(const p::State& state) { +p::StateRoot expected_state_root(const p::State& state) { const auto entries = account_entries(state); pv::Bytes payload; pv::append_u16(payload, 1); @@ -112,14 +122,15 @@ p::Hash expected_state_root(const p::State& state) { pv::append_u64(payload, state.fee_pool); pv::append_u64(payload, entries.size()); pv::append(payload, pv::merkle(entries, "state")); - return hash_value(pv::hash("protocol-stack:v1:state-root", payload)); + return tagged_hash( + pv::hash("protocol-stack:v1:state-root", payload)); } -p::Hash require_state_root(const p::State& state) { +p::StateRoot require_state_root(const p::State& state) { const auto commitment = pc::state_root(state); - pv::require(std::holds_alternative(commitment), + pv::require(std::holds_alternative(commitment), "expected state root"); - return std::get(commitment); + return std::get(commitment); } void require_state_error(const p::State& state, pc::StateError expected) { @@ -143,16 +154,18 @@ void verify_final_entries(const p::State& state, const pv::Values& values) { } } -std::vector verify_receipts(const pv::Values& values) { +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; + 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 = hash_value(expected, 6); + const auto transaction_id = + tagged_hash(expected, 6); const p::Receipt receipt{ transaction_id, static_cast(expected[38]), @@ -172,17 +185,20 @@ void verify_frozen_commitments(const pv::Values& values) { const auto previous_root = require_state_root(previous_state); const auto resulting_root = require_state_root(resulting_state); pv::require(previous_root == - hash_value(pv::hex_decode(values.at("previous_state_root"))), + tagged_hash( + pv::hex_decode(values.at("previous_state_root"))), "previous state root"); pv::require(resulting_root == - hash_value(pv::hex_decode(values.at("resulting_state_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 == - hash_value(pv::hex_decode(values.at("transaction_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, @@ -193,26 +209,28 @@ void verify_frozen_commitments(const pv::Values& values) { const auto encoded_block_id = pc::block_id(header); pv::require(encoded_block_id && *encoded_block_id == - hash_value(pv::hex_decode(values.at("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); +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][offset] = + ids[index].data()[offset] = static_cast((index + 1) * 17 + offset); } } return ids; } -p::Hash expected_transaction_root(std::span 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 hash_value(pv::merkle(encoded, "tx")); + return tagged_hash(pv::merkle(encoded, "tx")); } void verify_merkle_shapes() { @@ -220,7 +238,8 @@ void verify_merkle_shapes() { 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); + const auto view = + std::span(ids).first(count); pv::require(pc::transaction_root(view) == expected_transaction_root(view), "transaction Merkle shape"); @@ -228,25 +247,28 @@ void verify_merkle_shapes() { auto reordered = sample_ids(); std::swap(reordered[1], reordered[3]); - const auto first_five = std::span(ids).first(5); + 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][7] ^= 1U; + 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{ids.front(), 100, count + 1, 1}, + p::Parameters{retag(ids.front()), 100, count + 1, 1}, 7, 1, {}, }; for (std::size_t index = count; index > 0; --index) { - state.accounts.emplace(ids[index - 1], p::Account{1, index - 1}); + 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"); @@ -256,10 +278,10 @@ void verify_merkle_shapes() { void verify_state_errors() { auto ids = sample_ids(); p::State state{ - p::Parameters{ids.front(), 100, 10, 1}, + p::Parameters{retag(ids.front()), 100, 10, 1}, 0, 0, - {{ids[1], p::Account{10, 0}}}, + {{retag(ids[1]), p::Account{10, 0}}}, }; auto invalid = state; @@ -291,7 +313,7 @@ void verify_state_errors() { void verify_invalid_receipts(const pv::Values& values) { const auto fixed_fee = std::stoull(values.at("fixed_fee")); - const p::Hash transaction_id{}; + const p::TransactionId transaction_id{}; pv::require( !pc::encode_receipt( p::Receipt{transaction_id, static_cast(9), 0}, @@ -313,15 +335,18 @@ void verify_invalid_receipts(const pv::Values& values) { "invalid failed fee"); auto header = pc::encode_block_header( - transaction_id, 1, transaction_id, transaction_id, transaction_id, 0); + 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( - transaction_id, 1, transaction_id, transaction_id, transaction_id, 0); + 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( - transaction_id, 1, transaction_id, transaction_id, transaction_id, 0); + p::ChainId{}, 1, p::StateRoot{}, p::TransactionRoot{}, + p::StateRoot{}, 0); header[5] = 2; pv::require(!pc::block_id(header), "block header version"); } diff --git a/tests/kernel/execution_test.cpp b/tests/kernel/execution_test.cpp index 0de09cd..34992ca 100644 --- a/tests/kernel/execution_test.cpp +++ b/tests/kernel/execution_test.cpp @@ -23,28 +23,31 @@ namespace { constexpr std::uint64_t kBlockHeight = 1; -p::Hash hash_value(const pv::Bytes& bytes, std::size_t offset = 0) { +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 result; + return Tagged{result}; } -void append_hash(pv::Bytes& target, const p::Hash& value) { +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) { +std::pair decode_account( + std::string_view encoded) { const auto bytes = pv::hex_decode(encoded); pv::require(bytes.size() == 48, "account entry size"); return { - hash_value(bytes), + 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; + 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; @@ -56,7 +59,8 @@ p::State initial_state(const pv::Values& values) { pv::require(genesis.size() >= 42, "truncated genesis"); return p::State{ p::Parameters{ - hash_value(pv::hex_decode(values.at("chain_id"))), + 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")), @@ -107,7 +111,8 @@ void verify_receipt(const p::Receipt& receipt, const p::Transfer& transfer, 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 == hash_value(expected_bytes, 6), + pv::require(receipt.transaction_id == + tagged_hash(expected_bytes, 6), "receipt transaction ID vector"); pv::require(static_cast(receipt.result) == expected_code, "transfer result"); @@ -190,7 +195,7 @@ void verify_nonce_exhaustion(const pv::Values& values, sender->second.nonce = std::numeric_limits::max(); const p::Transfer transfer{ sender->first, - p::Hash{}, + p::TransactionId{}, 0, recipient->first, 1, @@ -229,7 +234,7 @@ void verify_internal_overflow_atomicity(const pv::Values& values) { recipient->second.balance = std::numeric_limits::max(); const p::Transfer recipient_overflow{ sender->first, - p::Hash{}, + p::TransactionId{}, 1, recipient->first, 1, @@ -250,7 +255,7 @@ void verify_internal_overflow_atomicity(const pv::Values& values) { const auto fee_sender = fee_state.accounts.begin(); const p::Transfer fee_overflow{ fee_sender->first, - p::Hash{}, + p::TransactionId{}, 1, fee_sender->first, 1, diff --git a/tests/kernel/genesis_test.cpp b/tests/kernel/genesis_test.cpp index e3fa515..ffb9d2f 100644 --- a/tests/kernel/genesis_test.cpp +++ b/tests/kernel/genesis_test.cpp @@ -22,7 +22,7 @@ constexpr std::size_t kAccountSize = 48; constexpr std::uint32_t kMaximumAccounts = 21'844; struct GenesisAccount { - p::Hash identifier; + p::AccountId identifier; std::uint64_t balance; std::uint64_t nonce; }; @@ -33,17 +33,18 @@ void append_u32(p::Bytes& target, std::uint32_t value) { } } -void append_hash(p::Bytes& target, const p::Hash& value) { +template +void append_hash(p::Bytes& target, const Tagged& value) { target.insert(target.end(), value.begin(), value.end()); } -p::Hash identifier(std::uint64_t value) { +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 result; + return p::AccountId{result}; } p::Bytes genesis_prefix(std::uint64_t supply_limit, @@ -100,11 +101,13 @@ 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{}; + p::Hash expected_chain_bytes{}; const auto chain_bytes = pv::hex_decode(values.at("chain_id")); - pv::require(chain_bytes.size() == expected_chain.size(), + pv::require(chain_bytes.size() == expected_chain_bytes.size(), "frozen chain ID size"); - std::copy(chain_bytes.begin(), chain_bytes.end(), expected_chain.begin()); + 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")), From f92e71c4b46a5bf09fbff7928d4b8539906cf649 Mon Sep 17 00:00:00 2001 From: Giorgi Chomakhashvili <133794518+kaikisegfault@users.noreply.github.com> Date: Thu, 23 Jul 2026 17:55:32 +0200 Subject: [PATCH 7/9] test(kernel): differentially verify ledger sequences Add 9,000 deterministic invariant scenarios with exact success-state comparison and first-error precedence, plus an independent standard-library Python model and public C++ runner. The fixed SplitMix64-v1 corpus checks 10,000 nonempty randomized sequences and 11 directed sequences after every successful block, including admission and execution outcomes, typed and encoded receipts, full state, roots, headers, and block IDs. Random coverage is enforced independently. Refs #8 --- CMakeLists.txt | 32 +++ docs/project/current-state.md | 27 +- tests/differential/cases.py | 200 +++++++++++++++ tests/differential/coverage.py | 86 +++++++ tests/differential/kernel_runner.cpp | 232 +++++++++++++++++ tests/differential/model.py | 361 +++++++++++++++++++++++++++ tests/differential/pinned_sodium.py | 99 ++++++++ tests/differential/protocol_bytes.py | 55 ++++ tests/differential/random_cases.py | 199 +++++++++++++++ tests/differential/run.py | 166 ++++++++++++ tests/differential/transcript.py | 84 +++++++ tests/kernel/property_test.cpp | 300 ++++++++++++++++++++++ 12 files changed, 1836 insertions(+), 5 deletions(-) create mode 100644 tests/differential/cases.py create mode 100644 tests/differential/coverage.py create mode 100644 tests/differential/kernel_runner.cpp create mode 100644 tests/differential/model.py create mode 100644 tests/differential/pinned_sodium.py create mode 100644 tests/differential/protocol_bytes.py create mode 100644 tests/differential/random_cases.py create mode 100644 tests/differential/run.py create mode 100644 tests/differential/transcript.py create mode 100644 tests/kernel/property_test.cpp diff --git a/CMakeLists.txt b/CMakeLists.txt index fe0f5db..cf00dd0 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -114,6 +114,16 @@ add_executable( 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 @@ -126,6 +136,8 @@ foreach( kernel_genesis_tests kernel_commitment_tests kernel_block_tests + kernel_property_tests + kernel_differential_runner ) target_compile_features(${protocol_stack_target} PRIVATE cxx_std_20) target_compile_definitions( @@ -206,6 +218,21 @@ add_test( 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 @@ -213,3 +240,8 @@ set_tests_properties( ENVIRONMENT "PROTOCOL_STACK_LIBSODIUM=${PROTOCOL_STACK_SODIUM_SHARED_LIBRARY}" ) +set_tests_properties( + kernel-differential + PROPERTIES + TIMEOUT 300 +) diff --git a/docs/project/current-state.md b/docs/project/current-state.md index 1d6df94..a49b579 100644 --- a/docs/project/current-state.md +++ b/docs/project/current-state.md @@ -100,6 +100,25 @@ vectors. 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. - Variable-length genesis and transaction byte entry points are now present; bounded fuzz smoke coverage is required before issue #8 is complete. @@ -107,11 +126,9 @@ vectors. Continue issue #8: -> Add deterministic property/invariant coverage, bounded transaction/genesis -> fuzz targets with Clang sanitizer CI smoke, and an independent Python model -> that differentially checks at least 10,000 seeded ordered transaction -> sequences against the public C++ ledger; then run every repository gate and -> prepare the coherent issue #8 pull request. +> Add bounded transaction-admission and genesis-decoder libFuzzer targets with +> Clang ASan+UBSan CI smoke, then run every repository gate, self-review the +> complete issue #8 diff, and prepare its coherent pull request. ## Open autonomous decisions 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/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; + } +} From 8010bc0e9572438fdb4b480a52822d96f28fd497 Mon Sep 17 00:00:00 2001 From: Giorgi Chomakhashvili <133794518+kaikisegfault@users.noreply.github.com> Date: Thu, 23 Jul 2026 18:26:45 +0200 Subject: [PATCH 8/9] feat(kernel): verify canonical primitive boundaries Run the unchanged primitive vectors through the production hash, signature, admission, Bech32m address, and commitment paths. Add bounded Clang libFuzzer smoke targets for admission, address decoding, and genesis loading under ASan and UBSan. The generic commitment path now reproduces the specified zero-supply empty-state root; canonical genesis continues to require nonzero supply. Refs #8 --- CMakeLists.txt | 169 +++++++++++-- CMakePresets.json | 1 + docs/architecture/ledger-kernel.md | 15 ++ docs/engineering/build-toolchain.md | 14 +- docs/engineering/verification.md | 11 +- docs/project/current-state.md | 23 +- include/protocol/v1/address.hpp | 20 ++ src/v1/address.cpp | 190 +++++++++++++++ src/v1/commitments.cpp | 3 +- tests/fuzz/address_fuzz.cpp | 84 +++++++ tests/fuzz/admission_fuzz.cpp | 108 +++++++++ tests/fuzz/genesis_fuzz.cpp | 76 ++++++ tests/kernel/commitments_test.cpp | 2 +- tests/kernel/primitives_test.cpp | 364 ++++++++++++++++++++++++++++ 14 files changed, 1042 insertions(+), 38 deletions(-) create mode 100644 include/protocol/v1/address.hpp create mode 100644 src/v1/address.cpp create mode 100644 tests/fuzz/address_fuzz.cpp create mode 100644 tests/fuzz/admission_fuzz.cpp create mode 100644 tests/fuzz/genesis_fuzz.cpp create mode 100644 tests/kernel/primitives_test.cpp diff --git a/CMakeLists.txt b/CMakeLists.txt index cf00dd0..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,21 +84,27 @@ add_executable( ledger_transition_vectors tools/ledger-vectors/verify.cpp ) -add_library( - protocol_kernel - STATIC - src/v1/admission.cpp - src/v1/commitments.cpp - src/v1/crypto.cpp - src/v1/execution.cpp - src/v1/genesis.cpp - src/v1/ledger.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 @@ -125,20 +141,57 @@ add_executable( ) target_link_libraries(kernel_differential_runner PRIVATE protocol_kernel) -foreach( - protocol_stack_target - IN ITEMS - protocol_primitive_vectors - ledger_transition_vectors - protocol_kernel - kernel_admission_tests - kernel_execution_tests - kernel_genesis_tests - kernel_commitment_tests - kernel_block_tests - kernel_property_tests - kernel_differential_runner +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} @@ -161,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 @@ -188,6 +270,12 @@ 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 @@ -245,3 +333,40 @@ set_tests_properties( 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/architecture/ledger-kernel.md b/docs/architecture/ledger-kernel.md index b5cc16c..49fc25f 100644 --- a/docs/architecture/ledger-kernel.md +++ b/docs/architecture/ledger-kernel.md @@ -120,6 +120,21 @@ 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`, 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 a49b579..7d0a905 100644 --- a/docs/project/current-state.md +++ b/docs/project/current-state.md @@ -79,6 +79,12 @@ vectors. 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 @@ -119,16 +125,23 @@ vectors. 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. -- Variable-length genesis and transaction byte entry points are now present; - bounded fuzz smoke coverage is required before issue #8 is complete. +- 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 Continue issue #8: -> Add bounded transaction-admission and genesis-decoder libFuzzer targets with -> Clang ASan+UBSan CI smoke, then run every repository gate, self-review the -> complete issue #8 diff, and prepare its coherent pull request. +> Commit and push the verified production-primitive and fuzz slice, open the +> coherent pull request with exact local evidence, and monitor all four +> required GitHub checks to a terminal result. ## Open autonomous decisions 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/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/commitments.cpp b/src/v1/commitments.cpp index ab20400..aaba80f 100644 --- a/src/v1/commitments.cpp +++ b/src/v1/commitments.cpp @@ -83,8 +83,7 @@ bool valid_result(TransferResult result) { StateCommitment state_root(const State& state) { const auto& parameters = state.parameters; - if (parameters.supply_limit == 0 || parameters.total_supply == 0 || - parameters.fixed_fee == 0 || + if (parameters.supply_limit == 0 || parameters.fixed_fee == 0 || parameters.total_supply > parameters.supply_limit) { return StateError::invalid_parameters; } 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/commitments_test.cpp b/tests/kernel/commitments_test.cpp index c7b53ed..e03773d 100644 --- a/tests/kernel/commitments_test.cpp +++ b/tests/kernel/commitments_test.cpp @@ -289,7 +289,7 @@ void verify_state_errors() { require_state_error(invalid, pc::StateError::invalid_parameters); invalid = state; invalid.parameters.total_supply = 0; - require_state_error(invalid, pc::StateError::invalid_parameters); + require_state_error(invalid, pc::StateError::supply_mismatch); invalid = state; invalid.parameters.fixed_fee = 0; require_state_error(invalid, pc::StateError::invalid_parameters); 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; + } +} From 3a50ce3a1f46c28a41fb8f240c70cff195a4ed1d Mon Sep 17 00:00:00 2001 From: Giorgi Chomakhashvili <133794518+kaikisegfault@users.noreply.github.com> Date: Thu, 23 Jul 2026 18:32:20 +0200 Subject: [PATCH 9/9] docs(project): record kernel pull request Point the durable handoff at PR #10 and the next persistence dependency so main will not inherit an obsolete branch-opening action. Refs #8 --- docs/project/current-state.md | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/docs/project/current-state.md b/docs/project/current-state.md index 7d0a905..b468cd3 100644 --- a/docs/project/current-state.md +++ b/docs/project/current-state.md @@ -16,6 +16,7 @@ vectors. 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. @@ -139,9 +140,10 @@ vectors. Continue issue #8: -> Commit and push the verified production-primitive and fuzz slice, open the -> coherent pull request with exact local evidence, and monitor all four -> required GitHub checks to a terminal result. +> 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