From c35457a137ea0ef21fb96f1b4f2662446659df09 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pawe=C5=82=20Bylica?= Date: Thu, 23 Jul 2026 19:43:21 +0200 Subject: [PATCH] state: Validate transaction chain id against the chain Load config.chainid into BlockInfo (state and blockchain loaders), use it for the CHAINID opcode (was hardcoded 1), and reject a mismatching tx chain id; only a legacy tx may use 0 (unspecified, EIP-155). Fixes the EEST invalid_chain_id cases. --- test/state/block.hpp | 3 +++ test/state/errors.hpp | 3 +++ test/state/host.cpp | 2 +- test/state/state.cpp | 6 +++++ test/unittests/state_transition.hpp | 1 + test/unittests/state_transition_tx_test.cpp | 26 +++++++++++++++++++++ test/unittests/state_tx_test.cpp | 2 ++ test/utils/blockchaintest_loader.cpp | 6 +++++ test/utils/rlp_encode.cpp | 2 ++ test/utils/statetest_export.cpp | 3 +++ test/utils/statetest_loader.cpp | 12 +++++++--- test/utils/t8n.cpp | 1 + test/utils/t8n.hpp | 2 +- tools/evmone/main.cpp | 2 +- 14 files changed, 65 insertions(+), 6 deletions(-) diff --git a/test/state/block.hpp b/test/state/block.hpp index 5f1511f09e..fa0ff49b45 100644 --- a/test/state/block.hpp +++ b/test/state/block.hpp @@ -51,6 +51,9 @@ struct BlockInfo /// The EIP-1559 base fee, since London. uint64_t base_fee = 0; + /// The chain id transactions must target. + uint64_t chain_id = 1; + /// The "blob gas used" parameter from EIP-4844 std::optional blob_gas_used; diff --git a/test/state/errors.hpp b/test/state/errors.hpp index 98a7efc385..8c4afd64c2 100644 --- a/test/state/errors.hpp +++ b/test/state/errors.hpp @@ -31,6 +31,7 @@ enum ErrorCode : int // NOLINT(*-use-enum-class) CREATE_SET_CODE_TX, EMPTY_AUTHORIZATION_LIST, MAX_GAS_LIMIT_EXCEEDED, + INVALID_CHAIN_ID, UNKNOWN_ERROR, // Block-level validation. @@ -100,6 +101,8 @@ inline const std::error_category& evmone_category() noexcept return "empty authorization list"; case MAX_GAS_LIMIT_EXCEEDED: return "max gas limit exceeded"; + case INVALID_CHAIN_ID: + return "invalid transaction chain id"; case UNKNOWN_ERROR: return "Unknown error"; case INCORRECT_BLOCK_FORMAT: diff --git a/test/state/host.cpp b/test/state/host.cpp index 5ccd5dbae9..a56b3e79d0 100644 --- a/test/state/host.cpp +++ b/test/state/host.cpp @@ -360,7 +360,7 @@ evmc_tx_context Host::get_tx_context() const noexcept m_block.timestamp, m_block.gas_limit, m_block.prev_randao, - 0x01_bytes32, // Chain ID is expected to be 1. + uint256be{m_block.chain_id}, uint256be{m_block.base_fee}, intx::be::store(m_block.blob_base_fee.value_or(0)), m_tx.blob_hashes.data(), diff --git a/test/state/state.cpp b/test/state/state.cpp index 4dfb53a1e1..97b69d39bf 100644 --- a/test/state/state.cpp +++ b/test/state/state.cpp @@ -449,6 +449,12 @@ std::variant validate_transaction( const StateView& state_view, const BlockInfo& block, const Transaction& tx, evmc_revision rev, int64_t block_gas_left, int64_t blob_gas_left) noexcept { + // chain_id must match the chain; a legacy tx may use 0 (unspecified, EIP-155). + // TODO: chain_id 0 is not "unspecified" for a legacy tx protected for chain 0 (v 35/36); + // telling it apart from an unprotected tx (v 27/28) needs the signature v from txbytes. + if (tx.chain_id != block.chain_id && (tx.type != Transaction::Type::legacy || tx.chain_id != 0)) + return make_error_code(INVALID_CHAIN_ID); + switch (tx.type) // Validate "special" transaction types. { case Transaction::Type::blob: diff --git a/test/unittests/state_transition.hpp b/test/unittests/state_transition.hpp index eebfc94dd9..b3828854dd 100644 --- a/test/unittests/state_transition.hpp +++ b/test/unittests/state_transition.hpp @@ -93,6 +93,7 @@ class state_transition : public ExportableFixture .max_gas_price = block.base_fee + 1, .max_priority_gas_price = block.base_fee + 1, .sender = Sender, + .chain_id = 1, .nonce = 1, }; TestState pre; diff --git a/test/unittests/state_transition_tx_test.cpp b/test/unittests/state_transition_tx_test.cpp index 1114226e00..beaf656835 100644 --- a/test/unittests/state_transition_tx_test.cpp +++ b/test/unittests/state_transition_tx_test.cpp @@ -50,6 +50,32 @@ TEST_F(state_transition, invalid_tx_non_existing_sender) expect.post[Sender].exists = false; } +TEST_F(state_transition, invalid_tx_wrong_chain_id) +{ + tx.to = To; + tx.chain_id = 2; // Mismatches the block chain id (1). + expect.tx_error = INVALID_CHAIN_ID; +} + +TEST_F(state_transition, invalid_tx_wrong_chain_id_legacy) +{ + tx.type = Transaction::Type::legacy; + tx.to = To; + tx.chain_id = 2; // Mismatches the block chain id (1). + expect.tx_error = INVALID_CHAIN_ID; +} + +TEST_F(state_transition, tx_legacy_unprotected_chain_id) +{ + rev = EVMC_ISTANBUL; + block.base_fee = 0; // should be 0 before London + tx.type = Transaction::Type::legacy; + tx.to = To; + tx.chain_id = 0; // Unprotected legacy tx is valid on any chain (pre-EIP-155). + + expect.post.at(Sender).nonce = pre[Sender].nonce + 1; +} + TEST_F(state_transition, tx_blob_gas_price) { rev = EVMC_CANCUN; diff --git a/test/unittests/state_tx_test.cpp b/test/unittests/state_tx_test.cpp index 4084080e8b..64d8f5839a 100644 --- a/test/unittests/state_tx_test.cpp +++ b/test/unittests/state_tx_test.cpp @@ -85,6 +85,7 @@ TEST(state_tx, validate_blob_tx) .gas_limit = 60000, .max_gas_price = block.base_fee, .sender = 0x02_address, + .chain_id = 1, }; const TestState state{{tx.sender, {.balance = 1'000'000}}}; @@ -216,6 +217,7 @@ TEST(state_tx, max_blob_count) .max_blob_gas_price = 1, .sender = 0x02_address, .to = 0x01_address, + .chain_id = 1, }; const TestState state{{tx.sender, {.balance = 1'000'000}}}; const auto blob_gas_limit = diff --git a/test/utils/blockchaintest_loader.cpp b/test/utils/blockchaintest_loader.cpp index 1fca6d5cb3..76f345fca9 100644 --- a/test/utils/blockchaintest_loader.cpp +++ b/test/utils/blockchaintest_loader.cpp @@ -175,10 +175,13 @@ BlockchainTest load_blockchain_test_case(const std::string& name, const json::js bt.pre_state = from_json(j.at("pre")); bt.network = j.at("network").get(); bt.rev = to_rev_schedule(bt.network); + uint64_t chain_id = 1; if (const auto config_it = j.find("config"); config_it != j.end()) { if (const auto bs_it = config_it->find("blobSchedule"); bs_it != config_it->end()) bt.blob_schedule = from_json(*bs_it); + if (const auto cid_it = config_it->find("chainid"); cid_it != config_it->end()) + chain_id = from_json(*cid_it); } for (const auto& el : j.at("blocks")) { @@ -206,6 +209,9 @@ BlockchainTest load_blockchain_test_case(const std::string& name, const json::js } } + for (auto& tb : bt.test_blocks) + tb.block_info.chain_id = chain_id; + bt.expectation.last_block_hash = from_json(j.at("lastblockhash")); if (const auto it = j.find("postState"); it != j.end()) diff --git a/test/utils/rlp_encode.cpp b/test/utils/rlp_encode.cpp index 8c9e25ca0f..2e92dd8468 100644 --- a/test/utils/rlp_encode.cpp +++ b/test/utils/rlp_encode.cpp @@ -22,6 +22,8 @@ namespace evmone::state if (tx.type == Transaction::Type::legacy) { // rlp [nonce, gas_price, gas_limit, to, value, data, v, r, s]; + // v is written verbatim. TODO: chain_id 0 is ambiguous — unprotected (v 27/28) vs + // protected for chain 0 (v 35/36) — so v must not be re-derived from chain_id. return rlp::encode_tuple(tx.nonce, tx.max_gas_price, static_cast(tx.gas_limit), tx.to.has_value() ? tx.to.value() : bytes_view(), tx.value, tx.data, tx.v, tx.r, tx.s); } diff --git a/test/utils/statetest_export.cpp b/test/utils/statetest_export.cpp index ce88b0ce19..eb5dfd8c74 100644 --- a/test/utils/statetest_export.cpp +++ b/test/utils/statetest_export.cpp @@ -61,6 +61,8 @@ std::string_view to_test_fork_name(evmc_revision rev) noexcept return "TR_BLOBVERSION_INVALID"; case BLOB_GAS_LIMIT_EXCEEDED: return "TR_BLOBLIST_OVERSIZE"; + case INVALID_CHAIN_ID: + return "TransactionException.INVALID_CHAINID"; case UNKNOWN_ERROR: return "Unknown error"; default: @@ -118,6 +120,7 @@ json::json to_state_test(std::string_view test_name, const state::BlockInfo& blo jtx["to"] = hex0x(*tx.to); jtx["sender"] = hex0x(tx.sender); jtx["secretKey"] = hex0x(SenderSecretKey); + jtx["chainId"] = hex0x(tx.chain_id); jtx["nonce"] = hex0x(tx.nonce); if (tx.type >= Transaction::Type::eip1559) { diff --git a/test/utils/statetest_loader.cpp b/test/utils/statetest_loader.cpp index d6c8c7f275..06a8419eb9 100644 --- a/test/utils/statetest_loader.cpp +++ b/test/utils/statetest_loader.cpp @@ -520,18 +520,24 @@ static void from_json(const json::json& j_t, StateTransitionTest& o) // LCOV_EXCL_STOP } + uint64_t chain_id = 1; if (const auto config_it = j_t.find("config"); config_it != j_t.end()) { if (const auto bs_it = config_it->find("blobSchedule"); bs_it != config_it->end()) o.blob_schedule = from_json(*bs_it); + if (const auto cid_it = config_it->find("chainid"); cid_it != config_it->end()) + chain_id = from_json(*cid_it); } for (const auto& [rev_name, expectations] : j_t.at("post").items()) { - const auto blob_params = get_blob_params(to_rev(rev_name), o.blob_schedule); - o.cases.emplace_back(to_rev(rev_name), + const auto rev = to_rev(rev_name); + const auto blob_params = get_blob_params(rev, o.blob_schedule); + auto block = from_json_with_rev(j_t.at("env"), rev, blob_params); + block.chain_id = chain_id; + o.cases.emplace_back(rev, expectations.get>(), - from_json_with_rev(j_t.at("env"), to_rev(rev_name), blob_params)); + std::move(block)); } } diff --git a/test/utils/t8n.cpp b/test/utils/t8n.cpp index 1782317df1..5972f1cc3b 100644 --- a/test/utils/t8n.cpp +++ b/test/utils/t8n.cpp @@ -45,6 +45,7 @@ void t8n(evmc::VM& vm, const T8NArgs& args) block = from_json_with_rev(j, rev, blob_params); block_hashes = from_json(j); } + block.chain_id = args.chain_id; JSON j_result; diff --git a/test/utils/t8n.hpp b/test/utils/t8n.hpp index 5d2c500308..34e95dccce 100644 --- a/test/utils/t8n.hpp +++ b/test/utils/t8n.hpp @@ -15,7 +15,7 @@ namespace evmone::tooling struct T8NArgs { evmc_revision rev = {}; - uint64_t chain_id = 0; + uint64_t chain_id = 1; std::optional block_reward; bool pre_state_only = false; diff --git a/tools/evmone/main.cpp b/tools/evmone/main.cpp index 3d64c9f4a3..82e4c1f154 100644 --- a/tools/evmone/main.cpp +++ b/tools/evmone/main.cpp @@ -55,7 +55,7 @@ struct HexOrFileValidator : CLI::Validator struct T8nOptions { std::string state_fork; - uint64_t state_chainid = 0; + uint64_t state_chainid = 1; std::optional state_reward; fs::path alloc_file; fs::path env_file;