From d662cd90b6918360720a8a75624a8563958a72b7 Mon Sep 17 00:00:00 2001 From: badrogger Date: Wed, 1 Apr 2026 19:14:36 +0100 Subject: [PATCH 01/14] Initial Paris fork support --- libethereum/SchainPatch.cpp | 6 ++- libethereum/SchainPatch.h | 6 +++ libethereum/SchainPatchEnum.h | 1 + test/unittests/libevm/VMTest.cpp | 73 +++++++++++++++++++++++++++++++- 4 files changed, 83 insertions(+), 3 deletions(-) diff --git a/libethereum/SchainPatch.cpp b/libethereum/SchainPatch.cpp index 6a8e7afb7..d93131a40 100644 --- a/libethereum/SchainPatch.cpp +++ b/libethereum/SchainPatch.cpp @@ -54,6 +54,8 @@ SchainPatchEnum getEnumForPatchName( const std::string& _patchName ) { return SchainPatchEnum::LondonForkPatch; else if ( _patchName == "ContractCreationReadOnlyPatch" ) return SchainPatchEnum::ContractCreationReadOnlyPatch; + else if ( _patchName == "ParisForkPatch" ) + return SchainPatchEnum::ParisForkPatch; #ifdef BITE else if ( _patchName == "BITE2Patch" || _patchName == "Bite2Patch" ) return SchainPatchEnum::Bite2Patch; @@ -114,6 +116,8 @@ std::string getPatchNameForEnum( SchainPatchEnum _enumValue ) { return "LondonForkPatch"; case SchainPatchEnum::ContractCreationReadOnlyPatch: return "ContractCreationReadOnlyPatch"; + case SchainPatchEnum::ParisForkPatch: + return "ParisForkPatch"; #ifdef BITE case SchainPatchEnum::Bite2Patch: return "Bite2Patch"; @@ -138,7 +142,7 @@ const std::unordered_set< SchainPatchEnum > SchainPatch::preEnabledForFAIR = { SchainPatchEnum::EIP1559TransactionsPatch, SchainPatchEnum::VerifyBlsSyncPatch, SchainPatchEnum::ClearPartialReceiptsPatch, SchainPatchEnum::InvalidTransactionFormatPatch, SchainPatchEnum::CurrentBlockRandomPatch, SchainPatchEnum::GroupIndexInitPatch, - SchainPatchEnum::LondonForkPatch + SchainPatchEnum::LondonForkPatch, SchainPatchEnum::ParisForkPatch }; const std::unordered_set< SchainPatchEnum > SchainPatch::preDisabledForFAIR = { SchainPatchEnum::RevertableFSPatch, SchainPatchEnum::FlexibleDeploymentPatch, diff --git a/libethereum/SchainPatch.h b/libethereum/SchainPatch.h index 8b48b6bd2..8128b7b02 100644 --- a/libethereum/SchainPatch.h +++ b/libethereum/SchainPatch.h @@ -213,6 +213,12 @@ DEFINE_SIMPLE_PATCH( SingleStateCommitPerBlockPatch ); DEFINE_SIMPLE_PATCH( ContractCreationReadOnlyPatch ); +/* + * Paris fork (EIP-3675 + EIP-4399): difficulty=0, no uncles, + * DIFFICULTY opcode returns prevRandao (0 in skaled, no beacon RANDAO). + */ +DEFINE_AMNESIC_PATCH( ParisForkPatch ); + #ifdef FAIR DEFINE_SIMPLE_PATCH( DisableSelfDestructPatch ); #endif diff --git a/libethereum/SchainPatchEnum.h b/libethereum/SchainPatchEnum.h index 976cb8b82..e109b374f 100644 --- a/libethereum/SchainPatchEnum.h +++ b/libethereum/SchainPatchEnum.h @@ -28,6 +28,7 @@ enum class SchainPatchEnum { GroupIndexInitPatch, LondonForkPatch, ContractCreationReadOnlyPatch, + ParisForkPatch, // EIP-3675 + EIP-4399 #ifdef BITE Bite2Patch, #endif // BITE diff --git a/test/unittests/libevm/VMTest.cpp b/test/unittests/libevm/VMTest.cpp index 8a01a6d21..7de9fb4f0 100644 --- a/test/unittests/libevm/VMTest.cpp +++ b/test/unittests/libevm/VMTest.cpp @@ -258,6 +258,22 @@ class Create2TestFixture : public TestOutputHelperFixture { se.reset( cp.createSealEngine() ); } + void enableParisForkPatch() { + struct PatchableChainParams : public ChainParams { + using ChainParams::ChainParams; + void setPatchTimestamp( SchainPatchEnum _patch, time_t _timestamp ) { + sChain._patchTimestamps[static_cast< size_t >( _patch )] = _timestamp; + } + }; + + PatchableChainParams cp( genesisInfo( Network::ConstantinopleTest ) ); +#ifndef FAIR + cp.setPatchTimestamp( SchainPatchEnum::ParisForkPatch, 1 ); +#endif + SchainPatch::init( cp ); + se.reset( cp.createSealEngine() ); + } + void resetSchainPatchToDefault() { ChainParams cp( genesisInfo( Network::ConstantinopleTest ) ); SchainPatch::init( cp ); @@ -341,7 +357,7 @@ class ExtcodehashTestFixture : public TestOutputHelperFixture { vm->exec( gas, extVm, onOp ); - BOOST_REQUIRE_EQUAL( gasBefore - gasAfter, + BOOST_REQUIRE_EQUAL( gasBefore - gasAfter, #ifdef FAIR 2600 // EIP-2929: cold account access cost #else @@ -808,7 +824,7 @@ class BalanceFixture : public TestOutputHelperFixture { vm->exec( gas, extVm, onOp ); - BOOST_REQUIRE_EQUAL( gasBefore - gasAfter, + BOOST_REQUIRE_EQUAL( gasBefore - gasAfter, #ifdef FAIR 2600 // EIP-2929: cold account access cost #else @@ -910,6 +926,11 @@ class SkaleInterpreterBalanceFixture : public BalanceFixture { public: SkaleInterpreterBalanceFixture() : BalanceFixture{new EVMC{evmc_create_interpreter()}} {} }; + +class LegacyVMParisTestFixture : public Create2TestFixture { +public: + LegacyVMParisTestFixture() : Create2TestFixture{new LegacyVM} {} +}; } // namespace BOOST_FIXTURE_TEST_SUITE( LegacyVMSuite, TestOutputHelperFixture ) @@ -1130,6 +1151,53 @@ BOOST_AUTO_TEST_CASE( Push0 ) { BOOST_AUTO_TEST_SUITE_END() +BOOST_FIXTURE_TEST_SUITE( LegacyVMParisSuite, LegacyVMParisTestFixture ) + +// EIP-4399: DIFFICULTY opcode must return 0 (prevRandao=0 in skaled) when ParisForkPatch is active. +BOOST_AUTO_TEST_CASE( difficultyReturnsZeroAfterParisFork ) { + // Bytecode: DIFFICULTY PUSH1 0x00 MSTORE PUSH1 0x20 PUSH1 0x00 RETURN + bytes code = fromHex( "4460005260206000f3" ); + + enableParisForkPatch(); + BlockHeader parisHeader = blockHeader; + parisHeader.setTimestamp( 1 ); + parisHeader.setDifficulty( 42 ); // non-zero to prove the opcode ignores header.difficulty + EnvInfo parisEnvInfo{ parisHeader, lastBlockHashes, 1, 0, se->chainParams().getChainId() }; + + ExtVM extVm( state, parisEnvInfo, se->chainParams(), address, address, address, + value, gasPrice, ref( inputData ), ref( code ), sha3( code ), version, depth, + isCreate, staticCall ); + + owning_bytes_ref ret = vm->exec( gas, extVm, OnOpFunc{} ); + BOOST_REQUIRE_EQUAL( ret.size(), 32 ); + BOOST_REQUIRE_EQUAL( fromBigEndian< u256 >( ret.toVector() ), 0 ); + + resetSchainPatchToDefault(); +} + +// Pre-Paris: DIFFICULTY opcode must return the actual block difficulty. +BOOST_AUTO_TEST_CASE( difficultyOpcodeUnchangedBeforeParisFork ) { + // Same bytecode, patch NOT enabled + bytes code = fromHex( "4460005260206000f3" ); + + BlockHeader preParisHeader = blockHeader; + preParisHeader.setTimestamp( 1 ); + preParisHeader.setDifficulty( 12345 ); + EnvInfo preParisEnvInfo{ preParisHeader, lastBlockHashes, 1, 0, se->chainParams().getChainId() }; + + ExtVM extVm( state, preParisEnvInfo, se->chainParams(), address, address, address, + value, gasPrice, ref( inputData ), ref( code ), sha3( code ), version, depth, + isCreate, staticCall ); + + owning_bytes_ref ret = vm->exec( gas, extVm, OnOpFunc{} ); + BOOST_REQUIRE_EQUAL( ret.size(), 32 ); +#ifndef FAIR + BOOST_REQUIRE_EQUAL( fromBigEndian< u256 >( ret.toVector() ), 12345 ); +#endif +} + +BOOST_AUTO_TEST_SUITE_END() + BOOST_AUTO_TEST_SUITE_END() BOOST_FIXTURE_TEST_SUITE( SkaleInterpreterSuite, TestOutputHelperFixture ) @@ -1167,6 +1235,7 @@ BOOST_AUTO_TEST_CASE( SkaleInterpreterCreate2collisionWithNonEmptyStorageEmptyIn testCreate2collisionWithNonEmptyStorageEmptyInitCode(); } + BOOST_AUTO_TEST_SUITE_END() BOOST_FIXTURE_TEST_SUITE( SkaleInterpreterExtcodehashSuite, SkaleInterpreterExtcodehashTestFixture ) From fd8b7541bb1937652cc2c9310d99ca5351babb88 Mon Sep 17 00:00:00 2001 From: badrogger Date: Thu, 2 Apr 2026 19:23:54 +0100 Subject: [PATCH 02/14] 1795 Add Paris fork support --- libethashseal/Ethash.cpp | 10 ++- libethcore/SealEngine.cpp | 3 +- libethereum/Block.cpp | 10 ++- libevm/ExtVMFace.cpp | 8 ++- libevm/LegacyVM.cpp | 6 +- .../hardfork-support/subroutine/eip_tests.py | 64 +++++++++++++++++++ test/unittests/libevm/VMTest.cpp | 2 + 7 files changed, 97 insertions(+), 6 deletions(-) diff --git a/libethashseal/Ethash.cpp b/libethashseal/Ethash.cpp index e87b27237..998e69d54 100644 --- a/libethashseal/Ethash.cpp +++ b/libethashseal/Ethash.cpp @@ -23,6 +23,7 @@ #include #include #include +#include #include @@ -91,7 +92,7 @@ void Ethash::verify( Strictness _s, BlockHeader const& _bi, BlockHeader const& _ bytesConstRef _block ) const { SealEngineFace::verify( _s, _bi, _parent, _block ); - if ( _parent ) { + if ( _parent && !ParisForkPatch::isEnabledInWorkingBlock() ) { // Check difficulty is correct given the two timestamps. auto expected = calculateDifficulty( _bi, _parent ); auto difficulty = _bi.difficulty(); @@ -201,7 +202,12 @@ u256 Ethash::calculateDifficulty( BlockHeader const& _bi, BlockHeader const& _pa void Ethash::populateFromParent( BlockHeader& _bi, BlockHeader const& _parent ) const { SealEngineFace::populateFromParent( _bi, _parent ); - _bi.setDifficulty( calculateDifficulty( _bi, _parent ) ); + if ( ParisForkPatch::isEnabledInWorkingBlock() ) { + _bi.setDifficulty( 0 ); + setMixHash( _bi, h256( 0 ) ); + } else { + _bi.setDifficulty( calculateDifficulty( _bi, _parent ) ); + } _bi.setGasLimit( childGasLimit( _parent, chainParams().getMinGasLimit() ) ); } diff --git a/libethcore/SealEngine.cpp b/libethcore/SealEngine.cpp index d968299dd..62ac33bf5 100644 --- a/libethcore/SealEngine.cpp +++ b/libethcore/SealEngine.cpp @@ -41,7 +41,8 @@ void SealEngineFace::verify( Strictness _s, BlockHeader const& _bi, BlockHeader _bi.verify( _s, _parent, _block ); if ( _s != CheckNothingNew ) { - if ( _bi.difficulty() < chainParams().getMinimumDifficulty() ) + if ( !ParisForkPatch::isEnabledInWorkingBlock() && + _bi.difficulty() < chainParams().getMinimumDifficulty() ) BOOST_THROW_EXCEPTION( InvalidDifficulty() << RequirementError( bigint( chainParams().getMinimumDifficulty() ), bigint( _bi.difficulty() ) ) ); diff --git a/libethereum/Block.cpp b/libethereum/Block.cpp index 23a4b971c..bc20c5ca3 100644 --- a/libethereum/Block.cpp +++ b/libethereum/Block.cpp @@ -28,6 +28,7 @@ #include "Executive.h" #include "ExtVM.h" #include "GenesisInfo.h" +#include "SchainPatch.h" #include "TransactionQueue.h" #include #include @@ -951,7 +952,14 @@ u256 Block::enact( VerifiedBlockRef const& _block, BlockChain const& _bc ) { u256 tdIncrease = m_currentBlock.difficulty(); // Check uncles & apply their rewards to state. - if ( rlp[2].itemCount() > 2 ) { + if ( ParisForkPatch::isEnabledInWorkingBlock() ) { + if ( rlp[2].itemCount() > 0 ) { + TooManyUncles ex; + ex << errinfo_max( 0 ); + ex << errinfo_got( rlp[2].itemCount() ); + BOOST_THROW_EXCEPTION( ex ); + } + } else if ( rlp[2].itemCount() > 2 ) { TooManyUncles ex; ex << errinfo_max( 2 ); ex << errinfo_got( rlp[2].itemCount() ); diff --git a/libevm/ExtVMFace.cpp b/libevm/ExtVMFace.cpp index e8cb15c3c..7e76a22d6 100644 --- a/libevm/ExtVMFace.cpp +++ b/libevm/ExtVMFace.cpp @@ -18,6 +18,7 @@ #include "ExtVMFace.h" #include +#include namespace dev { namespace eth { @@ -132,7 +133,12 @@ evmc_tx_context EvmCHost::get_tx_context() noexcept { result.block_number = envInfo.number(); result.block_timestamp = envInfo.timestamp(); result.block_gas_limit = static_cast< int64_t >( envInfo.gasLimit() ); - result.block_difficulty = toEvmC( envInfo.difficulty() ); + if ( ParisForkPatch::isEnabledInWorkingBlock() ) { + // EIP-4399: DIFFICULTY opcode returns prevRandao. In skaled (BFT, no beacon), prevRandao=0. + result.block_difficulty = toEvmC( u256( 0 ) ); + } else { + result.block_difficulty = toEvmC( envInfo.difficulty() ); + } result.chain_id = toEvmC( envInfo.chainID() ); return result; } diff --git a/libevm/LegacyVM.cpp b/libevm/LegacyVM.cpp index 030303545..304632671 100644 --- a/libevm/LegacyVM.cpp +++ b/libevm/LegacyVM.cpp @@ -1355,7 +1355,11 @@ void LegacyVM::interpretCases() { ON_OP(); updateIOGas(); - m_SPP[0] = m_ext->envInfo().difficulty(); + // EIP-4399: post-Paris, DIFFICULTY returns prevRandao (0 in skaled/BFT) + if ( ParisForkPatch::isEnabledInWorkingBlock() ) + m_SPP[0] = 0; + else + m_SPP[0] = m_ext->envInfo().difficulty(); } NEXT diff --git a/test/api-tests/hardfork-support/subroutine/eip_tests.py b/test/api-tests/hardfork-support/subroutine/eip_tests.py index 5b5d6a405..16eafa10f 100644 --- a/test/api-tests/hardfork-support/subroutine/eip_tests.py +++ b/test/api-tests/hardfork-support/subroutine/eip_tests.py @@ -2003,6 +2003,67 @@ def test_eip_1559_block_hash_integrity( ) +# --------------------------------------------------------------------------- +# EIP-3675 / EIP-4399: Paris fork (difficulty=0, PREVRANDAO opcode) +# --------------------------------------------------------------------------- + +def test_eip_3675( + w3: Web3, deployer: LocalAccount, sol_dir: str, gas_limit: int = 3_000_000 +) -> EIPTestResult: + """EIP-3675: eth_getBlockByNumber must return difficulty=0x0 post-Paris.""" + logger.info("=== EIP-3675 difficulty=0 header test ===") + block = w3.eth.get_block("latest") + difficulty = _as_int(block.get("difficulty")) + + details = { + "difficulty": difficulty, + "block_number": block["number"], + } + if difficulty != 0: + return EIPTestResult( + eip="3675", + passed=False, + message=f"Expected difficulty=0, got {difficulty}", + details=details, + ) + return EIPTestResult( + eip="3675", + passed=True, + message="difficulty=0x0 in latest block header", + details=details, + ) + + +def test_eip_4399( + w3: Web3, deployer: LocalAccount, sol_dir: str, gas_limit: int = 3_000_000 +) -> EIPTestResult: + """EIP-4399: PREVRANDAO opcode returns 0 (skaled has no beacon RANDAO).""" + logger.info("=== EIP-4399 PREVRANDAO opcode test ===") + abi, bytecode = _load_artifact(sol_dir, "EIP4399Test") + addr = _deploy_contract(w3, deployer, abi, bytecode, gas_limit) + contract = w3.eth.contract(address=addr, abi=abi) + + prevrandao = _as_int(contract.functions.getPrevRandao().call()) + + details = { + "prevrandao": prevrandao, + "contract": addr, + } + if prevrandao != 0: + return EIPTestResult( + eip="4399", + passed=False, + message=f"Expected PREVRANDAO=0 (no beacon RANDAO in skaled), got {prevrandao}", + details=details, + ) + return EIPTestResult( + eip="4399", + passed=True, + message="PREVRANDAO opcode returned 0 (correct for skaled post-Paris)", + details=details, + ) + + # --------------------------------------------------------------------------- # Orchestrator # --------------------------------------------------------------------------- @@ -2032,6 +2093,8 @@ def test_eip_1559_block_hash_integrity( "1559-fee-history": test_eip_1559_fee_history, "1559-max-priority-fee": test_eip_1559_max_priority_fee, "1559-block-hash-integrity": test_eip_1559_block_hash_integrity, + "3675": test_eip_3675, + "4399": test_eip_4399, } ALL_EIPS = [ @@ -2043,6 +2106,7 @@ def test_eip_1559_block_hash_integrity( "1559-effective-price", "1559-basefee-header", "1559-fee-history", "1559-max-priority-fee", "1559-block-hash-integrity", + "3675", "4399", ] diff --git a/test/unittests/libevm/VMTest.cpp b/test/unittests/libevm/VMTest.cpp index 7de9fb4f0..c4e5bb77a 100644 --- a/test/unittests/libevm/VMTest.cpp +++ b/test/unittests/libevm/VMTest.cpp @@ -271,12 +271,14 @@ class Create2TestFixture : public TestOutputHelperFixture { cp.setPatchTimestamp( SchainPatchEnum::ParisForkPatch, 1 ); #endif SchainPatch::init( cp ); + SchainPatch::useLatestBlockTimestamp( 1 ); se.reset( cp.createSealEngine() ); } void resetSchainPatchToDefault() { ChainParams cp( genesisInfo( Network::ConstantinopleTest ) ); SchainPatch::init( cp ); + SchainPatch::useLatestBlockTimestamp( 0 ); } From a2e69c4e56839c041a29826e733821c06b4a91b3 Mon Sep 17 00:00:00 2001 From: badrogger Date: Mon, 6 Apr 2026 13:41:59 +0100 Subject: [PATCH 03/14] 1795 Fix api tests --- .../hardfork-support/subroutine/eip_tests.py | 31 ++++++++++++++++++- 1 file changed, 30 insertions(+), 1 deletion(-) diff --git a/test/api-tests/hardfork-support/subroutine/eip_tests.py b/test/api-tests/hardfork-support/subroutine/eip_tests.py index 16eafa10f..6f1708c2d 100644 --- a/test/api-tests/hardfork-support/subroutine/eip_tests.py +++ b/test/api-tests/hardfork-support/subroutine/eip_tests.py @@ -2034,10 +2034,21 @@ def test_eip_3675( ) +def _is_anvil(w3: Web3) -> bool: + try: + return "anvil" in w3.client_version.lower() + except Exception: + return False + + def test_eip_4399( w3: Web3, deployer: LocalAccount, sol_dir: str, gas_limit: int = 3_000_000 ) -> EIPTestResult: - """EIP-4399: PREVRANDAO opcode returns 0 (skaled has no beacon RANDAO).""" + """EIP-4399: PREVRANDAO opcode is accessible post-Paris. + + skaled (BFT, no beacon chain): returns 0. + Anvil: returns a non-zero simulated value — any non-zero value is accepted. + """ logger.info("=== EIP-4399 PREVRANDAO opcode test ===") abi, bytecode = _load_artifact(sol_dir, "EIP4399Test") addr = _deploy_contract(w3, deployer, abi, bytecode, gas_limit) @@ -2049,6 +2060,24 @@ def test_eip_4399( "prevrandao": prevrandao, "contract": addr, } + + if _is_anvil(w3): + # Anvil simulates PREVRANDAO as a non-zero random value — just verify the opcode works. + if prevrandao == 0: + return EIPTestResult( + eip="4399", + passed=False, + message="PREVRANDAO=0 on Anvil — opcode not active or returning wrong value", + details=details, + ) + return EIPTestResult( + eip="4399", + passed=True, + message=f"PREVRANDAO opcode returned non-zero value (Anvil simulation: {prevrandao})", + details=details, + ) + + # skaled: BFT consensus, no beacon chain — PREVRANDAO is always 0. if prevrandao != 0: return EIPTestResult( eip="4399", From 2cce66eb199efffd6858f00f5e47be0e9e313f7b Mon Sep 17 00:00:00 2001 From: badrogger Date: Mon, 6 Apr 2026 15:24:28 +0100 Subject: [PATCH 04/14] 1795 Commit missing files --- .../sol/contracts/eips/EIP4399Test.sol | 11 +++ test/unittests/libethereum/ParisForkTests.cpp | 92 +++++++++++++++++++ 2 files changed, 103 insertions(+) create mode 100644 test/api-tests/hardfork-support/sol/contracts/eips/EIP4399Test.sol create mode 100644 test/unittests/libethereum/ParisForkTests.cpp diff --git a/test/api-tests/hardfork-support/sol/contracts/eips/EIP4399Test.sol b/test/api-tests/hardfork-support/sol/contracts/eips/EIP4399Test.sol new file mode 100644 index 000000000..028826a07 --- /dev/null +++ b/test/api-tests/hardfork-support/sol/contracts/eips/EIP4399Test.sol @@ -0,0 +1,11 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.20; + +// EIP-4399: PREVRANDAO opcode. +// Post-Paris, opcode 0x44 (formerly DIFFICULTY) returns the beacon RANDAO mix. +// In skaled (BFT, no beacon chain), prevRandao is hardcoded to 0. +contract EIP4399Test { + function getPrevRandao() external view returns (uint256) { + return block.prevrandao; + } +} diff --git a/test/unittests/libethereum/ParisForkTests.cpp b/test/unittests/libethereum/ParisForkTests.cpp new file mode 100644 index 000000000..fa58d098f --- /dev/null +++ b/test/unittests/libethereum/ParisForkTests.cpp @@ -0,0 +1,92 @@ +/* + Modifications Copyright (C) 2018-2019 SKALE Labs + + This file is part of cpp-ethereum. + + cpp-ethereum is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + cpp-ethereum is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with cpp-ethereum. If not, see . +*/ +/** @file ParisForkTests.cpp + * @date 2026 + * Paris fork (EIP-3675 + EIP-4399) unit tests. + * Tests verify that SealEngine::verify skips the minimumDifficulty check when + * ParisForkPatch is active, and enforces it when the patch is not active. + */ + +#include +#include +#include +#include +#include +#include +#include + +using namespace dev; +using namespace eth; + +namespace { + +struct PatchableChainParams : public ChainParams { + using ChainParams::ChainParams; + void setPatchTimestamp( SchainPatchEnum _patch, time_t _timestamp ) { + sChain._patchTimestamps[static_cast< size_t >( _patch )] = _timestamp; + } +}; + +} // namespace + +BOOST_FIXTURE_TEST_SUITE( ParisForkTests, dev::test::TestOutputHelperFixture ) + +// EIP-3675: when ParisForkPatch is active, difficulty=0 must pass SealEngine::verify. +BOOST_AUTO_TEST_CASE( parisForkDifficultyZeroPasses ) { + PatchableChainParams cp( genesisInfo( Network::ConstantinopleTest ) ); +#ifndef FAIR + cp.setPatchTimestamp( SchainPatchEnum::ParisForkPatch, 1 ); +#endif + SchainPatch::init( cp ); + SchainPatch::useLatestBlockTimestamp( 1 ); + std::unique_ptr< SealEngineFace > se( cp.createSealEngine() ); + + BlockHeader bi; + bi.setGasLimit( 0x7fffffffffffffff ); + bi.setGasUsed( 0 ); + bi.setDifficulty( 0 ); + bi.setTimestamp( 1 ); + + BOOST_REQUIRE_NO_THROW( se->verify( QuickNonce, bi, BlockHeader{}, bytesConstRef{} ) ); + + ChainParams resetCp( genesisInfo( Network::ConstantinopleTest ) ); + SchainPatch::init( resetCp ); + SchainPatch::useLatestBlockTimestamp( 0 ); +} + +// EIP-3675: without ParisForkPatch, difficulty=0 < minimumDifficulty(131072) must throw. +BOOST_AUTO_TEST_CASE( parisForkDifficultyZeroThrowsPreParis ) { + ChainParams cp( genesisInfo( Network::ConstantinopleTest ) ); + SchainPatch::init( cp ); + SchainPatch::useLatestBlockTimestamp( 0 ); + std::unique_ptr< SealEngineFace > se( cp.createSealEngine() ); + + BlockHeader bi; + bi.setGasLimit( 0x7fffffffffffffff ); + bi.setGasUsed( 0 ); + bi.setDifficulty( 0 ); + bi.setTimestamp( 1 ); + +#ifndef FAIR + BOOST_REQUIRE_THROW( + se->verify( QuickNonce, bi, BlockHeader{}, bytesConstRef{} ), InvalidDifficulty ); +#endif +} + +BOOST_AUTO_TEST_SUITE_END() From b8e36d0d29a3330fd4f4c19ffdae763e1e6b4425 Mon Sep 17 00:00:00 2001 From: badrogger Date: Mon, 6 Apr 2026 16:12:38 +0100 Subject: [PATCH 05/14] 1795 Fix tests --- test/tools/libtesteth/BlockChainHelper.cpp | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/test/tools/libtesteth/BlockChainHelper.cpp b/test/tools/libtesteth/BlockChainHelper.cpp index 76fe207b0..6504beef9 100644 --- a/test/tools/libtesteth/BlockChainHelper.cpp +++ b/test/tools/libtesteth/BlockChainHelper.cpp @@ -400,7 +400,11 @@ void TestBlock::recalcBlockHeaderBytes() { RLPStream blHeaderStream; m_blockHeader.streamRLP( blHeaderStream, WithSeal ); - m_blockHeader = BlockHeader( blHeaderStream.out(), HeaderData ); + // Invalidate the cached hash so BlockHeader::hash() recomputes it from the current fields + // via streamRLP on next access. Re-parsing the serialized bytes is not safe here: a + // default TestBlock header carries timestamp=-1, which streamRLP encodes as + // 0xffffffffffffffff, and BlockHeader::populate() then throws BadCast from toPositiveInt64. + m_blockHeader.noteDirty(); RLPStream ret( 3 ); ret.appendRaw( blHeaderStream.out() ); // block header From 2d3d9774325776cdadbfa02e28216af41740c52f Mon Sep 17 00:00:00 2001 From: badrogger Date: Mon, 6 Apr 2026 16:13:35 +0100 Subject: [PATCH 06/14] 1795 Format --- libethereum/SchainPatchEnum.h | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/libethereum/SchainPatchEnum.h b/libethereum/SchainPatchEnum.h index e109b374f..5134f36c4 100644 --- a/libethereum/SchainPatchEnum.h +++ b/libethereum/SchainPatchEnum.h @@ -28,7 +28,7 @@ enum class SchainPatchEnum { GroupIndexInitPatch, LondonForkPatch, ContractCreationReadOnlyPatch, - ParisForkPatch, // EIP-3675 + EIP-4399 + ParisForkPatch, // EIP-3675 + EIP-4399 #ifdef BITE Bite2Patch, #endif // BITE From 9cf4eb7a2d8aebefd66fde24d5f800548f970aff Mon Sep 17 00:00:00 2001 From: badrogger Date: Mon, 6 Jul 2026 17:20:21 +0100 Subject: [PATCH 07/14] 1795 Fix populate --- libethashseal/Ethash.cpp | 27 +- libethcore/BlockHeader.h | 4 + libethcore/SealEngine.cpp | 11 +- libethereum/Block.cpp | 2 +- libethereum/SchainPatch.h | 2 +- libevm/ExtVMFace.cpp | 2 +- libevm/LegacyVM.cpp | 2 +- test/api-tests/hardfork-compat/_test_utils.py | 34 +- test/api-tests/hardfork-compat/conftest.py | 213 +++++-- .../hardfork-compat/hardfork-compat.toml | 81 ++- test/api-tests/hardfork-compat/suite.py | 11 +- .../hardfork-compat/test_hardfork_compat.py | 577 +++++++++++------- test/unittests/libethereum/ParisForkTests.cpp | 64 ++ 13 files changed, 700 insertions(+), 330 deletions(-) diff --git a/libethashseal/Ethash.cpp b/libethashseal/Ethash.cpp index c26b3122b..c29854b54 100644 --- a/libethashseal/Ethash.cpp +++ b/libethashseal/Ethash.cpp @@ -89,13 +89,21 @@ void Ethash::verify( Strictness _s, BlockHeader const& _bi, BlockHeader const& _ bytesConstRef _block ) const { SealEngineFace::verify( _s, _bi, _parent, _block ); - if ( _parent && !ParisForkPatch::isEnabledInWorkingBlock() ) { - // Check difficulty is correct given the two timestamps. - auto expected = calculateDifficulty( _bi, _parent ); - auto difficulty = _bi.difficulty(); - if ( difficulty != expected ) - BOOST_THROW_EXCEPTION( InvalidDifficulty() << RequirementError( - ( bigint ) expected, ( bigint ) difficulty ) ); + if ( _parent ) { + const bool isParis = + ParisForkPatch::isEnabledWhen( static_cast< time_t >( _parent.timestamp() ) ); + if ( isParis ) { + if ( _bi.sealFieldCount() != 0 ) + BOOST_THROW_EXCEPTION( InvalidBlockFormat() << errinfo_comment( + "Paris block header must use SKALE no-seal format" ) ); + } else { + // Check difficulty is correct given the two timestamps. + auto expected = calculateDifficulty( _bi, _parent ); + auto difficulty = _bi.difficulty(); + if ( difficulty != expected ) + BOOST_THROW_EXCEPTION( InvalidDifficulty() << RequirementError( + ( bigint ) expected, ( bigint ) difficulty ) ); + } } // check it hashes according to proof of work or that it's the genesis block. @@ -199,9 +207,10 @@ u256 Ethash::calculateDifficulty( BlockHeader const& _bi, BlockHeader const& _pa void Ethash::populateFromParent( BlockHeader& _bi, BlockHeader const& _parent ) const { SealEngineFace::populateFromParent( _bi, _parent ); - if ( ParisForkPatch::isEnabledInWorkingBlock() ) { + if ( ParisForkPatch::isEnabledWhen( static_cast< time_t >( _parent.timestamp() ) ) ) { _bi.setDifficulty( 0 ); - setMixHash( _bi, h256( 0 ) ); + // Keep SKALE's no-seal header shape. Setting only mixHash would create a + // London header with one seal field, which BlockHeader::populate() rejects. } else { _bi.setDifficulty( calculateDifficulty( _bi, _parent ) ); } diff --git a/libethcore/BlockHeader.h b/libethcore/BlockHeader.h index f964e39fb..9932375f3 100644 --- a/libethcore/BlockHeader.h +++ b/libethcore/BlockHeader.h @@ -213,6 +213,10 @@ class BlockHeader { ret = RLP( m_seal[_offset] ).convert< T >( RLP::VeryStrict ); return ret; } + size_t sealFieldCount() const { + Guard l( m_sealLock ); + return m_seal.size(); + } private: void populate( RLP const& _header ); diff --git a/libethcore/SealEngine.cpp b/libethcore/SealEngine.cpp index 0074f5473..01b094be1 100644 --- a/libethcore/SealEngine.cpp +++ b/libethcore/SealEngine.cpp @@ -41,8 +41,15 @@ void SealEngineFace::verify( Strictness _s, BlockHeader const& _bi, BlockHeader _bi.verify( _s, _parent, _block ); if ( _s != CheckNothingNew ) { - if ( !ParisForkPatch::isEnabledInWorkingBlock() && - _bi.difficulty() < chainParams().getMinimumDifficulty() ) + const time_t committedBlockTimestamp = _parent ? + static_cast< time_t >( _parent.timestamp() ) : + static_cast< time_t >( _bi.timestamp() ); + if ( _parent && ParisForkPatch::isEnabledWhen( committedBlockTimestamp ) ) { + if ( _bi.difficulty() != 0 ) + BOOST_THROW_EXCEPTION( InvalidDifficulty() << RequirementError( + bigint( 0 ), bigint( _bi.difficulty() ) ) ); + } else if ( !ParisForkPatch::isEnabledWhen( committedBlockTimestamp ) && + _bi.difficulty() < chainParams().getMinimumDifficulty() ) BOOST_THROW_EXCEPTION( InvalidDifficulty() << RequirementError( bigint( chainParams().getMinimumDifficulty() ), bigint( _bi.difficulty() ) ) ); diff --git a/libethereum/Block.cpp b/libethereum/Block.cpp index e7259ee2f..08569b697 100644 --- a/libethereum/Block.cpp +++ b/libethereum/Block.cpp @@ -1037,7 +1037,7 @@ u256 Block::enact( VerifiedBlockRef const& _block, BlockChain const& _bc ) { u256 tdIncrease = m_currentBlock.difficulty(); // Check uncles & apply their rewards to state. - if ( ParisForkPatch::isEnabledInWorkingBlock() ) { + if ( ParisForkPatch::isEnabledWhen( previousInfo().timestamp() ) ) { if ( rlp[2].itemCount() > 0 ) { TooManyUncles ex; ex << errinfo_max( 0 ); diff --git a/libethereum/SchainPatch.h b/libethereum/SchainPatch.h index 8128b7b02..9b5d1d4e6 100644 --- a/libethereum/SchainPatch.h +++ b/libethereum/SchainPatch.h @@ -217,7 +217,7 @@ DEFINE_SIMPLE_PATCH( ContractCreationReadOnlyPatch ); * Paris fork (EIP-3675 + EIP-4399): difficulty=0, no uncles, * DIFFICULTY opcode returns prevRandao (0 in skaled, no beacon RANDAO). */ -DEFINE_AMNESIC_PATCH( ParisForkPatch ); +DEFINE_SIMPLE_PATCH( ParisForkPatch ); #ifdef FAIR DEFINE_SIMPLE_PATCH( DisableSelfDestructPatch ); diff --git a/libevm/ExtVMFace.cpp b/libevm/ExtVMFace.cpp index 7e76a22d6..73eb96604 100644 --- a/libevm/ExtVMFace.cpp +++ b/libevm/ExtVMFace.cpp @@ -133,7 +133,7 @@ evmc_tx_context EvmCHost::get_tx_context() noexcept { result.block_number = envInfo.number(); result.block_timestamp = envInfo.timestamp(); result.block_gas_limit = static_cast< int64_t >( envInfo.gasLimit() ); - if ( ParisForkPatch::isEnabledInWorkingBlock() ) { + if ( ParisForkPatch::isEnabledWhen( envInfo.committedBlockTimestamp() ) ) { // EIP-4399: DIFFICULTY opcode returns prevRandao. In skaled (BFT, no beacon), prevRandao=0. result.block_difficulty = toEvmC( u256( 0 ) ); } else { diff --git a/libevm/LegacyVM.cpp b/libevm/LegacyVM.cpp index 304632671..226daeb82 100644 --- a/libevm/LegacyVM.cpp +++ b/libevm/LegacyVM.cpp @@ -1356,7 +1356,7 @@ void LegacyVM::interpretCases() { updateIOGas(); // EIP-4399: post-Paris, DIFFICULTY returns prevRandao (0 in skaled/BFT) - if ( ParisForkPatch::isEnabledInWorkingBlock() ) + if ( ParisForkPatch::isEnabledWhen( m_ext->envInfo().committedBlockTimestamp() ) ) m_SPP[0] = 0; else m_SPP[0] = m_ext->envInfo().difficulty(); diff --git a/test/api-tests/hardfork-compat/_test_utils.py b/test/api-tests/hardfork-compat/_test_utils.py index ad7fd0c18..5af9d79e0 100644 --- a/test/api-tests/hardfork-compat/_test_utils.py +++ b/test/api-tests/hardfork-compat/_test_utils.py @@ -38,6 +38,30 @@ def wait_for_tx(w3: Web3, tx_hash, timeout_s: int) -> Optional[dict]: return None +def wait_for_block_timestamp(w3: Web3, target_timestamp: int, timeout_s: int) -> Optional[int]: + """Wait until the latest block timestamp reaches ``target_timestamp``.""" + deadline = time.time() + timeout_s + last_seen = None + while time.time() < deadline: + try: + block = w3.eth.get_block("latest") + last_seen = (block["number"], block["timestamp"]) + logger.info( + "Waiting for timestamp >= %d: latest block=%d timestamp=%d", + target_timestamp, block["number"], block["timestamp"], + ) + if int(block["timestamp"]) >= target_timestamp: + return int(block["number"]) + except Exception: + pass + time.sleep(2) + logger.error( + "Timed out waiting for block timestamp >= %d (last seen=%s)", + target_timestamp, last_seen, + ) + return None + + def wait_for_sync_catchup( w3_primary: Web3, w3_sync: Web3, timeout_s: int, ) -> bool: @@ -72,9 +96,9 @@ def compare_state_roots( """Compare per-block stateRoot from block 0 to ``up_to_block``. Returns the list of mismatched block numbers (empty means all match). - A mismatch means the 5.2.0 binary derived a different world state than - 5.1.0 while replaying the same block -- i.e. an unguarded state-transition - change between the two versions. + A mismatch means the archive sync node derived a different world state than + the upgraded primary while replaying the same block -- i.e. an unguarded + state-transition change across the fork/upgrade path. """ mismatches = [] for bn in range(0, up_to_block + 1): @@ -83,7 +107,7 @@ def compare_state_roots( sync_root = _block_state_root(w3_sync, bn) if primary_root != sync_root: logger.error( - "STATE ROOT MISMATCH block %d: 5.1.0=%s 5.2.0=%s", + "STATE ROOT MISMATCH block %d: primary=%s sync=%s", bn, primary_root, sync_root, ) mismatches.append(bn) @@ -111,7 +135,7 @@ def compare_block_hashes( sync_hash = w3_sync.eth.get_block(bn)["hash"].hex() if primary_hash != sync_hash: logger.error( - "HASH MISMATCH block %d: 5.1.0=%s 5.2.0=%s", + "HASH MISMATCH block %d: primary=%s sync=%s", bn, primary_hash, sync_hash, ) mismatches.append(bn) diff --git a/test/api-tests/hardfork-compat/conftest.py b/test/api-tests/hardfork-compat/conftest.py index 68c0637c3..3c60a9864 100644 --- a/test/api-tests/hardfork-compat/conftest.py +++ b/test/api-tests/hardfork-compat/conftest.py @@ -1,10 +1,12 @@ """ pytest fixtures for the hardfork-compat test suite. -The primary node (5.1.0 binary) is session-scoped: launched once, used by -all workload tests. The sync node (5.2.0 binary, syncNode=true, -archiveMode=true) is launched on-demand by the final test after all -primary-node transactions have been sent, then compared block-by-block. +The primary node starts on the London-capable binary, produces pre-upgrade +blocks, is restarted in-place with the current Paris-capable binary and a +future ParisForkPatch timestamp, then produces both pre-activation and +post-activation blocks. A current-version sync node (syncNode=true, +archiveMode=true) is launched at the end to replay the whole chain and compare +state roots / block hashes block-by-block. Configuration is read from a JSON file whose path is stored in the HARDFORK_COMPAT_CFG_JSON environment variable. run.py sets this @@ -20,15 +22,14 @@ import sys import time from pathlib import Path -from typing import Optional import pytest from web3 import Web3 logger = logging.getLogger("hardfork-compat.conftest") -SUITE_DIR = Path(__file__).resolve().parent -REPO_ROOT = SUITE_DIR.parent.parent.parent +SUITE_DIR = Path(__file__).resolve().parent +REPO_ROOT = SUITE_DIR.parent.parent.parent FUNC_TESTS = SUITE_DIR.parent # Add api-tests/ to sys.path so ``from run import ...`` works. @@ -71,16 +72,37 @@ def timeouts(hardfork_cfg: dict) -> dict: @pytest.fixture(scope="session") -def sync_binary(hardfork_cfg: dict) -> Path: - """Path to the 5.2.0 binary used for the sync node.""" +def london_binary(hardfork_cfg: dict) -> Path: + """Path to the London-capable binary used before the Paris upgrade.""" return _hardfork_binary( hardfork_cfg, - "v520_binary", - "HARDFORK_COMPAT_V520_BINARY", + "london_binary", + "HARDFORK_COMPAT_LONDON_BINARY", "bin-5-2-0", + legacy_cfg_key="v510_binary", + legacy_env_key="HARDFORK_COMPAT_V510_BINARY", ) +@pytest.fixture(scope="session") +def current_binary(hardfork_cfg: dict) -> Path: + """Path to the current Paris-capable binary used after upgrade and for sync.""" + return _hardfork_binary( + hardfork_cfg, + "current_binary", + "HARDFORK_COMPAT_CURRENT_BINARY", + "build/skaled/skaled", + legacy_cfg_key="v520_binary", + legacy_env_key="HARDFORK_COMPAT_V520_BINARY", + ) + + +@pytest.fixture(scope="session") +def sync_binary(current_binary: Path) -> Path: + """Path to the current binary used for the archive sync node.""" + return current_binary + + # --------------------------------------------------------------------------- # Node lifecycle helpers # --------------------------------------------------------------------------- @@ -91,14 +113,24 @@ def _resolve(path_str: str) -> Path: def _hardfork_binary( - hardfork_cfg: dict, cfg_key: str, env_key: str, default: str, + hardfork_cfg: dict, + cfg_key: str, + env_key: str, + default: str, + legacy_cfg_key: str | None = None, + legacy_env_key: str | None = None, ) -> Path: """Resolve a hardfork-compat binary path. - Environment variables allow local and CI callers to point at externally - supplied binaries without editing hardfork-compat.toml. + New Paris-specific names are preferred, while the older v510/v520 names + remain supported so local/CI callers can migrate without a flag day. """ - return _resolve(os.environ.get(env_key) or hardfork_cfg.get(cfg_key, default)) + value = os.environ.get(env_key) or hardfork_cfg.get(cfg_key) + if value is None and legacy_env_key: + value = os.environ.get(legacy_env_key) + if value is None and legacy_cfg_key: + value = hardfork_cfg.get(legacy_cfg_key) + return _resolve(value or default) def _resolve_ft(path_str: str) -> Path: @@ -118,6 +150,13 @@ def _wait_for_rpc(w3: Web3, timeout_s: int, label: str) -> None: raise RuntimeError(f"{label} RPC did not come up within {timeout_s}s") +def _tail_file(path: Path, max_lines: int = 80) -> str: + try: + return "\n".join(path.read_text(errors="replace").splitlines()[-max_lines:]) + except OSError as exc: + return f"" + + def _stop_node(proc: subprocess.Popen, log_fd, label: str) -> None: if proc.poll() is None: logger.info("Stopping %s node (pid=%d) ...", label, proc.pid) @@ -150,15 +189,15 @@ def _launch_node( log_fd = open(log_path, "w") cmd = [ str(binary), - "--config", str(cfg_out), + "--config", str(cfg_out), "--http-port", str(http_port), - "--ws-port", str(http_port - 1), + "--ws-port", str(http_port - 1), "--info-http-port", str(http_port + 6), "-v", "9", "--web3-trace", "--enable-debug-behavior-apis", "--ipcpath", str(datadir), - "-d", str(datadir), + "-d", str(datadir), ] logger.info("Launching %s node (http=%d) -> %s", label, http_port, log_path) proc = subprocess.Popen(cmd, stdout=log_fd, stderr=subprocess.STDOUT, cwd=REPO_ROOT) @@ -168,11 +207,11 @@ def _launch_node( def make_sync_config( primary_cfg_path: Path, sync_cfg_path: Path, sync_http_port: int, ) -> None: - """Create the 5.2.0 sync config from the primary config. + """Create the current-version archive sync config from the primary config. - syncNode=true + archiveMode=true makes the node replay every block and - retain historic state, so its recomputed per-block stateRoot can be - compared against the 5.1.0 primary. + The primary config already contains the resolved Paris timestamp by the time + this is called. Do not re-resolve relative timestamps here, otherwise the + sync node could replay with a different activation point. """ with open(primary_cfg_path) as f: cfg = json.load(f) @@ -192,20 +231,81 @@ def make_sync_config( with open(sync_cfg_path, "w") as f: json.dump(cfg, f, indent=2) logger.info( - "Created 5.2.0 sync config: %s (syncNode=true, archiveMode=true)", + "Created current sync config: %s (syncNode=true, archiveMode=true)", sync_cfg_path, ) +class ManagedPrimaryNode: + def __init__( + self, + *, + w3: Web3, + cfg_path: Path, + datadir: Path, + http_port: int, + proc: subprocess.Popen, + log_fd, + label: str, + ): + self.w3 = w3 + self.cfg_path = cfg_path + self.datadir = datadir + self.http_port = http_port + self.proc = proc + self.log_fd = log_fd + self.label = label + self.upgraded = False + + def stop(self) -> None: + if self.proc is not None and self.log_fd is not None: + _stop_node(self.proc, self.log_fd, self.label) + self.proc = None + self.log_fd = None + + def upgrade_to_current( + self, *, binary: Path, patches: dict, timeout_s: int, + ) -> None: + """Restart the primary in-place on the current binary with Paris patches.""" + if self.upgraded: + logger.info("Primary node is already upgraded to current binary") + return + + from run import inject_patches, set_ulimit + + self.stop() + inject_patches(str(self.cfg_path), patches) + set_ulimit() + + log_dir = SUITE_DIR / "logs" + log_dir.mkdir(parents=True, exist_ok=True) + self.label = "PRIMARY(current)" + log_path = log_dir / "skaled-primary-current.log" + self.proc, self.log_fd = _launch_node( + binary, + self.cfg_path, + self.http_port, + self.datadir, + log_path, + self.label, + fresh=False, + ) + try: + _wait_for_rpc(self.w3, timeout_s, self.label) + except RuntimeError as exc: + raise RuntimeError(f"{exc}\n--- {log_path} tail ---\n{_tail_file(log_path)}") from exc + self.upgraded = True + + # --------------------------------------------------------------------------- -# Primary node (5.1.0, session-scoped) +# Primary node (London first, then current in-place) # --------------------------------------------------------------------------- @pytest.fixture(scope="session") -def primary_session(run_cfg: dict, hardfork_cfg: dict): +def primary_session(run_cfg: dict, hardfork_cfg: dict, london_binary: Path): """ - Render the primary (5.1.0) config, launch the node, wait for RPC, - and yield ``(w3, cfg_out_path)``. + Render the primary config, launch the London binary, wait for RPC, + and yield a managed node object that tests can upgrade in-place. """ from run import ( configure_single_node_skaled, @@ -218,12 +318,6 @@ def primary_session(run_cfg: dict, hardfork_cfg: dict): priv_key = run_cfg.get("type", {}).get("private_key", "") tmpl_ctx = run_cfg.get("skaled", {}).get("template", {}).get("context", {}) http_port = int(hardfork_cfg.get("primary_http_port", 5334)) - binary = _hardfork_binary( - hardfork_cfg, - "v510_binary", - "HARDFORK_COMPAT_V510_BINARY", - "bin-5-1-0", - ) datadir = _resolve("test/api-tests/hardfork-compat/datadir-primary") cfg_out = _resolve( "test/api-tests/hardfork-compat/configs/config-primary.generated.json" @@ -232,13 +326,10 @@ def primary_session(run_cfg: dict, hardfork_cfg: dict): "hardfork-compat/config-templates/config-template.json.j2" ) - if not binary.is_file(): + if not london_binary.is_file(): pytest.fail( - f"5.1.0 skaled binary not found: {binary}\n" - "Set HARDFORK_COMPAT_V510_BINARY or [hardfork_compat].v510_binary, " - "or build with:\n" - " git checkout v5.1.0 && cmake -H. -Bbuild-v510 -DCMAKE_BUILD_TYPE=Release " - "&& cmake --build build-v510 --target skaled -- -j4" + f"London skaled binary not found: {london_binary}\n" + "Set HARDFORK_COMPAT_LONDON_BINARY or [hardfork_compat].london_binary." ) render_template_file(str(tmpl), str(cfg_out), tmpl_ctx) @@ -250,9 +341,10 @@ def primary_session(run_cfg: dict, hardfork_cfg: dict): log_dir = SUITE_DIR / "logs" log_dir.mkdir(parents=True, exist_ok=True) + log_path = log_dir / "skaled-primary-london.log" proc, log_fd = _launch_node( - binary, cfg_out, http_port, datadir, - log_dir / "skaled-primary-v510.log", "PRIMARY(5.1.0)", + london_binary, cfg_out, http_port, datadir, + log_path, "PRIMARY(London)", ) w3 = Web3(Web3.HTTPProvider( f"http://127.0.0.1:{http_port}", request_kwargs={"timeout": 10} @@ -260,22 +352,43 @@ def primary_session(run_cfg: dict, hardfork_cfg: dict): rpc_timeout = hardfork_cfg.get("timeouts", {}).get("rpc_up", 360) try: - _wait_for_rpc(w3, rpc_timeout, "PRIMARY(5.1.0)") + _wait_for_rpc(w3, rpc_timeout, "PRIMARY(London)") except RuntimeError as e: - _stop_node(proc, log_fd, "PRIMARY(5.1.0)") - pytest.fail(str(e)) + _stop_node(proc, log_fd, "PRIMARY(London)") + pytest.fail(f"{e}\n--- {log_path} tail ---\n{_tail_file(log_path)}") + + node = ManagedPrimaryNode( + w3=w3, + cfg_path=cfg_out, + datadir=datadir, + http_port=http_port, + proc=proc, + log_fd=log_fd, + label="PRIMARY(London)", + ) try: - yield w3, cfg_out + yield node finally: - _stop_node(proc, log_fd, "PRIMARY(5.1.0)") + node.stop() + + +@pytest.fixture(scope="session") +def primary_node(primary_session: ManagedPrimaryNode) -> ManagedPrimaryNode: + return primary_session + + +@pytest.fixture(scope="session") +def w3_primary(primary_session: ManagedPrimaryNode) -> Web3: + return primary_session.w3 @pytest.fixture(scope="session") -def w3_primary(primary_session) -> Web3: - return primary_session[0] +def primary_cfg_path(primary_session: ManagedPrimaryNode) -> Path: + return primary_session.cfg_path @pytest.fixture(scope="session") -def primary_cfg_path(primary_session) -> Path: - return primary_session[1] +def workload_state() -> dict: + """Mutable cross-test state for deployed contracts and activation timestamp.""" + return {} diff --git a/test/api-tests/hardfork-compat/hardfork-compat.toml b/test/api-tests/hardfork-compat/hardfork-compat.toml index 93ccf5c0c..436dc28db 100644 --- a/test/api-tests/hardfork-compat/hardfork-compat.toml +++ b/test/api-tests/hardfork-compat/hardfork-compat.toml @@ -1,13 +1,13 @@ # ========================================================================== -# hardfork-compat suite -- test parameters +# hardfork-compat suite -- Paris fork replay test parameters # ========================================================================== # Merged on top of run.toml at runtime by the test runner. # -# Purpose: confirm that the 5.2.0 skaled binary derives byte-identical state -# from a chain produced by the 5.1.0 binary. A 5.1.0 node (primary) produces -# blocks with a mixed transaction workload; a 5.2.0 node (sync) replays every -# block as an archive/sync node and recomputes its own state. The suite then -# compares the per-block stateRoot of every block between the two binaries. +# Purpose: confirm that the current Paris-capable skaled binary can replay a +# chain that starts on the London-capable binary, upgrades before Paris +# activation, and continues after Paris activation without stateRoot or block +# hash divergence. The final current-version sync node runs with +# syncNode=true and archiveMode=true and compares every block. # -------------------------------------------------------------------------- # Environment type override @@ -24,58 +24,53 @@ env_type = "delegated" use_sgx = false # Binaries to compare (relative to repo root, or absolute). -# Local/CI callers can override these without editing TOML via: -# HARDFORK_COMPAT_V510_BINARY=/path/to/v5.1.0/skaled -# HARDFORK_COMPAT_V520_BINARY=/path/to/v5.2.0/skaled -# Build each version into its own build dir beforehand, e.g.: -# git checkout v5.1.0 && cmake -H. -Bbuild-v510 -DCMAKE_BUILD_TYPE=Release \ -# && cmake --build build-v510 --target skaled -- -j4 -# git checkout v5.2.0 && cmake -H. -Bbuild-v520 -DCMAKE_BUILD_TYPE=Release \ -# && cmake --build build-v520 --target skaled -- -j4 -v510_binary = "bin-5-1-0" -v520_binary = "bin-5-2-0" +# By default the suite uses the freshly built current binary as the London +# baseline with Paris disabled, then restarts it with Paris enabled. To test a +# real binary upgrade, point london_binary at a separate London-fork build via: +# HARDFORK_COMPAT_LONDON_BINARY=/path/to/london/skaled +# HARDFORK_COMPAT_CURRENT_BINARY=/path/to/current/skaled +# Older HARDFORK_COMPAT_V510_BINARY / HARDFORK_COMPAT_V520_BINARY names remain +# accepted by the fixtures as aliases if these explicit keys are absent. +london_binary = "build/skaled/skaled" +current_binary = "build/skaled/skaled" -# Ports: 5.1.0 primary on 5334, 5.2.0 sync node on 5344. +# Ports: primary on 5334, archive sync node on 5344. primary_http_port = 5334 sync_http_port = 5344 # -------------------------------------------------------------------------- -# Patch timestamps that must be identical in both the 5.1.0 primary config -# and the 5.2.0 sync config. These are also present in the base template, but -# listing them here makes the suite's intended cross-version execution schedule -# explicit and keeps both generated configs aligned. +# Baseline London-era settings injected into the primary config before the +# first launch. These are not the fork under test; they make the old binary run +# with the London behavior that Paris builds on. # -------------------------------------------------------------------------- [hardfork_compat.common_patches] pushZeroPatchTimestamp = 1 +berlinForkPatchTimestamp = 1 EIP1559TransactionsPatchTimestamp = 1 +londonForkPatchTimestamp = 1 flexibleDeploymentPatchTimestamp = 1 +snapshotIntervalSec = 0 +emptyBlockIntervalMs = 2000 -# Patch timestamps injected only into the 5.2.0 sync config right before -# launching the sync node. The Berlin (EIP-2718/2930/2929/2565) and London -# (EIP-1559 baseFee, EIP-3198 BASEFEE opcode) fork timestamps are added here -# so they are recognised only by the 5.2.0 binary -- the 5.1.0 release predates -# these config fields and rejects them as unknown. -# -# Both forks are scheduled one hour into the future (relative to sync launch, -# i.e. after the 5.1.0 primary has already produced the whole workload). This -# reproduces the real upgrade "gap period": the new 5.2.0 container is running -# but the fork has not activated yet, so it must re-execute the pre-fork -# transactions byte-for-byte like 5.1.0. Because every replayed/compared block -# predates the fork timestamp, both stateRoot AND block hash must match. Both -# forks share the same timestamp so they activate together. -# Supports absolute integers and relative expressions: "now", "now+300", "now+5m". +# -------------------------------------------------------------------------- +# Paris timestamp injected only when the primary is restarted on the current +# binary. It must be in the future at upgrade time so the suite can produce: +# 1. London-binary pre-upgrade workload +# 2. current-binary pre-Paris workload +# 3. current-binary post-Paris workload +# Supports absolute integers and relative expressions: "now", "now+30s", +# "now+5m", etc. The resolved value is copied to the archive sync config; +# it is not re-resolved for the sync node. # -------------------------------------------------------------------------- [hardfork_compat.patches] -berlinForkPatchTimestamp = "now+1h" -londonForkPatchTimestamp = "now+1h" -singleStateCommitPerBlockPatchTimestamp = 1 -snapshotIntervalSec = 0 +parisForkPatchTimestamp = "now+90s" # -------------------------------------------------------------------------- # Timeouts # -------------------------------------------------------------------------- [hardfork_compat.timeouts] -rpc_up = 360 # seconds to wait for RPC to become available -block_produce = 60 # seconds to wait for new blocks after launch -tx_mine = 120 # seconds to wait for a sent transaction to be mined -sync_catchup = 300 # seconds to wait for the 5.2.0 sync node to catch up +rpc_up = 360 # seconds to wait for RPC to become available +block_produce = 60 # seconds to wait for new blocks after launch +tx_mine = 120 # seconds to wait for a sent transaction to be mined +paris_activation = 240 # seconds to wait for Paris timestamp to become active +sync_catchup = 600 # seconds to wait for the archive sync node to catch up diff --git a/test/api-tests/hardfork-compat/suite.py b/test/api-tests/hardfork-compat/suite.py index dbeefe108..1435f7b84 100644 --- a/test/api-tests/hardfork-compat/suite.py +++ b/test/api-tests/hardfork-compat/suite.py @@ -1,11 +1,12 @@ """ hardfork-compat test suite -- entry point for run.py. -Verifies that the 5.2.0 skaled binary derives byte-identical state from a -chain produced by the 5.1.0 binary. A 5.1.0 node (primary) produces blocks -with a mixed transaction workload; a 5.2.0 node (sync, syncNode=true, -archiveMode=true) replays every block and recomputes its own state. The suite -then compares the per-block stateRoot of every block between the two binaries. +Verifies Paris fork replay compatibility across an in-place London -> current +upgrade. The primary starts on the London-capable binary, sends native/ERC20 +and PREVRANDAO workloads, restarts on the current binary with a future Paris +activation timestamp, sends another workload before activation and another after +activation. A current sync node (syncNode=true, archiveMode=true) then replays +every block and compares per-block stateRoot and block hash values. Delegates all test logic to pytest. run.py calls ``deploy()`` and ``run_tests()``; pytest manages everything else including node lifecycle, diff --git a/test/api-tests/hardfork-compat/test_hardfork_compat.py b/test/api-tests/hardfork-compat/test_hardfork_compat.py index 63056d6b1..1ed632858 100644 --- a/test/api-tests/hardfork-compat/test_hardfork_compat.py +++ b/test/api-tests/hardfork-compat/test_hardfork_compat.py @@ -1,34 +1,33 @@ """ -hardfork-compat: cross-version state-root equality across a 5.1.0 -> 5.2.0 upgrade. +hardfork-compat: Paris fork replay compatibility across a London -> current upgrade. Test flow: - 1. Verify the 5.1.0 primary RPC is up and producing blocks - 2. Run a mixed transaction workload on the 5.1.0 primary: - - legacy value transfers - - Type1 (EIP-2930 access list) transactions - - Type2 (EIP-1559) transactions - - a London-fork deploy exercising the EIP-3198 BASEFEE opcode - - a contract deploy (SSTORE in constructor) - - a factory whose constructor runs CREATE and CREATE2 - 3. Launch the 5.2.0 sync node (syncNode=true, archiveMode=true) - 4. Wait for the sync node to catch up to the primary head - 5. Compare the per-block stateRoot of every block - 6. Compare per-block hashes (covers receiptsRoot/transactionsRoot) - Both comparisons are hard assertions: any mismatch fails the test. - -Tests run in file order (sequential): the workload must complete before the -sync node is launched and the comparison runs. + 1. Start the primary node with the London-capable binary. + 2. Produce a pre-upgrade London workload: + - native token transfers + - basic ERC20 deploy/mint/transfer + - a DIFFICULTY/PREVRANDAO opcode transaction + 3. Restart the same primary node/datadir on the current binary and inject a + future ParisForkPatch timestamp. + 4. Produce the same essential workload before Paris activation. + 5. Wait until the Paris timestamp is active and produce the workload again. + 6. Launch a current-version sync node with archiveMode=true and verify that + replaying the whole chain has no per-block stateRoot or block-hash + mismatches. """ +import json import logging from pathlib import Path +import pytest from eth_account import Account from web3 import Web3 from _test_utils import ( compare_block_hashes, compare_state_roots, + wait_for_block_timestamp, wait_for_new_block, wait_for_sync_catchup, wait_for_tx, @@ -37,133 +36,295 @@ logger = logging.getLogger("hardfork-compat.test") SUITE_DIR = Path(__file__).resolve().parent +ERC20_BYTECODE_PATH = ( + SUITE_DIR.parent.parent / "unittests/libweb3jsonrpc/contracts/ERC20_bytecode.txt" +) -# --------------------------------------------------------------------------- -# Minimal contract bytecode (no Solidity compiler needed) -# -# Constructor: PUSH1 42, PUSH1 0, SSTORE -- writes 42 to slot 0 -# Then copies 1-byte runtime (STOP) to memory and RETURNs it. -# Hex: 602a6000556001601160003960016000f300 -# --------------------------------------------------------------------------- -_SIMPLE_STORAGE_BYTECODE = "0x602a6000556001601160003960016000f300" +# ERC20 from test/unittests/libweb3jsonrpc/contracts/ERC20.sol. It exposes +# mint(address,uint256), transfer(address,uint256), and balanceOf(address). +_ERC20_BYTECODE = "0x" + ERC20_BYTECODE_PATH.read_text().strip() -# --------------------------------------------------------------------------- -# Factory bytecode that exercises CREATE and CREATE2 during its own -# construction, deploying two minimal child contracts (empty runtime). -# Mirrors the berlin-compat factory so both opcodes touch state. -# --------------------------------------------------------------------------- -_CREATE_FACTORY_BYTECODE = ( - "0x6460006000f36000526005601b6000f05060006005601b6000f5" - "50600060205360016020f3" -) +# Constructor: DIFFICULTY/PREVRANDAO, PUSH1 0, SSTORE, then empty runtime. +# This exercises EIP-4399 in a state-changing transaction post-Paris. +_PREVRANDAO_RECORDER_BYTECODE = "0x4460005560006000f3" -# --------------------------------------------------------------------------- -# London-fork bytecode that exercises the EIP-3198 BASEFEE opcode (0x48). -# -# Constructor: BASEFEE, PUSH1 0, SSTORE -- writes the block base fee to slot 0 -# Then RETURNs an empty (zero-length) runtime. -# Hex: 4860005560006000f3 -# -# BASEFEE is only a valid opcode once the London fork is active; on a binary -# without London support the EVM treats 0x48 as invalid and the deploy reverts. -# Either way both binaries behave identically, so the per-block stateRoot -# comparison -- the suite's real assertion -- still holds. -# --------------------------------------------------------------------------- -_BASEFEE_BYTECODE = "0x4860005560006000f3" +_ERC20_MINT_SELECTOR = "40c10f19" +_ERC20_TRANSFER_SELECTOR = "a9059cbb" +_ERC20_BALANCE_OF_SELECTOR = "70a08231" # --------------------------------------------------------------------------- -# Helpers for sending transactions +# Transaction helpers # --------------------------------------------------------------------------- -def _send_legacy_transfer( - w3: Web3, private_key: str, recipient: str, value: int, timeout_s: int, -) -> dict: +def _raw_signed_transaction(signed) -> bytes: + if hasattr(signed, "raw_transaction"): + return signed.raw_transaction + return signed.rawTransaction + + +def _legacy_gas_price(w3: Web3) -> int: + return max(int(w3.eth.gas_price), 1) + + +def _type2_fee_cap(w3: Web3) -> int: + gas_price = _legacy_gas_price(w3) + latest = w3.eth.get_block("latest") + base_fee = int(latest.get("baseFeePerGas") or gas_price) + return max(gas_price * 2, base_fee + gas_price) + + +def _send_raw_tx(w3: Web3, private_key: str, tx: dict, timeout_s: int, label: str): account = Account.from_key(private_key) - tx = { - "chainId": w3.eth.chain_id, - "nonce": w3.eth.get_transaction_count(account.address), - "to": recipient, - "value": value, - "gas": 21000, - "gasPrice": w3.eth.gas_price, - } + tx = dict(tx) + tx.setdefault("chainId", w3.eth.chain_id) + tx.setdefault("nonce", w3.eth.get_transaction_count(account.address)) + signed = Account.sign_transaction(tx, private_key) - tx_hash = w3.eth.send_raw_transaction(signed.raw_transaction) - logger.info("Sent legacy transfer tx: %s", tx_hash.hex()) - return wait_for_tx(w3, tx_hash, timeout_s) + tx_hash = w3.eth.send_raw_transaction(_raw_signed_transaction(signed)) + logger.info("Sent %s tx: %s", label, tx_hash.hex()) + receipt = wait_for_tx(w3, tx_hash, timeout_s) + assert receipt is not None, f"{label} tx not mined within {timeout_s}s" + logger.info( + "%s tx mined: block=%d status=%s gasUsed=%d", + label, receipt["blockNumber"], receipt.get("status"), receipt["gasUsed"], + ) + return receipt -def _send_type1_tx( +def _send_legacy_transfer( w3: Web3, private_key: str, recipient: str, value: int, timeout_s: int, -) -> dict: - """Send a Type 1 (EIP-2930 access list) transaction and return the receipt.""" - account = Account.from_key(private_key) - tx = { - "type": 1, - "chainId": w3.eth.chain_id, - "nonce": w3.eth.get_transaction_count(account.address), - "to": recipient, - "value": value, - "gas": 30000, - "gasPrice": w3.eth.gas_price, - "accessList": [{"address": recipient, "storageKeys": []}], - } - signed = Account.sign_transaction(tx, private_key) - tx_hash = w3.eth.send_raw_transaction(signed.raw_transaction) - logger.info("Sent Type1 tx: %s", tx_hash.hex()) - return wait_for_tx(w3, tx_hash, timeout_s) +): + return _send_raw_tx( + w3, + private_key, + { + "to": recipient, + "value": value, + "gas": 21000, + "gasPrice": _legacy_gas_price(w3), + }, + timeout_s, + "legacy native transfer", + ) -def _send_type2_tx( +def _send_type2_transfer( w3: Web3, private_key: str, recipient: str, value: int, timeout_s: int, -) -> dict: - """Send a Type 2 (EIP-1559) transaction and return the receipt.""" - account = Account.from_key(private_key) - tx = { - "type": 2, - "chainId": w3.eth.chain_id, - "nonce": w3.eth.get_transaction_count(account.address), - "to": recipient, - "value": value, - "gas": 30000, - "maxFeePerGas": w3.eth.gas_price * 2, - "maxPriorityFeePerGas": 0, - } - signed = Account.sign_transaction(tx, private_key) - tx_hash = w3.eth.send_raw_transaction(signed.raw_transaction) - logger.info("Sent Type2 tx: %s", tx_hash.hex()) - return wait_for_tx(w3, tx_hash, timeout_s) +): + return _send_raw_tx( + w3, + private_key, + { + "type": 2, + "to": recipient, + "value": value, + "gas": 30000, + "maxFeePerGas": _type2_fee_cap(w3), + "maxPriorityFeePerGas": 0, + }, + timeout_s, + "type2 native transfer", + ) + + +def _deploy(w3: Web3, private_key: str, bytecode: str, gas: int, timeout_s: int, label: str): + return _send_raw_tx( + w3, + private_key, + { + "gas": gas, + "gasPrice": _legacy_gas_price(w3), + "data": bytecode, + }, + timeout_s, + label, + ) + + +def _address_word(address: str) -> str: + raw = address[2:] if address.startswith("0x") else address + assert len(raw) == 40, f"Bad address length for {address}" + return raw.lower().rjust(64, "0") + + +def _uint_word(value: int) -> str: + return int(value).to_bytes(32, "big").hex() + +def _erc20_call_data(selector: str, address: str, amount: int | None = None) -> str: + data = selector + _address_word(address) + if amount is not None: + data += _uint_word(amount) + return "0x" + data -def _deploy(w3: Web3, private_key: str, bytecode: str, gas: int, timeout_s: int) -> dict: + +def _send_contract_call( + w3: Web3, private_key: str, to: str, data: str, gas: int, timeout_s: int, label: str, +): + return _send_raw_tx( + w3, + private_key, + { + "to": to, + "gas": gas, + "gasPrice": _legacy_gas_price(w3), + "data": data, + }, + timeout_s, + label, + ) + + +def _erc20_balance_of(w3: Web3, token: str, address: str) -> int: + result = w3.eth.call({"to": token, "data": _erc20_call_data(_ERC20_BALANCE_OF_SELECTOR, address)}) + return int.from_bytes(bytes(result), "big") + + +def _erc20_mint(w3: Web3, private_key: str, token: str, to: str, amount: int, timeout_s: int): + receipt = _send_contract_call( + w3, + private_key, + token, + _erc20_call_data(_ERC20_MINT_SELECTOR, to, amount), + 120_000, + timeout_s, + "ERC20 mint", + ) + assert receipt["status"] == 1, "ERC20 mint reverted" + return receipt + + +def _erc20_transfer( + w3: Web3, private_key: str, token: str, recipient: str, amount: int, timeout_s: int, +): + sender = Account.from_key(private_key).address + balance = _erc20_balance_of(w3, token, sender) + assert balance >= amount, f"ERC20 sender balance too low: {balance} < {amount}" + + receipt = _send_contract_call( + w3, + private_key, + token, + _erc20_call_data(_ERC20_TRANSFER_SELECTOR, recipient, amount), + 120_000, + timeout_s, + "ERC20 transfer", + ) + assert receipt["status"] == 1, "ERC20 transfer reverted" + assert _erc20_balance_of(w3, token, recipient) >= amount, "ERC20 recipient balance not updated" + return receipt + + +def _deploy_erc20(w3: Web3, private_key: str, timeout_s: int) -> str: + receipt = _deploy(w3, private_key, _ERC20_BYTECODE, 2_500_000, timeout_s, "ERC20 deploy") + assert receipt["status"] == 1, "ERC20 deploy reverted" + token = receipt["contractAddress"] + assert token, "ERC20 deploy did not return a contract address" + logger.info("ERC20 deployed at %s", token) + return token + + +def _deploy_prevrandao_recorder(w3: Web3, private_key: str, timeout_s: int): + receipt = _deploy( + w3, + private_key, + _PREVRANDAO_RECORDER_BYTECODE, + 100_000, + timeout_s, + "PREVRANDAO recorder deploy", + ) + assert receipt["status"] == 1, "PREVRANDAO recorder deploy reverted" + return receipt + + +def _read_schain_value(config_path: Path, key: str): + with open(config_path) as f: + cfg = json.load(f) + return cfg["skaleConfig"]["sChain"].get(key) + + +def _assert_receipts_before_timestamp(w3: Web3, receipts: list, timestamp: int, label: str) -> None: + for receipt in receipts: + block = w3.eth.get_block(receipt["blockNumber"]) + assert int(block["timestamp"]) < timestamp, ( + f"{label} receipt landed after Paris activation: " + f"block={block['number']} timestamp={block['timestamp']} activation={timestamp}" + ) + + +def _assert_receipts_after_paris(w3: Web3, receipts: list, timestamp: int, label: str) -> None: + for receipt in receipts: + block = w3.eth.get_block(receipt["blockNumber"]) + assert int(block["timestamp"]) >= timestamp, ( + f"{label} receipt landed before Paris activation: " + f"block={block['number']} timestamp={block['timestamp']} activation={timestamp}" + ) + assert int(block["difficulty"]) == 0, ( + f"Post-Paris block {block['number']} has non-zero difficulty {block['difficulty']}" + ) + + +def _run_paris_workload_phase( + w3: Web3, + private_key: str, + timeouts: dict, + workload_state: dict, + phase: str, + *, + deploy_token: bool = False, +) -> list: + """Run the minimal Paris-compat workload and return all receipts.""" + timeout = timeouts.get("tx_mine", 120) account = Account.from_key(private_key) - tx = { - "chainId": w3.eth.chain_id, - "nonce": w3.eth.get_transaction_count(account.address), - "gas": gas, - "gasPrice": w3.eth.gas_price, - "data": bytecode, - } - signed = Account.sign_transaction(tx, private_key) - tx_hash = w3.eth.send_raw_transaction(signed.raw_transaction) - logger.info("Sent deploy tx: %s", tx_hash.hex()) - return wait_for_tx(w3, tx_hash, timeout_s) + receipts = [] + + logger.info("=== Paris hardfork-compat workload phase: %s ===", phase) + + receipts.append( + _send_legacy_transfer( + w3, private_key, Account.create().address, 100 + len(phase), timeout, + ) + ) + assert receipts[-1]["status"] == 1, f"{phase}: legacy native transfer reverted" + + receipts.append( + _send_type2_transfer( + w3, private_key, Account.create().address, 200 + len(phase), timeout, + ) + ) + assert receipts[-1]["status"] == 1, f"{phase}: type2 native transfer reverted" + + if deploy_token or "erc20" not in workload_state: + token = _deploy_erc20(w3, private_key, timeout) + workload_state["erc20"] = token + receipts.append(_erc20_mint(w3, private_key, token, account.address, 1_000_000, timeout)) + token = workload_state["erc20"] + receipts.append( + _erc20_transfer( + w3, private_key, token, Account.create().address, 10 + len(phase), timeout, + ) + ) + + receipts.append(_deploy_prevrandao_recorder(w3, private_key, timeout)) + + logger.info("Completed workload phase %s with %d transactions", phase, len(receipts)) + return receipts # --------------------------------------------------------------------------- # Tests (ordered by file position) # --------------------------------------------------------------------------- -def test_primary_rpc_up(w3_primary: Web3): - """Verify the 5.1.0 primary node RPC is reachable.""" +def test_london_primary_rpc_up(w3_primary: Web3): + """Verify the London primary node RPC is reachable.""" bn = w3_primary.eth.block_number - assert bn >= 0, "Primary (5.1.0) RPC is not responding" - logger.info("Primary (5.1.0) RPC is up at block %d", bn) + assert bn >= 0, "London primary RPC is not responding" + logger.info("London primary RPC is up at block %d", bn) -def test_primary_block_production(w3_primary: Web3, timeouts: dict): - """Verify the 5.1.0 primary node is producing blocks.""" +def test_london_primary_block_production(w3_primary: Web3, timeouts: dict): + """Verify the London primary node is producing blocks.""" start = w3_primary.eth.block_number timeout = timeouts.get("block_produce", 60) new_bn = wait_for_new_block(w3_primary, start, timeout) @@ -173,91 +334,101 @@ def test_primary_block_production(w3_primary: Web3, timeouts: dict): logger.info("Block %d produced (was %d)", new_bn, start) -def test_workload_legacy_transfers(w3_primary: Web3, private_key: str, timeouts: dict): - """Send several legacy value transfers on the 5.1.0 primary.""" - timeout = timeouts.get("tx_mine", 120) - for i in range(3): - recipient = Account.create().address - receipt = _send_legacy_transfer(w3_primary, private_key, recipient, 1 + i, timeout) - assert receipt is not None, f"Legacy transfer {i} not mined within {timeout}s" - assert receipt["status"] == 1, f"Legacy transfer {i} reverted (status=0)" - logger.info("Legacy transfers mined on 5.1.0 primary") +def test_london_pre_upgrade_workload( + w3_primary: Web3, private_key: str, timeouts: dict, workload_state: dict, +): + """Run native/ERC20/PREVRANDAO workload before the binary upgrade.""" + receipts = _run_paris_workload_phase( + w3_primary, + private_key, + timeouts, + workload_state, + "london-pre-upgrade", + deploy_token=True, + ) + assert receipts, "No pre-upgrade receipts produced" -def test_workload_type1_tx(w3_primary: Web3, private_key: str, timeouts: dict): - """Send a Type1 (EIP-2930 access list) transaction on the 5.1.0 primary.""" - timeout = timeouts.get("tx_mine", 120) - recipient = Account.create().address - receipt = _send_type1_tx(w3_primary, private_key, recipient, 1, timeout) - assert receipt is not None, "Type1 tx not mined" - assert receipt["status"] == 1, "Type1 tx reverted (status=0)" - logger.info( - "Type1 tx mined: block=%d gasUsed=%d", receipt["blockNumber"], receipt["gasUsed"] +def test_upgrade_primary_to_current_with_paris_timestamp( + primary_node, current_binary: Path, hardfork_cfg: dict, timeouts: dict, workload_state: dict, +): + """Restart primary on current binary with a future ParisForkPatch timestamp.""" + if not current_binary.is_file(): + pytest.fail( + f"Current skaled binary not found: {current_binary}\n" + "Set HARDFORK_COMPAT_CURRENT_BINARY or [hardfork_compat].current_binary." + ) + + before_bn = primary_node.w3.eth.block_number + logger.info("Primary at block %d before current-binary upgrade", before_bn) + + primary_node.upgrade_to_current( + binary=current_binary, + patches=hardfork_cfg.get("patches", {}), + timeout_s=timeouts.get("rpc_up", 360), ) + activation = _read_schain_value(primary_node.cfg_path, "parisForkPatchTimestamp") + assert isinstance(activation, int) and activation > 0, ( + "[hardfork_compat.patches].parisForkPatchTimestamp must resolve to a positive integer" + ) + workload_state["paris_activation_timestamp"] = activation -def test_workload_type2_tx(w3_primary: Web3, private_key: str, timeouts: dict): - """Send a Type2 (EIP-1559) transaction on the 5.1.0 primary.""" - timeout = timeouts.get("tx_mine", 120) - recipient = Account.create().address - receipt = _send_type2_tx(w3_primary, private_key, recipient, 1, timeout) - assert receipt is not None, "Type2 tx not mined" - assert receipt["status"] == 1, "Type2 tx reverted (status=0)" + latest = primary_node.w3.eth.get_block("latest") + assert int(latest["timestamp"]) < activation, ( + "Paris activation timestamp must be in the future at upgrade time so the suite can " + f"run a pre-activation workload (latest={latest['timestamp']}, activation={activation})" + ) logger.info( - "Type2 tx mined: block=%d gasUsed=%d", receipt["blockNumber"], receipt["gasUsed"] + "Primary upgraded to current binary at block=%d timestamp=%d; Paris activates at %d", + latest["number"], latest["timestamp"], activation, ) -def test_workload_london_basefee(w3_primary: Web3, private_key: str, timeouts: dict): - """Deploy a London-fork contract that uses the EIP-3198 BASEFEE opcode. +def test_current_pre_paris_workload( + w3_primary: Web3, private_key: str, timeouts: dict, workload_state: dict, +): + """Run another workload on current binary before Paris activation.""" + activation = workload_state["paris_activation_timestamp"] + latest = w3_primary.eth.get_block("latest") + assert int(latest["timestamp"]) < activation, ( + "Paris activated before the current-version pre-activation workload could start" + ) - On a London-capable binary the constructor stores the block base fee to - slot 0 and the deploy succeeds; on a pre-London binary opcode 0x48 is - invalid and the deploy reverts. Both binaries behave identically, so the - transaction is recorded the same way on the primary and the sync node and - the per-block stateRoot comparison still passes. The assertion therefore - only requires the tx to be mined and included in a block. - """ - timeout = timeouts.get("tx_mine", 120) - receipt = _deploy(w3_primary, private_key, _BASEFEE_BYTECODE, 100_000, timeout) - assert receipt is not None, "BASEFEE deploy not mined" - assert receipt["blockNumber"] is not None, "BASEFEE deploy not included in a block" - logger.info( - "London BASEFEE deploy mined: block=%d status=%d gasUsed=%d", - receipt["blockNumber"], receipt["status"], receipt["gasUsed"], + receipts = _run_paris_workload_phase( + w3_primary, private_key, timeouts, workload_state, "current-pre-paris", ) + _assert_receipts_before_timestamp(w3_primary, receipts, activation, "current-pre-paris") -def test_workload_contract_deploy(w3_primary: Web3, private_key: str, timeouts: dict): - """Deploy a contract (SSTORE in constructor) on the 5.1.0 primary.""" - timeout = timeouts.get("tx_mine", 120) - receipt = _deploy(w3_primary, private_key, _SIMPLE_STORAGE_BYTECODE, 100_000, timeout) - assert receipt is not None, "Contract deploy not mined" - assert receipt["status"] == 1, "Contract deploy reverted (status=0)" - assert receipt["contractAddress"] is not None, "No contract address in receipt" - logger.info( - "Contract deployed: addr=%s block=%d", receipt["contractAddress"], receipt["blockNumber"] +def test_wait_for_paris_activation(w3_primary: Web3, timeouts: dict, workload_state: dict): + """Wait until the chain timestamp reaches ParisForkPatch activation.""" + activation = workload_state["paris_activation_timestamp"] + timeout = timeouts.get("paris_activation", 180) + activation_block = wait_for_block_timestamp(w3_primary, activation, timeout) + assert activation_block is not None, ( + f"No block reached Paris activation timestamp {activation} within {timeout}s" ) + workload_state["paris_activation_block"] = activation_block + logger.info("Paris active at block %d (timestamp >= %d)", activation_block, activation) -def test_workload_create_create2_factory(w3_primary: Web3, private_key: str, timeouts: dict): - """Deploy a factory whose constructor runs CREATE and CREATE2 on the 5.1.0 primary.""" - timeout = timeouts.get("tx_mine", 120) - receipt = _deploy(w3_primary, private_key, _CREATE_FACTORY_BYTECODE, 300_000, timeout) - assert receipt is not None, "Factory deploy not mined" - assert receipt["status"] == 1, "Factory deploy reverted (status=0)" - assert receipt["contractAddress"] is not None, "No contract address in receipt" - logger.info( - "CREATE/CREATE2 factory deployed: addr=%s block=%d", - receipt["contractAddress"], receipt["blockNumber"], +def test_current_post_paris_workload( + w3_primary: Web3, private_key: str, timeouts: dict, workload_state: dict, +): + """Run the workload after Paris activation; block difficulty must be zero.""" + activation = workload_state["paris_activation_timestamp"] + receipts = _run_paris_workload_phase( + w3_primary, private_key, timeouts, workload_state, "current-post-paris", ) + _assert_receipts_after_paris(w3_primary, receipts, activation, "current-post-paris") def test_sync_catchup_and_state_root_comparison( w3_primary: Web3, primary_cfg_path, sync_binary: Path, hardfork_cfg: dict, timeouts: dict, ): - """Launch the 5.2.0 sync node, wait for catch-up, compare per-block stateRoot.""" + """Launch current archive sync node, catch up, compare state roots and hashes.""" from conftest import ( _launch_node, _resolve, @@ -266,16 +437,12 @@ def test_sync_catchup_and_state_root_comparison( make_sync_config, SUITE_DIR, ) - from run import inject_patches, set_ulimit + from run import set_ulimit if not sync_binary.is_file(): - import pytest pytest.fail( - f"5.2.0 skaled binary not found: {sync_binary}\n" - "Set HARDFORK_COMPAT_V520_BINARY or [hardfork_compat].v520_binary, " - "or build with:\n" - " git checkout v5.2.0 && cmake -H. -Bbuild-v520 -DCMAKE_BUILD_TYPE=Release " - "&& cmake --build build-v520 --target skaled -- -j4" + f"Current sync skaled binary not found: {sync_binary}\n" + "Set HARDFORK_COMPAT_CURRENT_BINARY or [hardfork_compat].current_binary." ) sync_http_port = int(hardfork_cfg.get("sync_http_port", 5344)) @@ -285,11 +452,9 @@ def test_sync_catchup_and_state_root_comparison( ) primary_bn = w3_primary.eth.block_number - logger.info("Primary (5.1.0) at block %d before 5.2.0 sync launch", primary_bn) + logger.info("Primary at block %d before archive sync launch", primary_bn) make_sync_config(Path(str(primary_cfg_path)), sync_cfg_out, sync_http_port) - inject_patches(str(sync_cfg_out), hardfork_cfg.get("common_patches", {})) - inject_patches(str(sync_cfg_out), hardfork_cfg.get("patches", {})) set_ulimit() log_dir = SUITE_DIR / "logs" @@ -297,7 +462,7 @@ def test_sync_catchup_and_state_root_comparison( proc, log_fd = _launch_node( sync_binary, sync_cfg_out, sync_http_port, sync_datadir, - log_dir / "skaled-sync-v520.log", "SYNC(5.2.0)", + log_dir / "skaled-sync-current.log", "SYNC(current archive)", ) w3_sync = Web3(Web3.HTTPProvider( @@ -306,52 +471,40 @@ def test_sync_catchup_and_state_root_comparison( try: rpc_timeout = timeouts.get("rpc_up", 360) - _wait_for_rpc(w3_sync, rpc_timeout, "SYNC(5.2.0)") + _wait_for_rpc(w3_sync, rpc_timeout, "SYNC(current archive)") sync_timeout = timeouts.get("sync_catchup", 300) caught_up = wait_for_sync_catchup(w3_primary, w3_sync, sync_timeout) assert caught_up, ( - f"5.2.0 sync node did not catch up within {sync_timeout}s " + f"Current archive sync node did not catch up within {sync_timeout}s " f"(primary={w3_primary.eth.block_number}, sync={w3_sync.eth.block_number})" ) compare_bn = min(w3_primary.eth.block_number, w3_sync.eth.block_number) - logger.info("Comparing stateRoot for blocks 0..%d (5.1.0 vs 5.2.0)", compare_bn) + logger.info("Comparing stateRoot and block hash for blocks 0..%d", compare_bn) # Run both comparisons before asserting so a failure reports the full - # picture (stateRoot and hash mismatches) in one go. The block hash - # embeds receiptsRoot/transactionsRoot, so it must fail the test just - # like a stateRoot mismatch. + # picture (stateRoot and hash mismatches) in one go. root_mismatches = compare_state_roots(w3_primary, w3_sync, compare_bn) hash_mismatches = compare_block_hashes(w3_primary, w3_sync, compare_bn) if root_mismatches: - logger.error( - "stateRoot mismatches between 5.1.0 and 5.2.0 at blocks: %s", - root_mismatches, - ) + logger.error("stateRoot mismatches at blocks: %s", root_mismatches) else: - logger.info( - "All %d block stateRoots match between 5.1.0 and 5.2.0", compare_bn + 1 - ) + logger.info("All %d block stateRoots match", compare_bn + 1) if hash_mismatches: - logger.error( - "Block hash mismatches between 5.1.0 and 5.2.0 at blocks: %s", - hash_mismatches, - ) + logger.error("Block hash mismatches at blocks: %s", hash_mismatches) else: - logger.info( - "All %d block hashes match between 5.1.0 and 5.2.0", compare_bn + 1 - ) + logger.info("All %d block hashes match", compare_bn + 1) assert not root_mismatches and not hash_mismatches, ( - f"5.1.0 vs 5.2.0 divergence: stateRoot mismatches at blocks " + f"Paris hardfork replay divergence: stateRoot mismatches at blocks " f"{root_mismatches}, block hash mismatches at blocks {hash_mismatches}" ) logger.info( - "All %d block stateRoots and hashes match between 5.1.0 and 5.2.0", + "All %d block stateRoots and hashes match after London->Paris replay", compare_bn + 1, ) finally: - _stop_node(proc, log_fd, "SYNC(5.2.0)") + _stop_node(proc, log_fd, "SYNC(current archive)") diff --git a/test/unittests/libethereum/ParisForkTests.cpp b/test/unittests/libethereum/ParisForkTests.cpp index fa58d098f..1fbfebb75 100644 --- a/test/unittests/libethereum/ParisForkTests.cpp +++ b/test/unittests/libethereum/ParisForkTests.cpp @@ -89,4 +89,68 @@ BOOST_AUTO_TEST_CASE( parisForkDifficultyZeroThrowsPreParis ) { #endif } +// EIP-3675: post-Paris blocks must have difficulty=0; non-zero difficulty changes the block hash. +BOOST_AUTO_TEST_CASE( parisForkNonZeroDifficultyThrowsPostParis ) { + PatchableChainParams cp( genesisInfo( Network::ConstantinopleTest ) ); +#ifndef FAIR + cp.setPatchTimestamp( SchainPatchEnum::ParisForkPatch, 1 ); +#endif + SchainPatch::init( cp ); + SchainPatch::useLatestBlockTimestamp( 1 ); + std::unique_ptr< SealEngineFace > se( cp.createSealEngine() ); + + BlockHeader parent; + parent.setGasLimit( 0x7fffffffffffffff ); + parent.setGasUsed( 0 ); + parent.setDifficulty( 0 ); + parent.setTimestamp( 1 ); + + BlockHeader bi; + bi.setParentHash( parent.hash() ); + bi.setNumber( 1 ); + bi.setGasLimit( 0x7fffffffffffffff ); + bi.setGasUsed( 0 ); + bi.setDifficulty( 42 ); + bi.setTimestamp( 2 ); + + BOOST_REQUIRE_THROW( se->verify( QuickNonce, bi, parent, bytesConstRef{} ), InvalidDifficulty ); + + ChainParams resetCp( genesisInfo( Network::ConstantinopleTest ) ); + SchainPatch::init( resetCp ); + SchainPatch::useLatestBlockTimestamp( 0 ); +} + +// SKALE canonical post-Paris headers use no Ethash seal fields; mixHash/nonce would change hash. +BOOST_AUTO_TEST_CASE( parisForkSealFieldsThrowPostParis ) { + PatchableChainParams cp( genesisInfo( Network::ConstantinopleTest ) ); +#ifndef FAIR + cp.setPatchTimestamp( SchainPatchEnum::ParisForkPatch, 1 ); +#endif + SchainPatch::init( cp ); + SchainPatch::useLatestBlockTimestamp( 1 ); + std::unique_ptr< SealEngineFace > se( cp.createSealEngine() ); + + BlockHeader parent; + parent.setGasLimit( 0x7fffffffffffffff ); + parent.setGasUsed( 0 ); + parent.setDifficulty( 0 ); + parent.setTimestamp( 1 ); + + BlockHeader bi; + bi.setParentHash( parent.hash() ); + bi.setNumber( 1 ); + bi.setGasLimit( 0x7fffffffffffffff ); + bi.setGasUsed( 0 ); + bi.setDifficulty( 0 ); + bi.setTimestamp( 2 ); + bi.setSeal( 0, h256( 0 ) ); + bi.setSeal( 1, Nonce( 0 ) ); + + BOOST_REQUIRE_THROW( se->verify( QuickNonce, bi, parent, bytesConstRef{} ), InvalidBlockFormat ); + + ChainParams resetCp( genesisInfo( Network::ConstantinopleTest ) ); + SchainPatch::init( resetCp ); + SchainPatch::useLatestBlockTimestamp( 0 ); +} + BOOST_AUTO_TEST_SUITE_END() From 6b14021705dc66fb70053660f73b2f06d634f978 Mon Sep 17 00:00:00 2001 From: badrogger Date: Thu, 16 Jul 2026 11:44:11 +0100 Subject: [PATCH 08/14] 1795 Fix FAIR stuck --- .github/actions/testeth-run/action.yml | 14 ++++++--- .github/workflows/test.yml | 2 ++ test/unittests/libweb3jsonrpc/jsonrpc.cpp | 30 ++++++++++++++++--- .../mapreduce_consensus/ConsensusEngine.cpp | 9 ++++++ 4 files changed, 47 insertions(+), 8 deletions(-) diff --git a/.github/actions/testeth-run/action.yml b/.github/actions/testeth-run/action.yml index b31d250ea..99706a956 100644 --- a/.github/actions/testeth-run/action.yml +++ b/.github/actions/testeth-run/action.yml @@ -23,6 +23,12 @@ runs: cd "${{ inputs.build_dir }}/test" + # Per-suite hard cap so one hung suite fails fast instead of stalling the job. + # -k sends SIGKILL if the suite ignores the initial SIGTERM (e.g. stuck in + # consensus teardown). Overridable via the SUITE_TIMEOUT env var. + SUITE_TIMEOUT="${SUITE_TIMEOUT:-25m}" + run_testeth() { timeout -k 1m "$SUITE_TIMEOUT" ./testeth "$@"; } + if [[ "${{ inputs.mode }}" == "historic" ]]; then mkdir -p /tmp/tests/ if [[ "${{ inputs.verbosity }}" == "1" ]]; then @@ -30,9 +36,9 @@ runs: fi if [[ "${{ inputs.verbosity }}" == "1" ]]; then - ./testeth -t JsonRpcSuite -- --express && touch /tmp/tests/JsonRpcSuitePassed || true + run_testeth -t JsonRpcSuite -- --express && touch /tmp/tests/JsonRpcSuitePassed || true else - ls /tmp/tests/JsonRpcSuitePassed 2>/dev/null || ./testeth -t JsonRpcSuite -- --express --verbosity 4 + ls /tmp/tests/JsonRpcSuitePassed 2>/dev/null || run_testeth -t JsonRpcSuite -- --express --verbosity 4 fi exit 0 fi @@ -58,7 +64,7 @@ runs: run_test() { local suite="$1" - if ./testeth --report_level=detailed -t "$suite" -- --express; then + if run_testeth --report_level=detailed -t "$suite" -- --express; then touch "/tmp/tests/${suite}Passed" else echo "Suite ${suite} failed (continuing)" @@ -72,7 +78,7 @@ runs: if ls "/tmp/tests/${suite}Passed" 2>/dev/null; then return fi - ./testeth --report_level=detailed -t "$suite" -- --express --verbosity 4 + run_testeth --report_level=detailed -t "$suite" -- --express --verbosity 4 } for s in "${TEST_SUITES[@]}"; do rerun_test "$s"; done fi diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 7588282ec..e6952792d 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -24,6 +24,8 @@ jobs: build-and-test: name: ${{ matrix.name }} runs-on: ubuntu-22.04 + # Backstop so a hung test can't ride GitHub's 6h default and stall the queue. + timeout-minutes: 120 strategy: fail-fast: false diff --git a/test/unittests/libweb3jsonrpc/jsonrpc.cpp b/test/unittests/libweb3jsonrpc/jsonrpc.cpp index 191186ad9..ff107c8d9 100644 --- a/test/unittests/libweb3jsonrpc/jsonrpc.cpp +++ b/test/unittests/libweb3jsonrpc/jsonrpc.cpp @@ -448,6 +448,21 @@ struct JsonRpcFixture : public TestOutputHelperFixture { // this fixture is used in all tests to load config. So also init bls library as well libBLS::init(); + // Each default-config fixture instance uses a distinct consensus base port. + // Reusing one fixed port across the many sequential consensus start/stops in a + // suite races the OS releasing the port: the next node fails to bind and the + // block wait at the end of this constructor never completes - a frequent CI + // hang (especially in FAIR/BITE builds, which spin up extra sockets). + // Boost.Test runs fixtures sequentially, so a plain counter is enough; step by + // 32 to clear the range consensus binds above basePort (basePort+0..+11, see + // port_type in libconsensus/SkaleCommon.h). Only applied on the programmatic + // (_config == "") path below, which sets nodeInfo and node ports consistently; + // explicit-config tests keep their config's port (their node topology, e.g. + // FAIR grouped nodeGroups, must stay internally consistent). + static size_t s_consensusPortSeq = rand_port; + s_consensusPortSeq += 32; + const size_t fixtureBasePort = 1024 + ( s_consensusPortSeq % 60000 ); + if ( _config != "" ) { if ( !_generation2 ) { Json::Value ret; @@ -545,8 +560,8 @@ struct JsonRpcFixture : public TestOutputHelperFixture { // so that tests can be run in parallel // TODO: better make it use ethemeral in-memory databases chainParams->extraData = h256::random().asBytes(); - chainParams->nodeInfo.port = chainParams->nodeInfo.port6 = rand_port; - chainParams->sChain.nodes[0].port = chainParams->sChain.nodes[0].port6 = rand_port; + chainParams->nodeInfo.port = chainParams->nodeInfo.port6 = fixtureBasePort; + chainParams->sChain.nodes[0].port = chainParams->sChain.nodes[0].port6 = fixtureBasePort; chainParams->skaleDisableChainIdCheck = true; if ( params.count( "getLogsBlocksLimit" ) && stoi( params.at( "getLogsBlocksLimit" ) ) ) @@ -578,8 +593,15 @@ struct JsonRpcFixture : public TestOutputHelperFixture { dev::eth::g_skaleHost = client->skaleHost(); client->startWorking(); - if ( !_isSyncNode ) - blockPromise.get_future().wait(); + if ( !_isSyncNode ) { + // Bounded wait: if consensus fails to start (e.g. it could not bind its + // port), the first block never arrives. Fail loudly instead of hanging + // the whole suite - and the CI job - forever. + if ( blockPromise.get_future().wait_for( std::chrono::seconds( 60 ) ) != + std::future_status::ready ) + BOOST_FAIL( "JsonRpcFixture setup timed out waiting for the first block " + "(consensus failed to start)" ); + } using FullServer = ModularServer< rpc::EthFace, rpc::SkaleFace, rpc::NetFace, rpc::Web3Face, rpc::AdminEthFace /*, rpc::AdminNetFace*/, rpc::DebugFace, rpc::TestFace >; diff --git a/test/unittests/mapreduce_consensus/ConsensusEngine.cpp b/test/unittests/mapreduce_consensus/ConsensusEngine.cpp index 2b79c2900..8b0a737bc 100644 --- a/test/unittests/mapreduce_consensus/ConsensusEngine.cpp +++ b/test/unittests/mapreduce_consensus/ConsensusEngine.cpp @@ -157,7 +157,16 @@ class SingleNodeConsensusFixture : public ConsensusExtFace { m_consensus->exitGracefully(); m_consensusThread.join(); + // Bounded wait: a fixed cap (600 * 100ms = 60s) avoids hanging the whole + // test binary - and the CI job - forever if consensus never reaches + // CONSENSUS_EXITED. + int exitWaitTenthsSec = 0; while ( m_consensus->getStatus() != CONSENSUS_EXITED ) { + if ( ++exitWaitTenthsSec > 600 ) { + std::cerr << "SingleNodeConsensusFixture: timed out waiting for consensus to exit" + << std::endl; + break; + } timespec ms100{ 0, 100000000 }; nanosleep( &ms100, nullptr ); } From 4266a5db3d961826521f70a574262f5b388d4df8 Mon Sep 17 00:00:00 2001 From: badrogger Date: Tue, 21 Jul 2026 12:06:10 +0100 Subject: [PATCH 09/14] 1795 Fix FAIR tests --- libethcore/BlockHeader.cpp | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/libethcore/BlockHeader.cpp b/libethcore/BlockHeader.cpp index 61c467306..070739b16 100644 --- a/libethcore/BlockHeader.cpp +++ b/libethcore/BlockHeader.cpp @@ -239,6 +239,10 @@ void BlockHeader::populateFromParent( BlockHeader const& _parent ) { m_parentHash = _parent.m_hash; m_gasLimit = _parent.m_gasLimit; m_difficulty = _parent.m_difficulty; + // EIP-3675 (Paris): post-merge blocks must carry difficulty 0. Activation is + // keyed to the parent timestamp, same as the check in SealEngineFace::verify. + if ( ParisForkPatch::isEnabledWhen( static_cast< time_t >( _parent.timestamp() ) ) ) + m_difficulty = 0; m_gasUsed = 0; m_baseFeePerGas = _parent.m_baseFeePerGas; // At London activation, the parent may be a pre-London block with baseFeePerGas=0 From 695d440b86d38b2614eef64a0f5cc0616530e7d9 Mon Sep 17 00:00:00 2001 From: badrogger Date: Thu, 30 Jul 2026 12:57:39 +0100 Subject: [PATCH 10/14] 1795 Rename difficulty to prevrandao --- libethashseal/Ethash.cpp | 15 ++-- libethashseal/Ethash.h | 5 ++ libethcore/BlockHeader.h | 2 + libethereum/SchainPatch.h | 2 +- libevm/ExtVMFace.cpp | 4 +- libevm/ExtVMFace.h | 1 + libevm/Instruction.cpp | 2 +- libevm/Instruction.h | 19 ++--- libevm/LegacyVM.cpp | 5 +- libevm/LegacyVMConfig.h | 2 +- libskale-interpreter/VM.cpp | 2 +- libskale-interpreter/VMConfig.h | 2 +- .../libethcore/BlockHeaderRLPTest.cpp | 32 +++++++++ test/unittests/libethereum/ParisForkTests.cpp | 72 ++++++++++++++++++- test/unittests/libevm/VMTest.cpp | 13 +++- 15 files changed, 150 insertions(+), 28 deletions(-) diff --git a/libethashseal/Ethash.cpp b/libethashseal/Ethash.cpp index c29854b54..0fba8ee03 100644 --- a/libethashseal/Ethash.cpp +++ b/libethashseal/Ethash.cpp @@ -93,9 +93,16 @@ void Ethash::verify( Strictness _s, BlockHeader const& _bi, BlockHeader const& _ const bool isParis = ParisForkPatch::isEnabledWhen( static_cast< time_t >( _parent.timestamp() ) ); if ( isParis ) { - if ( _bi.sealFieldCount() != 0 ) + if ( _bi.sealFieldCount() != 2 ) + BOOST_THROW_EXCEPTION( + InvalidBlockFormat() + << errinfo_comment( "Paris block header must contain prevRandao and nonce" ) ); + if ( prevRandao( _bi ) != h256( 0 ) ) BOOST_THROW_EXCEPTION( InvalidBlockFormat() << errinfo_comment( - "Paris block header must use SKALE no-seal format" ) ); + "Paris block header prevRandao must be zero" ) ); + if ( nonce( _bi ) != Nonce( 0 ) ) + BOOST_THROW_EXCEPTION( InvalidBlockFormat() << errinfo_comment( + "Paris block header nonce must be zero" ) ); } else { // Check difficulty is correct given the two timestamps. auto expected = calculateDifficulty( _bi, _parent ); @@ -209,8 +216,8 @@ void Ethash::populateFromParent( BlockHeader& _bi, BlockHeader const& _parent ) SealEngineFace::populateFromParent( _bi, _parent ); if ( ParisForkPatch::isEnabledWhen( static_cast< time_t >( _parent.timestamp() ) ) ) { _bi.setDifficulty( 0 ); - // Keep SKALE's no-seal header shape. Setting only mixHash would create a - // London header with one seal field, which BlockHeader::populate() rejects. + setPrevRandao( _bi, h256( 0 ) ); + setNonce( _bi, Nonce( 0 ) ); } else { _bi.setDifficulty( calculateDifficulty( _bi, _parent ) ); } diff --git a/libethashseal/Ethash.h b/libethashseal/Ethash.h index 92dd767cb..ddc2af932 100644 --- a/libethashseal/Ethash.h +++ b/libethashseal/Ethash.h @@ -67,6 +67,7 @@ class Ethash : public SealEngineBase { static h256 seedHash( BlockHeader const& _bi ); static Nonce nonce( BlockHeader const& _bi ) { return _bi.seal< Nonce >( NonceField ); } static h256 mixHash( BlockHeader const& _bi ) { return _bi.seal< h256 >( MixHashField ); } + static h256 prevRandao( BlockHeader const& _bi ) { return _bi.prevRandao(); } static h256 boundary( BlockHeader const& _bi ) { auto d = _bi.difficulty(); return d ? ( h256 ) u256( ( bigint( 1 ) << 256 ) / d ) : h256(); @@ -79,6 +80,10 @@ class Ethash : public SealEngineBase { _bi.setSeal( MixHashField, _v ); return _bi; } + static BlockHeader& setPrevRandao( BlockHeader& _bi, h256 const& _v ) { + _bi.setPrevRandao( _v ); + return _bi; + } u256 calculateDifficulty( BlockHeader const& _bi, BlockHeader const& _parent ) const; u256 childGasLimit( BlockHeader const& _bi, u256 const& _gasFloorTarget = Invalid256 ) const; diff --git a/libethcore/BlockHeader.h b/libethcore/BlockHeader.h index 9932375f3..50f1fc9e2 100644 --- a/libethcore/BlockHeader.h +++ b/libethcore/BlockHeader.h @@ -185,6 +185,7 @@ class BlockHeader { void setSeal( T const& _value ) { setSeal( 0, _value ); } + void setPrevRandao( h256 const& _value ) { setSeal( 0, _value ); } h256 const& parentHash() const { return m_parentHash; } h256 const& sha3Uncles() const { return m_sha3Uncles; } @@ -200,6 +201,7 @@ class BlockHeader { bytes const& extraData() const { return m_extraData; } LogBloom const& logBloom() const { return m_logBloom; } u256 const& difficulty() const { return m_difficulty; } + h256 prevRandao() const { return seal< h256 >( 0 ); } u256 baseFeePerGas() const { return m_baseFeePerGas; } void setBaseFeePerGas( u256 const& _v ) { m_baseFeePerGas = _v; diff --git a/libethereum/SchainPatch.h b/libethereum/SchainPatch.h index 9b5d1d4e6..7636969b3 100644 --- a/libethereum/SchainPatch.h +++ b/libethereum/SchainPatch.h @@ -215,7 +215,7 @@ DEFINE_SIMPLE_PATCH( ContractCreationReadOnlyPatch ); /* * Paris fork (EIP-3675 + EIP-4399): difficulty=0, no uncles, - * DIFFICULTY opcode returns prevRandao (0 in skaled, no beacon RANDAO). + * PREVRANDAO opcode returns the zero prevRandao stored in the block header. */ DEFINE_SIMPLE_PATCH( ParisForkPatch ); diff --git a/libevm/ExtVMFace.cpp b/libevm/ExtVMFace.cpp index 73eb96604..69b8cc16a 100644 --- a/libevm/ExtVMFace.cpp +++ b/libevm/ExtVMFace.cpp @@ -134,8 +134,8 @@ evmc_tx_context EvmCHost::get_tx_context() noexcept { result.block_timestamp = envInfo.timestamp(); result.block_gas_limit = static_cast< int64_t >( envInfo.gasLimit() ); if ( ParisForkPatch::isEnabledWhen( envInfo.committedBlockTimestamp() ) ) { - // EIP-4399: DIFFICULTY opcode returns prevRandao. In skaled (BFT, no beacon), prevRandao=0. - result.block_difficulty = toEvmC( u256( 0 ) ); + // This EVMC version names the 0x44 context field block_difficulty even after Paris. + result.block_difficulty = toEvmC( envInfo.prevRandao() ); } else { result.block_difficulty = toEvmC( envInfo.difficulty() ); } diff --git a/libevm/ExtVMFace.h b/libevm/ExtVMFace.h index 6e91a9c1a..6e7bef490 100644 --- a/libevm/ExtVMFace.h +++ b/libevm/ExtVMFace.h @@ -159,6 +159,7 @@ class EnvInfo { Address const& author() const { return m_headerInfo.author(); } int64_t timestamp() const { return m_headerInfo.timestamp(); } u256 const& difficulty() const { return m_headerInfo.difficulty(); } + h256 prevRandao() const { return m_headerInfo.prevRandao(); } u256 const& gasLimit() const { return m_headerInfo.gasLimit(); } LastBlockHashesFace const& lastHashes() const { return m_lastHashes; } time_t committedBlockTimestamp() const { return m_committedBlockTimestamp; } diff --git a/libevm/Instruction.cpp b/libevm/Instruction.cpp index 55675ed28..e690e9062 100644 --- a/libevm/Instruction.cpp +++ b/libevm/Instruction.cpp @@ -70,7 +70,7 @@ static const std::map c_instructionInfo = { Instruction::COINBASE, { "COINBASE", 0, 1, Tier::Base } }, { Instruction::TIMESTAMP, { "TIMESTAMP", 0, 1, Tier::Base } }, { Instruction::NUMBER, { "NUMBER", 0, 1, Tier::Base } }, - { Instruction::DIFFICULTY, { "DIFFICULTY", 0, 1, Tier::Base } }, + { Instruction::PREVRANDAO, { "PREVRANDAO", 0, 1, Tier::Base } }, { Instruction::GASLIMIT, { "GASLIMIT", 0, 1, Tier::Base } }, { Instruction::CHAINID, { "CHAINID", 0, 1, Tier::Base } }, { Instruction::SELFBALANCE, { "SELFBALANCE", 0, 1, Tier::Low } }, diff --git a/libevm/Instruction.h b/libevm/Instruction.h index ec0b82da7..11a5511e8 100644 --- a/libevm/Instruction.h +++ b/libevm/Instruction.h @@ -72,15 +72,16 @@ enum class Instruction : uint8_t { RETURNDATACOPY = 0x3e, ///< copy data returned from previous call to memory EXTCODEHASH = 0x3f, ///< get external code hash - BLOCKHASH = 0x40, ///< get hash of most recent complete block - COINBASE, ///< get the block's coinbase address - TIMESTAMP, ///< get the block's timestamp - NUMBER, ///< get the block's number - DIFFICULTY, ///< get the block's difficulty - GASLIMIT, ///< get the block's gas limit - CHAINID, ///< get the network's ChainID - SELFBALANCE, ///< get balance of the current address - BASEFEE = 0x48, ///< get the block's base fee (EIP-3198) + BLOCKHASH = 0x40, ///< get hash of most recent complete block + COINBASE, ///< get the block's coinbase address + TIMESTAMP, ///< get the block's timestamp + NUMBER, ///< get the block's number + PREVRANDAO = 0x44, ///< get the previous RANDAO value after Paris + DIFFICULTY = PREVRANDAO, ///< pre-Paris compatibility alias for opcode 0x44 + GASLIMIT = 0x45, ///< get the block's gas limit + CHAINID, ///< get the network's ChainID + SELFBALANCE, ///< get balance of the current address + BASEFEE = 0x48, ///< get the block's base fee (EIP-3198) POP = 0x50, ///< remove item from stack MLOAD, ///< load word from memory diff --git a/libevm/LegacyVM.cpp b/libevm/LegacyVM.cpp index 226daeb82..3329fac29 100644 --- a/libevm/LegacyVM.cpp +++ b/libevm/LegacyVM.cpp @@ -1351,13 +1351,12 @@ void LegacyVM::interpretCases() { } NEXT - CASE( DIFFICULTY ) { + CASE( PREVRANDAO ) { ON_OP(); updateIOGas(); - // EIP-4399: post-Paris, DIFFICULTY returns prevRandao (0 in skaled/BFT) if ( ParisForkPatch::isEnabledWhen( m_ext->envInfo().committedBlockTimestamp() ) ) - m_SPP[0] = 0; + m_SPP[0] = u256( m_ext->envInfo().prevRandao() ); else m_SPP[0] = m_ext->envInfo().difficulty(); } diff --git a/libevm/LegacyVMConfig.h b/libevm/LegacyVMConfig.h index 24c252048..4ff86a74b 100644 --- a/libevm/LegacyVMConfig.h +++ b/libevm/LegacyVMConfig.h @@ -238,7 +238,7 @@ namespace eth { &&COINBASE, \ &&TIMESTAMP, \ &&NUMBER, \ - &&DIFFICULTY, \ + &&PREVRANDAO, \ &&GASLIMIT, \ &&CHAINID, \ &&SELFBALANCE, \ diff --git a/libskale-interpreter/VM.cpp b/libskale-interpreter/VM.cpp index 6d414ff12..7c18b8f01 100644 --- a/libskale-interpreter/VM.cpp +++ b/libskale-interpreter/VM.cpp @@ -1005,7 +1005,7 @@ void VM::interpretCases() { } NEXT - CASE( DIFFICULTY ) { + CASE( PREVRANDAO ) { ON_OP(); updateIOGas(); diff --git a/libskale-interpreter/VMConfig.h b/libskale-interpreter/VMConfig.h index 763a07291..90b6439ae 100644 --- a/libskale-interpreter/VMConfig.h +++ b/libskale-interpreter/VMConfig.h @@ -221,7 +221,7 @@ namespace eth { &&COINBASE, \ &&TIMESTAMP, \ &&NUMBER, \ - &&DIFFICULTY, \ + &&PREVRANDAO, \ &&GASLIMIT, \ &&CHAINID, \ &&SELFBALANCE, \ diff --git a/test/unittests/libethcore/BlockHeaderRLPTest.cpp b/test/unittests/libethcore/BlockHeaderRLPTest.cpp index 6f29ee9cc..81e004166 100644 --- a/test/unittests/libethcore/BlockHeaderRLPTest.cpp +++ b/test/unittests/libethcore/BlockHeaderRLPTest.cpp @@ -65,6 +65,13 @@ void enableLondonAtTimestampOne() { SchainPatch::init( cp ); } +void enableLondonAndParisAtTimestampOne() { + PatchableChainParams cp; + cp.setPatchTimestamp( SchainPatchEnum::LondonForkPatch, 1 ); + cp.setPatchTimestamp( SchainPatchEnum::ParisForkPatch, 1 ); + SchainPatch::init( cp ); +} + } // namespace BOOST_FIXTURE_TEST_SUITE( BlockHeaderRLPTests, TestOutputHelperFixture ) @@ -146,6 +153,31 @@ BOOST_AUTO_TEST_CASE( londonHeaderWithSealFieldsRoundTrips ) { BOOST_REQUIRE_EQUAL( restored.hash( WithSeal ), originalHash ); } +BOOST_AUTO_TEST_CASE( parisHeaderEncodesPrevRandaoAndNonceBeforeBaseFee ) { + SchainPatchGuard guard; + enableLondonAndParisAtTimestampOne(); + + const u256 expectedBaseFee = 777; + BlockHeader original = makeHeader( 50, expectedBaseFee ); + original.setDifficulty( 0 ); + original.setPrevRandao( h256( 0 ) ); + original.setSeal( 1, Nonce( 0 ) ); + + RLPStream stream; + original.streamRLP( stream, WithSeal ); + RLP rlp( stream.out() ); + + BOOST_REQUIRE_EQUAL( rlp.itemCount(), 16u ); + BOOST_REQUIRE_EQUAL( rlp[13].toHash< h256 >( RLP::VeryStrict ), h256( 0 ) ); + BOOST_REQUIRE_EQUAL( rlp[14].toHash< Nonce >( RLP::VeryStrict ), Nonce( 0 ) ); + BOOST_REQUIRE_EQUAL( rlp[15].toInt< u256 >(), expectedBaseFee ); + + BlockHeader restored( stream.out(), HeaderData ); + BOOST_REQUIRE_EQUAL( restored.prevRandao(), h256( 0 ) ); + BOOST_REQUIRE_EQUAL( restored.seal< Nonce >( 1 ), Nonce( 0 ) ); + BOOST_REQUIRE_EQUAL( restored.baseFeePerGas(), expectedBaseFee ); +} + BOOST_AUTO_TEST_CASE( londonHeaderWithoutSealStillIncludesBaseFee ) { SchainPatchGuard guard; enableLondonAtTimestampOne(); diff --git a/test/unittests/libethereum/ParisForkTests.cpp b/test/unittests/libethereum/ParisForkTests.cpp index 1fbfebb75..0d1370152 100644 --- a/test/unittests/libethereum/ParisForkTests.cpp +++ b/test/unittests/libethereum/ParisForkTests.cpp @@ -120,8 +120,8 @@ BOOST_AUTO_TEST_CASE( parisForkNonZeroDifficultyThrowsPostParis ) { SchainPatch::useLatestBlockTimestamp( 0 ); } -// SKALE canonical post-Paris headers use no Ethash seal fields; mixHash/nonce would change hash. -BOOST_AUTO_TEST_CASE( parisForkSealFieldsThrowPostParis ) { +// EIP-4399 reuses the Ethash mixHash position for prevRandao and requires a zero nonce. +BOOST_AUTO_TEST_CASE( parisForkPrevRandaoAndNonceValidation ) { PatchableChainParams cp( genesisInfo( Network::ConstantinopleTest ) ); #ifndef FAIR cp.setPatchTimestamp( SchainPatchEnum::ParisForkPatch, 1 ); @@ -146,6 +146,13 @@ BOOST_AUTO_TEST_CASE( parisForkSealFieldsThrowPostParis ) { bi.setSeal( 0, h256( 0 ) ); bi.setSeal( 1, Nonce( 0 ) ); + BOOST_REQUIRE_NO_THROW( se->verify( QuickNonce, bi, parent, bytesConstRef{} ) ); + + bi.setPrevRandao( h256( 1 ) ); + BOOST_REQUIRE_THROW( se->verify( QuickNonce, bi, parent, bytesConstRef{} ), InvalidBlockFormat ); + + bi.setPrevRandao( h256( 0 ) ); + bi.setSeal( 1, Nonce( 1 ) ); BOOST_REQUIRE_THROW( se->verify( QuickNonce, bi, parent, bytesConstRef{} ), InvalidBlockFormat ); ChainParams resetCp( genesisInfo( Network::ConstantinopleTest ) ); @@ -153,4 +160,65 @@ BOOST_AUTO_TEST_CASE( parisForkSealFieldsThrowPostParis ) { SchainPatch::useLatestBlockTimestamp( 0 ); } +BOOST_AUTO_TEST_CASE( parisForkMissingPrevRandaoAndNonceThrows ) { + PatchableChainParams cp( genesisInfo( Network::ConstantinopleTest ) ); +#ifndef FAIR + cp.setPatchTimestamp( SchainPatchEnum::ParisForkPatch, 1 ); +#endif + SchainPatch::init( cp ); + SchainPatch::useLatestBlockTimestamp( 1 ); + std::unique_ptr< SealEngineFace > se( cp.createSealEngine() ); + + BlockHeader parent; + parent.setGasLimit( 0x7fffffffffffffff ); + parent.setGasUsed( 0 ); + parent.setDifficulty( 0 ); + parent.setTimestamp( 1 ); + + BlockHeader bi; + bi.setParentHash( parent.hash() ); + bi.setNumber( 1 ); + bi.setGasLimit( 0x7fffffffffffffff ); + bi.setGasUsed( 0 ); + bi.setDifficulty( 0 ); + bi.setTimestamp( 2 ); + + BOOST_REQUIRE_THROW( se->verify( QuickNonce, bi, parent, bytesConstRef{} ), InvalidBlockFormat ); + + ChainParams resetCp( genesisInfo( Network::ConstantinopleTest ) ); + SchainPatch::init( resetCp ); + SchainPatch::useLatestBlockTimestamp( 0 ); +} + +BOOST_AUTO_TEST_CASE( parisForkPopulateAddsPrevRandaoAndNonce ) { + PatchableChainParams cp( genesisInfo( Network::ConstantinopleTest ) ); +#ifndef FAIR + cp.setPatchTimestamp( SchainPatchEnum::ParisForkPatch, 1 ); +#endif + SchainPatch::init( cp ); + SchainPatch::useLatestBlockTimestamp( 1 ); + std::unique_ptr< SealEngineFace > se( cp.createSealEngine() ); + + BlockHeader parent; + parent.setNumber( 1 ); + parent.setGasLimit( 0x7fffffffffffffff ); + parent.setGasUsed( 0 ); + parent.setDifficulty( 0 ); + parent.setTimestamp( 1 ); + parent.hash(); + + BlockHeader bi; + se->populateFromParent( bi, parent ); + bi.setTimestamp( 2 ); + + BOOST_REQUIRE_EQUAL( bi.difficulty(), 0 ); + BOOST_REQUIRE_EQUAL( bi.sealFieldCount(), 2 ); + BOOST_REQUIRE_EQUAL( bi.prevRandao(), h256( 0 ) ); + BOOST_REQUIRE_EQUAL( bi.seal< Nonce >( 1 ), Nonce( 0 ) ); + + ChainParams resetCp( genesisInfo( Network::ConstantinopleTest ) ); + SchainPatch::init( resetCp ); + SchainPatch::useLatestBlockTimestamp( 0 ); +} + BOOST_AUTO_TEST_SUITE_END() diff --git a/test/unittests/libevm/VMTest.cpp b/test/unittests/libevm/VMTest.cpp index 1e046e458..6c652629a 100644 --- a/test/unittests/libevm/VMTest.cpp +++ b/test/unittests/libevm/VMTest.cpp @@ -1259,19 +1259,26 @@ BOOST_AUTO_TEST_CASE( Push0 ) { BOOST_REQUIRE_EQUAL( stack[0], u256() ); } +BOOST_AUTO_TEST_CASE( PrevRandaoOpcodeNameAndValue ) { + BOOST_REQUIRE_EQUAL( static_cast< unsigned >( Instruction::PREVRANDAO ), 0x44u ); + BOOST_REQUIRE_EQUAL( instructionInfo( Instruction::PREVRANDAO ).name, "PREVRANDAO" ); +} + BOOST_AUTO_TEST_SUITE_END() BOOST_FIXTURE_TEST_SUITE( LegacyVMParisSuite, LegacyVMParisTestFixture ) -// EIP-4399: DIFFICULTY opcode must return 0 (prevRandao=0 in skaled) when ParisForkPatch is active. -BOOST_AUTO_TEST_CASE( difficultyReturnsZeroAfterParisFork ) { - // Bytecode: DIFFICULTY PUSH1 0x00 MSTORE PUSH1 0x20 PUSH1 0x00 RETURN +// EIP-4399: PREVRANDAO must return the zero prevRandao stored in a valid SKALE Paris header. +BOOST_AUTO_TEST_CASE( prevRandaoReturnsZeroAfterParisFork ) { + // Bytecode: PREVRANDAO PUSH1 0x00 MSTORE PUSH1 0x20 PUSH1 0x00 RETURN bytes code = fromHex( "4460005260206000f3" ); enableParisForkPatch(); BlockHeader parisHeader = blockHeader; parisHeader.setTimestamp( 1 ); parisHeader.setDifficulty( 42 ); // non-zero to prove the opcode ignores header.difficulty + parisHeader.setPrevRandao( h256( 0 ) ); + parisHeader.setSeal( 1, Nonce( 0 ) ); EnvInfo parisEnvInfo{ parisHeader, lastBlockHashes, 1, 0, se->chainParams().getChainId() }; ExtVM extVm( state, parisEnvInfo, se->chainParams(), address, address, address, From 8ac61cb53af14bb9d7bdee6d41b970fe4d4303a5 Mon Sep 17 00:00:00 2001 From: badrogger Date: Thu, 30 Jul 2026 16:41:46 +0100 Subject: [PATCH 11/14] 1795 Use getRandomForBlockId for prevrandao --- libethashseal/Ethash.cpp | 6 ++--- libethereum/Block.cpp | 26 +++++++++++++++---- libethereum/Block.h | 9 ++++--- libethereum/Client.cpp | 10 +++---- libethereum/Client.h | 4 +-- libethereum/SchainPatch.h | 10 +++++-- libethereum/SkaleHost.cpp | 12 ++++++++- test/unittests/libethereum/ParisForkTests.cpp | 4 ++- test/unittests/libevm/VMTest.cpp | 25 ++++++++++++++++++ 9 files changed, 84 insertions(+), 22 deletions(-) diff --git a/libethashseal/Ethash.cpp b/libethashseal/Ethash.cpp index 0fba8ee03..edaebdb90 100644 --- a/libethashseal/Ethash.cpp +++ b/libethashseal/Ethash.cpp @@ -97,9 +97,9 @@ void Ethash::verify( Strictness _s, BlockHeader const& _bi, BlockHeader const& _ BOOST_THROW_EXCEPTION( InvalidBlockFormat() << errinfo_comment( "Paris block header must contain prevRandao and nonce" ) ); - if ( prevRandao( _bi ) != h256( 0 ) ) - BOOST_THROW_EXCEPTION( InvalidBlockFormat() << errinfo_comment( - "Paris block header prevRandao must be zero" ) ); + // prevRandao is derived from the previous block's threshold signature at + // construction (SkaleHost::createBlock); verify cannot re-derive it here (no + // consensus access), so only the header shape and the zero nonce are pinned. if ( nonce( _bi ) != Nonce( 0 ) ) BOOST_THROW_EXCEPTION( InvalidBlockFormat() << errinfo_comment( "Paris block header nonce must be zero" ) ); diff --git a/libethereum/Block.cpp b/libethereum/Block.cpp index 08569b697..5175e8287 100644 --- a/libethereum/Block.cpp +++ b/libethereum/Block.cpp @@ -530,7 +530,7 @@ void Block::sanityCheckPartialTransactionReceipts( std::optional< BlockNumber > tuple< TransactionReceipts, unsigned, bool > Block::syncEveryone( BlockChain const& _bc, const Transactions& _transactions, uint64_t _timestamp, u256 _gasPrice, u256 _baseFeePerGas, - OnTransactionConsumed const& _onTransactionConsumed ) { + OnTransactionConsumed const& _onTransactionConsumed, u256 _prevRandao ) { if ( isSealed() ) BOOST_THROW_EXCEPTION( InvalidOperationOnSealedBlock() ); @@ -541,7 +541,8 @@ tuple< TransactionReceipts, unsigned, bool > Block::syncEveryone( BlockChain con context.singleCommitEnabled = SingleStateCommitPerBlockPatch::isEnabledInWorkingBlock(); if ( context.singleCommitEnabled && isCurrentBlockCommitted() ) { - auto recovered = recoverFromReceipts( _transactions, _timestamp, _baseFeePerGas ); + auto recovered = + recoverFromReceipts( _transactions, _timestamp, _baseFeePerGas, _prevRandao ); bool needsQueueReadyNotification = false; Transactions queueCleanupTransactions; u256 cumulativeGas = 0; @@ -564,7 +565,7 @@ tuple< TransactionReceipts, unsigned, bool > Block::syncEveryone( BlockChain con return make_tuple( recovered.first, recovered.second, needsQueueReadyNotification ); } - prepareStateForSync( _timestamp, _baseFeePerGas, context ); + prepareStateForSync( _timestamp, _baseFeePerGas, _prevRandao, context ); executeTransactions( _bc, _transactions, _gasPrice, context, _onTransactionConsumed ); if ( !context.singleCommitEnabled || !isCurrentBlockCommitted() ) { @@ -576,7 +577,7 @@ tuple< TransactionReceipts, unsigned, bool > Block::syncEveryone( BlockChain con } std::pair< TransactionReceipts, unsigned > Block::recoverFromReceipts( - const Transactions& _transactions, uint64_t, u256 _baseFeePerGas ) { + const Transactions& _transactions, uint64_t, u256 _baseFeePerGas, u256 _prevRandao ) { if ( !SingleStateCommitPerBlockPatch::isEnabledInWorkingBlock() ) { BOOST_THROW_EXCEPTION( std::runtime_error( "recoverFromReceipts called outside single commit mode" ) ); @@ -601,6 +602,13 @@ std::pair< TransactionReceipts, unsigned > Block::recoverFromReceipts( resetCurrent( savedData->timestamp ); if ( _baseFeePerGas != 0 ) m_currentBlock.setBaseFeePerGas( _baseFeePerGas ); + // Nonzero only post-Paris (gated in SkaleHost::createBlock); recovery must rebuild the + // exact header the pre-crash execution was producing. Applied only when the seal engine + // produced the Paris 2-field seal shape (Ethash) — never invent seal fields on engines + // whose headers carry none (e.g. NoProof test chains): a single-field seal is rejected + // by BlockHeader::populate(). + if ( _prevRandao != 0 && m_currentBlock.sealFieldCount() == 2 ) + m_currentBlock.setPrevRandao( h256( _prevRandao ) ); for ( const auto& tx : _transactions ) { m_transactions.push_back( tx ); @@ -625,10 +633,18 @@ std::pair< TransactionReceipts, unsigned > Block::recoverFromReceipts( return std::make_pair( m_receipts, m_receipts.size() - badCount ); } -void Block::prepareStateForSync( uint64_t _timestamp, u256 _baseFeePerGas, SyncContext& _context ) { +void Block::prepareStateForSync( + uint64_t _timestamp, u256 _baseFeePerGas, u256 _prevRandao, SyncContext& _context ) { resetCurrent( _timestamp ); if ( _baseFeePerGas != 0 ) m_currentBlock.setBaseFeePerGas( _baseFeePerGas ); + // Nonzero only post-Paris (gated in SkaleHost::createBlock). resetCurrent() wrote the + // Paris zero seal fields via Ethash::populateFromParent; this overrides the value the + // same way baseFee is set. Applied only when that 2-field seal shape exists — never + // invent seal fields on engines whose headers carry none (e.g. NoProof test chains): + // a single-field seal is rejected by BlockHeader::populate(). + if ( _prevRandao != 0 && m_currentBlock.sealFieldCount() == 2 ) + m_currentBlock.setPrevRandao( h256( _prevRandao ) ); m_state = m_state.createStateCopyAndClearCaches(); #ifndef FAIR diff --git a/libethereum/Block.h b/libethereum/Block.h index 502157dbd..9cfd11160 100644 --- a/libethereum/Block.h +++ b/libethereum/Block.h @@ -272,7 +272,8 @@ class Block { std::tuple< TransactionReceipts, unsigned, bool > syncEveryone( BlockChain const& _bc, const Transactions& _transactions, uint64_t _timestamp, u256 _gasPrice, u256 _baseFeePerGas = 0, - OnTransactionConsumed const& _onTransactionConsumed = OnTransactionConsumed() ); + OnTransactionConsumed const& _onTransactionConsumed = OnTransactionConsumed(), + u256 _prevRandao = 0 ); /// Execute all transactions within a given block. /// @returns the additional total difficulty. @@ -365,7 +366,8 @@ class Block { /// Undo the changes to the state for committing to mine. void uncommitToSeal(); - void prepareStateForSync( uint64_t _timestamp, u256 _baseFeePerGas, SyncContext& _context ); + void prepareStateForSync( + uint64_t _timestamp, u256 _baseFeePerGas, u256 _prevRandao, SyncContext& _context ); void executeTransactions( BlockChain const& _bc, const Transactions& _transactions, u256 _gasPrice, SyncContext& _context, OnTransactionConsumed const& _onTransactionConsumed ); @@ -377,7 +379,8 @@ class Block { // Loads saved receipts from progress log to skip re-execution after crash. // Throws if called outside single commit mode or if receipts are unavailable. std::pair< TransactionReceipts, unsigned > recoverFromReceipts( - const Transactions& _transactions, uint64_t _timestamp, u256 _baseFeePerGas ); + const Transactions& _transactions, uint64_t _timestamp, u256 _baseFeePerGas, + u256 _prevRandao ); void saveStateChanges( BlockChain const& _bc, const Transactions& _transactions, const SyncContext& _context ); void runCommit( BlockChain const& _bc, const SyncContext& _context ); // run commit for state diff --git a/libethereum/Client.cpp b/libethereum/Client.cpp index f1114354d..e1aa88557 100644 --- a/libethereum/Client.cpp +++ b/libethereum/Client.cpp @@ -561,7 +561,7 @@ size_t Client::importTransactionsAsBlock( const Transactions& _transactions, uint64_t _winningNodeIndex, #endif uint64_t _timestamp, Block::OnTransactionConsumed const& _onTransactionConsumed, - bool* _needsQueueReadyNotification ) { + bool* _needsQueueReadyNotification, u256 _prevRandao ) { // on schain creation, SnapshotAgent needs timestamp of block 1 // so we use this HACK // pass block number 0 as for bigger BN it is initialized in init() @@ -591,7 +591,7 @@ size_t Client::importTransactionsAsBlock( const Transactions& _transactions, size_t cntSucceeded = 0; cntSucceeded = syncTransactions( _transactions, _gasPrice, _timestamp, _onTransactionConsumed, - _needsQueueReadyNotification ); + _needsQueueReadyNotification, _prevRandao ); sealUnconditionally( false ); importWorkingBlock(); @@ -667,7 +667,7 @@ bool Client::updateGroupIfNeeded() { size_t Client::syncTransactions( const Transactions& _transactions, u256 _gasPrice, uint64_t _timestamp, Block::OnTransactionConsumed const& _onTransactionConsumed, - bool* _needsQueueReadyNotification ) { + bool* _needsQueueReadyNotification, u256 _prevRandao ) { assert( m_skaleHost ); while ( m_working.isSealed() ) { @@ -702,8 +702,8 @@ size_t Client::syncTransactions( const Transactions& _transactions, u256 _gasPri } tie( newPendingReceipts, goodReceipts, needsQueueReadyNotification ) = - m_working.syncEveryone( - bc(), _transactions, _timestamp, _gasPrice, baseFeePerGas, _onTransactionConsumed ); + m_working.syncEveryone( bc(), _transactions, _timestamp, _gasPrice, baseFeePerGas, + _onTransactionConsumed, _prevRandao ); m_state = m_state.createStateCopyAndClearCaches(); #ifdef HISTORIC_STATE // make sure the trie in new state object points to the new state root diff --git a/libethereum/Client.h b/libethereum/Client.h index 6bf4f0225..f72801d5a 100644 --- a/libethereum/Client.h +++ b/libethereum/Client.h @@ -309,7 +309,7 @@ class Client : public ClientBase, protected Worker { #endif uint64_t _timestamp = ( uint64_t ) utcTime(), Block::OnTransactionConsumed const& _onTransactionConsumed = Block::OnTransactionConsumed(), - bool* _needsQueueReadyNotification = nullptr ); + bool* _needsQueueReadyNotification = nullptr, u256 _prevRandao = 0 ); boost::filesystem::path createSnapshotFile( unsigned _blockNumber ) { return m_snapshotAgent->createSnapshotFile( _blockNumber ); @@ -410,7 +410,7 @@ class Client : public ClientBase, protected Worker { size_t syncTransactions( const Transactions& _transactions, u256 _gasPrice, uint64_t _timestamp = ( uint64_t ) utcTime(), Block::OnTransactionConsumed const& _onTransactionConsumed = Block::OnTransactionConsumed(), - bool* _needsQueueReadyNotification = nullptr ); + bool* _needsQueueReadyNotification = nullptr, u256 _prevRandao = 0 ); /// As rejigSealing - but stub /// thread unsafe!! diff --git a/libethereum/SchainPatch.h b/libethereum/SchainPatch.h index 7636969b3..d5259bf05 100644 --- a/libethereum/SchainPatch.h +++ b/libethereum/SchainPatch.h @@ -214,8 +214,14 @@ DEFINE_SIMPLE_PATCH( SingleStateCommitPerBlockPatch ); DEFINE_SIMPLE_PATCH( ContractCreationReadOnlyPatch ); /* - * Paris fork (EIP-3675 + EIP-4399): difficulty=0, no uncles, - * PREVRANDAO opcode returns the zero prevRandao stored in the block header. + * Paris fork (EIP-3675 + EIP-4399): difficulty=0, no uncles, and the header carries + * prevRandao = BLAKE3(thresholdSig(N-1)) — the previous block's consensus threshold + * signature hashed, same derivation as the getBlockRandom precompiled. The value is + * derived once at block construction (SkaleHost::createBlock) and only ever read from + * the header afterwards (PREVRANDAO opcode, replay, historic queries). Genesis and + * block 1 keep zero. Ethash::verify pins the header shape (2 seal fields, nonce=0) + * but cannot re-derive the value; changing the derivation after this patch has + * shipped requires a NEW patch. */ DEFINE_SIMPLE_PATCH( ParisForkPatch ); diff --git a/libethereum/SkaleHost.cpp b/libethereum/SkaleHost.cpp index d9913abdf..0fdcaf165 100644 --- a/libethereum/SkaleHost.cpp +++ b/libethereum/SkaleHost.cpp @@ -685,6 +685,16 @@ void SkaleHost::createBlock( const ConsensusExtFace::Transactions& _approvedTran BlockHeader latestInfo = static_cast< const Interface& >( m_client ).blockInfo( LatestBlock ); + // EIP-4399: derive the new block's prevRandao from the previous block's threshold + // signature. This is the only place the value ever crosses from consensus to the EVM; + // execution and replay read it from the header. The previous block was committed to the + // consensus BlockDB one block ago, so the lookup cannot hit DB rotation. Any failure here + // must abort the import (fail closed) — a fallback value would fork replay from history. + u256 prevRandao = 0; + if ( _blockID > 1 && ParisForkPatch::isEnabledWhen( latestInfo.timestamp() ) ) { + prevRandao = m_consensus->getRandomForBlockId( _blockID - 1 ); + } + // Keep this outside m_blockImportMutex to avoid lock-order cycles with // chain reads performed by random resolution. #ifdef BITE @@ -740,7 +750,7 @@ void SkaleHost::createBlock( const ConsensusExtFace::Transactions& _approvedTran #ifdef FAIR _winningNodeIndex, #endif - _timeStamp, onTransactionConsumed, &needsQueueReadyNotification ); + _timeStamp, onTransactionConsumed, &needsQueueReadyNotification, prevRandao ); } // m_blockImportMutex if ( needsQueueReadyNotification ) diff --git a/test/unittests/libethereum/ParisForkTests.cpp b/test/unittests/libethereum/ParisForkTests.cpp index 0d1370152..f08930f48 100644 --- a/test/unittests/libethereum/ParisForkTests.cpp +++ b/test/unittests/libethereum/ParisForkTests.cpp @@ -148,8 +148,10 @@ BOOST_AUTO_TEST_CASE( parisForkPrevRandaoAndNonceValidation ) { BOOST_REQUIRE_NO_THROW( se->verify( QuickNonce, bi, parent, bytesConstRef{} ) ); + // the prevRandao value is consensus-derived at construction and not re-checked here, + // so a nonzero value is valid; only the shape and the zero nonce are pinned bi.setPrevRandao( h256( 1 ) ); - BOOST_REQUIRE_THROW( se->verify( QuickNonce, bi, parent, bytesConstRef{} ), InvalidBlockFormat ); + BOOST_REQUIRE_NO_THROW( se->verify( QuickNonce, bi, parent, bytesConstRef{} ) ); bi.setPrevRandao( h256( 0 ) ); bi.setSeal( 1, Nonce( 1 ) ); diff --git a/test/unittests/libevm/VMTest.cpp b/test/unittests/libevm/VMTest.cpp index 6c652629a..b5f507b70 100644 --- a/test/unittests/libevm/VMTest.cpp +++ b/test/unittests/libevm/VMTest.cpp @@ -1292,6 +1292,31 @@ BOOST_AUTO_TEST_CASE( prevRandaoReturnsZeroAfterParisFork ) { resetSchainPatchToDefault(); } +// The opcode returns whatever prevRandao the header carries, so the consensus-derived +// nonzero value written at block construction flows through with no further EVM changes. +BOOST_AUTO_TEST_CASE( prevRandaoReturnsHeaderValueWhenSet ) { + // Bytecode: PREVRANDAO PUSH1 0x00 MSTORE PUSH1 0x20 PUSH1 0x00 RETURN + bytes code = fromHex( "4460005260206000f3" ); + + enableParisForkPatch(); + BlockHeader parisHeader = blockHeader; + parisHeader.setTimestamp( 1 ); + parisHeader.setDifficulty( 0 ); + parisHeader.setPrevRandao( h256( 42 ) ); + parisHeader.setSeal( 1, Nonce( 0 ) ); + EnvInfo parisEnvInfo{ parisHeader, lastBlockHashes, 1, 0, se->chainParams().getChainId() }; + + ExtVM extVm( state, parisEnvInfo, se->chainParams(), address, address, address, + value, gasPrice, ref( inputData ), ref( code ), sha3( code ), version, depth, + isCreate, staticCall ); + + owning_bytes_ref ret = vm->exec( gas, extVm, OnOpFunc{} ); + BOOST_REQUIRE_EQUAL( ret.size(), 32 ); + BOOST_REQUIRE_EQUAL( fromBigEndian< u256 >( ret.toVector() ), 42 ); + + resetSchainPatchToDefault(); +} + // Pre-Paris: DIFFICULTY opcode must return the actual block difficulty. BOOST_AUTO_TEST_CASE( difficultyOpcodeUnchangedBeforeParisFork ) { // Same bytecode, patch NOT enabled From 11430285d4daeab4045a9e7d6f21f4a462997da0 Mon Sep 17 00:00:00 2001 From: badrogger Date: Thu, 30 Jul 2026 16:43:11 +0100 Subject: [PATCH 12/14] 1795 Paris coverage in api-tests, fix execution-specs nonce race --- .github/actions/api-tests-run/action.yml | 21 ++++---- .../hardfork-compat/test_hardfork_compat.py | 49 +++++++++++++++++++ .../configs/config-template.json.j2 | 1 + .../hardfork-support/hardfork-support.toml | 6 ++- ...ution-specs-worker-nonce-resync-wait.patch | 37 ++++++++++++++ .../sol/contracts/eips/EIP4399Test.sol | 3 +- .../hardfork-support/subroutine/eip_tests.py | 45 ++++++++++++++--- 7 files changed, 142 insertions(+), 20 deletions(-) create mode 100644 test/api-tests/hardfork-support/patches/execution-specs-worker-nonce-resync-wait.patch diff --git a/.github/actions/api-tests-run/action.yml b/.github/actions/api-tests-run/action.yml index 5eefc18d4..b008ac1d9 100644 --- a/.github/actions/api-tests-run/action.yml +++ b/.github/actions/api-tests-run/action.yml @@ -40,15 +40,18 @@ runs: shell: bash run: | set -euo pipefail - patch_path="../hardfork-support/patches/execution-specs-berlin-nonce-overflow-stubs.patch" - if git -C test/api-tests/execution-specs apply --check "${patch_path}"; then - git -C test/api-tests/execution-specs apply "${patch_path}" - elif git -C test/api-tests/execution-specs apply --reverse --check "${patch_path}"; then - echo "execution-specs patch already applied: ${patch_path}" - else - echo "::error::execution-specs patch does not apply: ${patch_path}" - git -C test/api-tests/execution-specs apply --check "${patch_path}" - fi + for patch_path in test/api-tests/hardfork-support/patches/*.patch; do + rel_path="../hardfork-support/patches/$(basename "${patch_path}")" + if git -C test/api-tests/execution-specs apply --check "${rel_path}"; then + git -C test/api-tests/execution-specs apply "${rel_path}" + echo "execution-specs patch applied: ${rel_path}" + elif git -C test/api-tests/execution-specs apply --reverse --check "${rel_path}"; then + echo "execution-specs patch already applied: ${rel_path}" + else + echo "::error::execution-specs patch does not apply: ${rel_path}" + git -C test/api-tests/execution-specs apply --check "${rel_path}" + fi + done - name: Render run.toml from template shell: bash diff --git a/test/api-tests/hardfork-compat/test_hardfork_compat.py b/test/api-tests/hardfork-compat/test_hardfork_compat.py index 1ed632858..0acf62476 100644 --- a/test/api-tests/hardfork-compat/test_hardfork_compat.py +++ b/test/api-tests/hardfork-compat/test_hardfork_compat.py @@ -254,6 +254,7 @@ def _assert_receipts_before_timestamp(w3: Web3, receipts: list, timestamp: int, def _assert_receipts_after_paris(w3: Web3, receipts: list, timestamp: int, label: str) -> None: + mix_hash_by_block = {} for receipt in receipts: block = w3.eth.get_block(receipt["blockNumber"]) assert int(block["timestamp"]) >= timestamp, ( @@ -263,6 +264,45 @@ def _assert_receipts_after_paris(w3: Web3, receipts: list, timestamp: int, label assert int(block["difficulty"]) == 0, ( f"Post-Paris block {block['number']} has non-zero difficulty {block['difficulty']}" ) + # Post-Paris skaled headers carry prevRandao (in the mixHash position) derived + # from the previous block's BLS threshold signature — non-zero and distinct + # per block for every block after block 1. + mix_hash = int.from_bytes(bytes(block["mixHash"]), "big") + assert mix_hash != 0, ( + f"Post-Paris block {block['number']} has zero prevRandao/mixHash" + ) + mix_hash_by_block[int(block["number"])] = mix_hash + distinct_values = set(mix_hash_by_block.values()) + assert len(distinct_values) == len(mix_hash_by_block), ( + f"{label}: prevRandao values repeat across post-Paris blocks: {mix_hash_by_block}" + ) + + +def _assert_prevrandao_recorder( + w3: Web3, recorder_receipt, paris_active: bool, label: str +) -> None: + """Check the value the PREVRANDAO recorder captured into slot 0 at deploy time. + + Pre-Paris, opcode 0x44 is DIFFICULTY and must equal the deploy block's difficulty. + Post-Paris on skaled it is the beacon-derived prevRandao, which must be non-zero + and equal to the deploy block's header mixHash (the EIP-4399 invariant). + """ + addr = recorder_receipt["contractAddress"] + assert addr, f"{label}: PREVRANDAO recorder receipt has no contract address" + recorded = int.from_bytes(bytes(w3.eth.get_storage_at(addr, 0)), "big") + block = w3.eth.get_block(recorder_receipt["blockNumber"]) + if paris_active: + mix_hash = int.from_bytes(bytes(block["mixHash"]), "big") + assert recorded != 0, f"{label}: post-Paris PREVRANDAO recorded as zero" + assert recorded == mix_hash, ( + f"{label}: recorded PREVRANDAO {hex(recorded)} != header mixHash " + f"{hex(mix_hash)} at block {block['number']}" + ) + else: + assert recorded == int(block["difficulty"]), ( + f"{label}: recorded DIFFICULTY {recorded} != block difficulty " + f"{block['difficulty']} at block {block['number']}" + ) def _run_paris_workload_phase( @@ -347,6 +387,9 @@ def test_london_pre_upgrade_workload( deploy_token=True, ) assert receipts, "No pre-upgrade receipts produced" + _assert_prevrandao_recorder( + w3_primary, receipts[-1], paris_active=False, label="london-pre-upgrade" + ) def test_upgrade_primary_to_current_with_paris_timestamp( @@ -399,6 +442,9 @@ def test_current_pre_paris_workload( w3_primary, private_key, timeouts, workload_state, "current-pre-paris", ) _assert_receipts_before_timestamp(w3_primary, receipts, activation, "current-pre-paris") + _assert_prevrandao_recorder( + w3_primary, receipts[-1], paris_active=False, label="current-pre-paris" + ) def test_wait_for_paris_activation(w3_primary: Web3, timeouts: dict, workload_state: dict): @@ -422,6 +468,9 @@ def test_current_post_paris_workload( w3_primary, private_key, timeouts, workload_state, "current-post-paris", ) _assert_receipts_after_paris(w3_primary, receipts, activation, "current-post-paris") + _assert_prevrandao_recorder( + w3_primary, receipts[-1], paris_active=True, label="current-post-paris" + ) def test_sync_catchup_and_state_root_comparison( diff --git a/test/api-tests/hardfork-support/configs/config-template.json.j2 b/test/api-tests/hardfork-support/configs/config-template.json.j2 index 01e3d6408..0a92e2fce 100644 --- a/test/api-tests/hardfork-support/configs/config-template.json.j2 +++ b/test/api-tests/hardfork-support/configs/config-template.json.j2 @@ -895,6 +895,7 @@ "snapshotDownloadInactiveTimeout": 120, "berlinForkPatchTimestamp": 1, "londonForkPatchTimestamp": 1, + "parisForkPatchTimestamp": 1, "singleStateCommitPerBlockPatchTimestamp": 1 } } diff --git a/test/api-tests/hardfork-support/hardfork-support.toml b/test/api-tests/hardfork-support/hardfork-support.toml index afc170236..9f9cceb8f 100644 --- a/test/api-tests/hardfork-support/hardfork-support.toml +++ b/test/api-tests/hardfork-support/hardfork-support.toml @@ -44,6 +44,7 @@ berlinForkPatchTimestamp = 1 pushZeroPatchTimestamp = 1 EIP1559TransactionsPatchTimestamp = 1 londonForkPatchTimestamp = 1 +parisForkPatchTimestamp = 1 singleStateCommitPerBlockPatchTimestamp = 1 flexibleDeploymentPatchTimestamp = 1 @@ -52,7 +53,8 @@ flexibleDeploymentPatchTimestamp = 1 # -------------------------------------------------------------------------- [anvil] image = "ghcr.io/foundry-rs/foundry:rc-3" -extra_args = ["--hardfork", "london"] +# "paris" is the post-merge hardfork name; older foundry builds call it "merge". +extra_args = ["--hardfork", "paris"] # -------------------------------------------------------------------------- # Contract deployment (Step 2) @@ -68,7 +70,7 @@ command = "bun hardhat run scripts/deploy_eip_tests.ts --network custom" # Number of iterations (0 = infinite loop until Ctrl-C). iterations = 1 -# EIPs to test (empty = all: Berlin + London suite EIPs). +# EIPs to test (empty = all: Berlin + London + Paris suite EIPs). eips = [] # Gas limit for deployment and test transactions. diff --git a/test/api-tests/hardfork-support/patches/execution-specs-worker-nonce-resync-wait.patch b/test/api-tests/hardfork-support/patches/execution-specs-worker-nonce-resync-wait.patch new file mode 100644 index 000000000..5721c76ae --- /dev/null +++ b/test/api-tests/hardfork-support/patches/execution-specs-worker-nonce-resync-wait.patch @@ -0,0 +1,37 @@ +diff --git a/packages/testing/src/execution_testing/cli/pytest_commands/plugins/execute/sender.py b/packages/testing/src/execution_testing/cli/pytest_commands/plugins/execute/sender.py +index 40db82a1e..d8fa96696 100644 +--- a/packages/testing/src/execution_testing/cli/pytest_commands/plugins/execute/sender.py ++++ b/packages/testing/src/execution_testing/cli/pytest_commands/plugins/execute/sender.py +@@ -412,7 +412,9 @@ def sync_worker_key_nonce(eth_rpc: EthRPC, session_worker_key: EOA) -> Account: + + Fetch the account state and update the local nonce if it differs + from the RPC nonce. This handles both nonce increases (normal +- progression) and decreases (chain reverts). ++ progression) and decreases (chain reverts). A lower RPC nonce is ++ accepted only after a grace period, because it usually just means ++ our transactions are still pending. + + Return the fetched account for further use. + """ +@@ -426,6 +428,21 @@ def sync_worker_key_nonce(eth_rpc: EthRPC, session_worker_key: EOA) -> Account: + session_worker_key, block_number="pending", skip_code=True + ) + rpc_nonce = Number(session_worker_account.nonce) ++ if rpc_nonce < session_worker_key.nonce: ++ # A lower on-chain nonce usually means our transactions are still ++ # pending. Some clients (skaled) report only the executed nonce ++ # and reject same-nonce replacements, so resyncing down right away ++ # collides with the in-flight transactions. Wait for them to land ++ # and accept the lower value only if it persists (drop/revert). ++ deadline = time.time() + 15 ++ while time.time() < deadline: ++ time.sleep(1) ++ session_worker_account = eth_rpc.get_account( ++ session_worker_key, block_number="latest", skip_code=True ++ ) ++ rpc_nonce = Number(session_worker_account.nonce) ++ if rpc_nonce >= session_worker_key.nonce: ++ break + if rpc_nonce != session_worker_key.nonce: + wk_nonce = session_worker_key.nonce + logger.info(f"Worker key nonce mismatch: {wk_nonce} != {rpc_nonce}") diff --git a/test/api-tests/hardfork-support/sol/contracts/eips/EIP4399Test.sol b/test/api-tests/hardfork-support/sol/contracts/eips/EIP4399Test.sol index 028826a07..fd4896322 100644 --- a/test/api-tests/hardfork-support/sol/contracts/eips/EIP4399Test.sol +++ b/test/api-tests/hardfork-support/sol/contracts/eips/EIP4399Test.sol @@ -3,7 +3,8 @@ pragma solidity ^0.8.20; // EIP-4399: PREVRANDAO opcode. // Post-Paris, opcode 0x44 (formerly DIFFICULTY) returns the beacon RANDAO mix. -// In skaled (BFT, no beacon chain), prevRandao is hardcoded to 0. +// In skaled, prevRandao = BLAKE3 of the previous block's BLS threshold signature, +// stored in the block header — non-zero for every block after block 1. contract EIP4399Test { function getPrevRandao() external view returns (uint256) { return block.prevrandao; diff --git a/test/api-tests/hardfork-support/subroutine/eip_tests.py b/test/api-tests/hardfork-support/subroutine/eip_tests.py index cc2924d5e..d5fb50aec 100644 --- a/test/api-tests/hardfork-support/subroutine/eip_tests.py +++ b/test/api-tests/hardfork-support/subroutine/eip_tests.py @@ -2116,11 +2116,13 @@ def _compute_block_hash(block) -> dict: Returns a dict mapping variant name -> bytes so the caller can determine which variant the node uses. Known variants: - "skale" — SKALE non-genesis blocks: no seal fields, baseFeePerGas + "skale" — SKALE pre-Paris blocks: no seal fields, baseFeePerGas directly after extraData. mixHash/nonce are NOT part of the hash even though jsInfo() exposes zero defaults via JSON-RPC. - "london" — Ethereum London spec (Anvil/geth): extraData + mixHash + - nonce + baseFeePerGas. + Post-Paris SKALE blocks carry prevRandao (in the mixHash + position) + nonce and therefore match "london" instead. + "london" — Ethereum London spec (Anvil/geth, SKALE post-Paris): + extraData + mixHash + nonce + baseFeePerGas. "shanghai" — EIP-4895: London fields + withdrawalsRoot. "cancun" — EIP-4844: Shanghai fields + blobGasUsed + excessBlobGas + parentBeaconBlockRoot. @@ -2313,7 +2315,9 @@ def test_eip_4399( ) -> EIPTestResult: """EIP-4399: PREVRANDAO opcode is accessible post-Paris. - skaled (BFT, no beacon chain): returns 0. + skaled: prevRandao = BLAKE3 of the previous block's BLS threshold signature, + carried in the header mixHash — non-zero after block 1, and the opcode result + must equal the header field (the EIP-4399 invariant). Anvil: returns a non-zero simulated value — any non-zero value is accepted. """ logger.info("=== EIP-4399 PREVRANDAO opcode test ===") @@ -2344,18 +2348,43 @@ def test_eip_4399( details=details, ) - # skaled: BFT consensus, no beacon chain — PREVRANDAO is always 0. - if prevrandao != 0: + # skaled: prevRandao is derived from the previous block's BLS threshold signature + # and stored in the header mixHash — non-zero for any block after block 1. + latest = w3.eth.get_block("latest") + mix_hash = int.from_bytes(bytes(latest["mixHash"]), "big") + # Bracket the invariant-check call between two height reads so we know which + # block context it executed in. + prevrandao_at_latest = _as_int(contract.functions.getPrevRandao().call()) + chain_quiet = w3.eth.block_number == latest["number"] + details["latest_block"] = latest["number"] + details["mix_hash"] = hex(mix_hash) + + if prevrandao == 0: + return EIPTestResult( + eip="4399", + passed=False, + message="Expected non-zero beacon-derived PREVRANDAO on skaled, got 0", + details=details, + ) + # EIP-4399 invariant: the opcode returns the executing block header's mixHash. + # Only checkable when no new block arrived around the bracketed call. + if chain_quiet and prevrandao_at_latest != mix_hash: return EIPTestResult( eip="4399", passed=False, - message=f"Expected PREVRANDAO=0 (no beacon RANDAO in skaled), got {prevrandao}", + message=( + f"PREVRANDAO {hex(prevrandao_at_latest)} != header mixHash {hex(mix_hash)} " + f"at block {latest['number']}" + ), details=details, ) return EIPTestResult( eip="4399", passed=True, - message="PREVRANDAO opcode returned 0 (correct for skaled post-Paris)", + message=( + f"PREVRANDAO returned non-zero beacon value {hex(prevrandao)[:14]}… " + f"(matches header mixHash: {chain_quiet})" + ), details=details, ) From 3e83bcf3e8b64e5541b9f3aa597469c1652ecc54 Mon Sep 17 00:00:00 2001 From: badrogger Date: Thu, 30 Jul 2026 17:17:53 +0100 Subject: [PATCH 13/14] 1795 Fix eth_call issue --- libethereum/Block.cpp | 20 +++--- libethereum/Block.h | 6 ++ libethereum/Client.cpp | 5 ++ libethereum/SkaleHost.cpp | 17 +++++ libethereum/SkaleHost.h | 4 ++ ...ution-specs-worker-nonce-resync-wait.patch | 49 +++++++++++++- .../hardfork-support/subroutine/eip_tests.py | 66 +++++++++++++------ test/api-tests/hardfork-support/suite.py | 50 ++++++++++++++ 8 files changed, 184 insertions(+), 33 deletions(-) diff --git a/libethereum/Block.cpp b/libethereum/Block.cpp index 5175e8287..26d525ae0 100644 --- a/libethereum/Block.cpp +++ b/libethereum/Block.cpp @@ -603,12 +603,8 @@ std::pair< TransactionReceipts, unsigned > Block::recoverFromReceipts( if ( _baseFeePerGas != 0 ) m_currentBlock.setBaseFeePerGas( _baseFeePerGas ); // Nonzero only post-Paris (gated in SkaleHost::createBlock); recovery must rebuild the - // exact header the pre-crash execution was producing. Applied only when the seal engine - // produced the Paris 2-field seal shape (Ethash) — never invent seal fields on engines - // whose headers carry none (e.g. NoProof test chains): a single-field seal is rejected - // by BlockHeader::populate(). - if ( _prevRandao != 0 && m_currentBlock.sealFieldCount() == 2 ) - m_currentBlock.setPrevRandao( h256( _prevRandao ) ); + // exact header the pre-crash execution was producing. + applyPrevRandao( _prevRandao ); for ( const auto& tx : _transactions ) { m_transactions.push_back( tx ); @@ -633,6 +629,11 @@ std::pair< TransactionReceipts, unsigned > Block::recoverFromReceipts( return std::make_pair( m_receipts, m_receipts.size() - badCount ); } +void Block::applyPrevRandao( u256 _prevRandao ) { + if ( _prevRandao != 0 && m_currentBlock.sealFieldCount() == 2 ) + m_currentBlock.setPrevRandao( h256( _prevRandao ) ); +} + void Block::prepareStateForSync( uint64_t _timestamp, u256 _baseFeePerGas, u256 _prevRandao, SyncContext& _context ) { resetCurrent( _timestamp ); @@ -640,11 +641,8 @@ void Block::prepareStateForSync( m_currentBlock.setBaseFeePerGas( _baseFeePerGas ); // Nonzero only post-Paris (gated in SkaleHost::createBlock). resetCurrent() wrote the // Paris zero seal fields via Ethash::populateFromParent; this overrides the value the - // same way baseFee is set. Applied only when that 2-field seal shape exists — never - // invent seal fields on engines whose headers carry none (e.g. NoProof test chains): - // a single-field seal is rejected by BlockHeader::populate(). - if ( _prevRandao != 0 && m_currentBlock.sealFieldCount() == 2 ) - m_currentBlock.setPrevRandao( h256( _prevRandao ) ); + // same way baseFee is set. + applyPrevRandao( _prevRandao ); m_state = m_state.createStateCopyAndClearCaches(); #ifndef FAIR diff --git a/libethereum/Block.h b/libethereum/Block.h index 9cfd11160..3dec3adba 100644 --- a/libethereum/Block.h +++ b/libethereum/Block.h @@ -275,6 +275,12 @@ class Block { OnTransactionConsumed const& _onTransactionConsumed = OnTransactionConsumed(), u256 _prevRandao = 0 ); + /// Write prevRandao into the current (working) header. Applied only when the seal + /// engine produced the Paris 2-field seal shape — never invents seal fields on + /// engines (e.g. NoProof test chains) whose headers carry none: a single-field + /// seal is rejected by BlockHeader::populate(). + void applyPrevRandao( u256 _prevRandao ); + /// Execute all transactions within a given block. /// @returns the additional total difficulty. u256 enactOn( VerifiedBlockRef const& _block, BlockChain const& _bc ); diff --git a/libethereum/Client.cpp b/libethereum/Client.cpp index e1aa88557..0c318ba7a 100644 --- a/libethereum/Client.cpp +++ b/libethereum/Client.cpp @@ -779,6 +779,11 @@ void Client::restartMining() { preChanged = newPreMine.sync( bc(), m_state ); if ( preChanged || m_postSeal.author() != m_preSeal.author() ) { + // Pending simulations (eth_call/eth_estimateGas execute against the working + // block) must see the same prevRandao a real next block would carry, not the + // zero placeholder populateFromParent writes. + if ( m_skaleHost ) + newPreMine.applyPrevRandao( m_skaleHost->getPrevRandaoForPendingBlock() ); DEV_WRITE_GUARDED( x_preSeal ) m_preSeal = newPreMine; DEV_WRITE_GUARDED( x_working ) diff --git a/libethereum/SkaleHost.cpp b/libethereum/SkaleHost.cpp index 0fdcaf165..b40de3598 100644 --- a/libethereum/SkaleHost.cpp +++ b/libethereum/SkaleHost.cpp @@ -1225,6 +1225,23 @@ u256 SkaleHost::getBlockRandom( unsigned _blockNumber, bool _isCalledFromTxn ) c return m_consensus->getRandomForBlockId( blockNumber ); } +u256 SkaleHost::getPrevRandaoForPendingBlock() const noexcept { + try { + auto latestNumber = m_client.number(); + if ( latestNumber == 0 || !m_consensus ) + return 0; + BlockHeader latestInfo = + static_cast< const Interface& >( m_client ).blockInfo( LatestBlock ); + if ( !ParisForkPatch::isEnabledWhen( latestInfo.timestamp() ) ) + return 0; + return m_consensus->getRandomForBlockId( latestNumber ); + } catch ( ... ) { + // Pending-simulation value only; degrading to zero never affects consensus. + cwarn << "Could not fetch prevRandao for the pending block; simulations will see 0"; + return 0; + } +} + #ifdef BITE u256 SkaleHost::getReencryptionBlockRandom( unsigned _blockNumber, bool _isCalledFromTxn ) const { auto blockNumber = resolveRandomBlockNumber( _blockNumber, _isCalledFromTxn ); diff --git a/libethereum/SkaleHost.h b/libethereum/SkaleHost.h index 47f53c54b..475f0f05d 100644 --- a/libethereum/SkaleHost.h +++ b/libethereum/SkaleHost.h @@ -155,6 +155,10 @@ class SkaleHost { dev::u256 getGasPrice( unsigned _blockNumber = dev::eth::LatestBlock ) const; dev::u256 getBlockRandom( unsigned _blockNumber, bool _isCalledFromTxn ) const; + // prevRandao the next (pending) block will carry: the just-committed block's + // consensus random. Returns 0 when unavailable (pre-Paris, genesis, consensus + // not running) — never throws; used only for read-only pending simulations. + dev::u256 getPrevRandaoForPendingBlock() const noexcept; dev::eth::SyncStatus syncStatus() const; std::map< std::string, uint64_t > getConsensusDbUsage() const; bool ignoreNewBlocksEnabled() const; diff --git a/test/api-tests/hardfork-support/patches/execution-specs-worker-nonce-resync-wait.patch b/test/api-tests/hardfork-support/patches/execution-specs-worker-nonce-resync-wait.patch index 5721c76ae..6345a3a51 100644 --- a/test/api-tests/hardfork-support/patches/execution-specs-worker-nonce-resync-wait.patch +++ b/test/api-tests/hardfork-support/patches/execution-specs-worker-nonce-resync-wait.patch @@ -1,5 +1,48 @@ +diff --git a/packages/testing/src/execution_testing/cli/pytest_commands/plugins/execute/rpc/remote_seed_sender.py b/packages/testing/src/execution_testing/cli/pytest_commands/plugins/execute/rpc/remote_seed_sender.py +index fa91b082a..0ca0f5c32 100644 +--- a/packages/testing/src/execution_testing/cli/pytest_commands/plugins/execute/rpc/remote_seed_sender.py ++++ b/packages/testing/src/execution_testing/cli/pytest_commands/plugins/execute/rpc/remote_seed_sender.py +@@ -1,6 +1,7 @@ + """Seed sender on a remote execution client.""" + + import os ++import time + from typing import Generator + + import pytest +@@ -57,6 +58,30 @@ def seed_key( + seed_account = eth_rpc.get_account(seed_key, skip_code=True) + seed_key.nonce = Number(seed_account.nonce) + ++ # A previous process using the same key may have left transactions ++ # pending. Some clients (skaled) report only the executed nonce and ++ # reject same-nonce replacements, so starting from a stale value ++ # collides with them. Wait until the nonce stays unchanged for a full ++ # block interval before trusting it. ++ stable_since = time.monotonic() ++ drain_deadline = time.monotonic() + 30 ++ last_nonce = seed_key.nonce ++ while time.monotonic() < drain_deadline: ++ if time.monotonic() - stable_since >= 12: ++ break ++ time.sleep(1) ++ current = Number( ++ eth_rpc.get_account(seed_key, skip_code=True).nonce ++ ) ++ if current != last_nonce: ++ logger.info( ++ f"Seed nonce advanced {last_nonce} -> {current}; " ++ "waiting for pending transactions to drain" ++ ) ++ last_nonce = current ++ stable_since = time.monotonic() ++ seed_key.nonce = last_nonce ++ + # Record the start balance of the worker key + start_balance = seed_account.balance + diff --git a/packages/testing/src/execution_testing/cli/pytest_commands/plugins/execute/sender.py b/packages/testing/src/execution_testing/cli/pytest_commands/plugins/execute/sender.py -index 40db82a1e..d8fa96696 100644 +index 40db82a1e..bd8abb9bd 100644 --- a/packages/testing/src/execution_testing/cli/pytest_commands/plugins/execute/sender.py +++ b/packages/testing/src/execution_testing/cli/pytest_commands/plugins/execute/sender.py @@ -412,7 +412,9 @@ def sync_worker_key_nonce(eth_rpc: EthRPC, session_worker_key: EOA) -> Account: @@ -23,8 +66,8 @@ index 40db82a1e..d8fa96696 100644 + # and reject same-nonce replacements, so resyncing down right away + # collides with the in-flight transactions. Wait for them to land + # and accept the lower value only if it persists (drop/revert). -+ deadline = time.time() + 15 -+ while time.time() < deadline: ++ deadline = time.monotonic() + 30 ++ while time.monotonic() < deadline: + time.sleep(1) + session_worker_account = eth_rpc.get_account( + session_worker_key, block_number="latest", skip_code=True diff --git a/test/api-tests/hardfork-support/subroutine/eip_tests.py b/test/api-tests/hardfork-support/subroutine/eip_tests.py index d5fb50aec..78c9b8290 100644 --- a/test/api-tests/hardfork-support/subroutine/eip_tests.py +++ b/test/api-tests/hardfork-support/subroutine/eip_tests.py @@ -2348,42 +2348,70 @@ def test_eip_4399( details=details, ) - # skaled: prevRandao is derived from the previous block's BLS threshold signature - # and stored in the header mixHash — non-zero for any block after block 1. - latest = w3.eth.get_block("latest") - mix_hash = int.from_bytes(bytes(latest["mixHash"]), "big") - # Bracket the invariant-check call between two height reads so we know which - # block context it executed in. - prevrandao_at_latest = _as_int(contract.functions.getPrevRandao().call()) - chain_quiet = w3.eth.block_number == latest["number"] - details["latest_block"] = latest["number"] - details["mix_hash"] = hex(mix_hash) + # skaled: verify through a real transaction — deploy the PREVRANDAO recorder + # (constructor stores opcode 0x44 into slot 0) and compare the recorded value + # with the mixHash of the exact block that mined the deployment. This anchors + # the EIP-4399 invariant (opcode == executing header's mixHash) to one block + # with no timing dependence; the earlier eth_call checks the pending context. + recorder_receipt = _send_tx( + w3, + deployer, + { + "from": deployer.address, + "data": "0x4460005560006000f3", + "gas": 100_000, + }, + ) + if recorder_receipt["status"] != 1: + return EIPTestResult( + eip="4399", + passed=False, + message="PREVRANDAO recorder deploy reverted", + details=details, + ) + recorder_addr = recorder_receipt["contractAddress"] + recorded = int.from_bytes(bytes(w3.eth.get_storage_at(recorder_addr, 0)), "big") + block = w3.eth.get_block(recorder_receipt["blockNumber"]) + mix_hash = int.from_bytes(bytes(block["mixHash"]), "big") + details.update( + { + "recorder": recorder_addr, + "recorder_block": int(block["number"]), + "recorded": hex(recorded), + "mix_hash": hex(mix_hash), + } + ) - if prevrandao == 0: + if recorded == 0: return EIPTestResult( eip="4399", passed=False, - message="Expected non-zero beacon-derived PREVRANDAO on skaled, got 0", + message="Expected non-zero beacon-derived PREVRANDAO in transaction, got 0", details=details, ) - # EIP-4399 invariant: the opcode returns the executing block header's mixHash. - # Only checkable when no new block arrived around the bracketed call. - if chain_quiet and prevrandao_at_latest != mix_hash: + if recorded != mix_hash: return EIPTestResult( eip="4399", passed=False, message=( - f"PREVRANDAO {hex(prevrandao_at_latest)} != header mixHash {hex(mix_hash)} " - f"at block {latest['number']}" + f"Recorded PREVRANDAO {hex(recorded)} != header mixHash {hex(mix_hash)} " + f"at block {block['number']}" ), details=details, ) + if prevrandao == 0: + return EIPTestResult( + eip="4399", + passed=False, + message="eth_call (pending context) returned zero PREVRANDAO", + details=details, + ) return EIPTestResult( eip="4399", passed=True, message=( - f"PREVRANDAO returned non-zero beacon value {hex(prevrandao)[:14]}… " - f"(matches header mixHash: {chain_quiet})" + f"PREVRANDAO non-zero and equal to header mixHash at block " + f"{block['number']} ({hex(recorded)[:14]}…)" ), details=details, ) diff --git a/test/api-tests/hardfork-support/suite.py b/test/api-tests/hardfork-support/suite.py index 1dd4b39eb..bbe107557 100644 --- a/test/api-tests/hardfork-support/suite.py +++ b/test/api-tests/hardfork-support/suite.py @@ -28,6 +28,7 @@ SUITE_DIR / "patches" / "execution-specs-berlin-nonce-overflow-stubs.patch" ) NONCE_OVERFLOW_PATCH_MARKER = "NONCE_OVERFLOW_CREATE_STUB" +SUPPORT_PATCHES_DIR = NONCE_OVERFLOW_PATCH.parent def _load_run_eip_tests(): @@ -76,6 +77,44 @@ def _ensure_nonce_overflow_stub_patch(project_dir: Path) -> bool: return True +def _apply_support_patches(project_dir: Path) -> list: + """Apply every hardfork-support execution-specs patch except the config-gated + nonce-overflow stub patch. Already-applied patches are skipped.""" + applied = [] + for patch in sorted(SUPPORT_PATCHES_DIR.glob("*.patch")): + if patch == NONCE_OVERFLOW_PATCH: + continue + check = subprocess.run( + ["git", "apply", "--check", str(patch)], + cwd=project_dir, + capture_output=True, + text=True, + check=False, + ) + if check.returncode == 0: + subprocess.run( + ["git", "apply", str(patch)], + cwd=project_dir, + capture_output=True, + text=True, + check=True, + ) + applied.append(patch.name) + continue + reverse = subprocess.run( + ["git", "apply", "--reverse", "--check", str(patch)], + cwd=project_dir, + capture_output=True, + text=True, + check=False, + ) + if reverse.returncode != 0: + raise RuntimeError( + f"{patch.name}: {check.stderr.strip() or check.stdout.strip()}" + ) + return applied + + def _restore_nonce_overflow_stub_patch(project_dir: Path) -> None: test_path = project_dir / NONCE_OVERFLOW_TEST if NONCE_OVERFLOW_PATCH_MARKER not in test_path.read_text(): @@ -176,6 +215,17 @@ def _run_execution_specs( message=f"failed to patch execution-specs nonce-overflow stubs: {exc}", ) + # Apply every other hardfork-support execution-specs patch, mirroring CI + # (.github/actions/api-tests-run). The stub patch above stays config-gated. + try: + _apply_support_patches(project_dir) + except (OSError, RuntimeError, subprocess.CalledProcessError) as exc: + return TestResult( + name=test_name, + passed=False, + message=f"failed to apply execution-specs patches: {exc}", + ) + fork = str(execution_cfg.get("fork", "Berlin")) timeout_sec = int(execution_cfg.get("timeout_sec", 1200)) cmd = [ From ab644e25441c2e60b955bb42435603b4e6560952 Mon Sep 17 00:00:00 2001 From: badrogger Date: Thu, 30 Jul 2026 19:34:37 +0100 Subject: [PATCH 14/14] 1795 Switch to accumulation for prevrandao --- libethashseal/Ethash.cpp | 7 +- libethereum/SchainPatch.h | 18 ++-- libethereum/SkaleHost.cpp | 21 +++-- .../hardfork-compat/test_hardfork_compat.py | 7 +- .../hardfork-support/patches/.gitattributes | 3 + .../sol/contracts/eips/EIP4399Test.sol | 5 +- .../hardfork-support/subroutine/eip_tests.py | 12 ++- test/api-tests/hardfork-support/suite.py | 91 +++++++++++++------ 8 files changed, 109 insertions(+), 55 deletions(-) create mode 100644 test/api-tests/hardfork-support/patches/.gitattributes diff --git a/libethashseal/Ethash.cpp b/libethashseal/Ethash.cpp index edaebdb90..f8b9446fe 100644 --- a/libethashseal/Ethash.cpp +++ b/libethashseal/Ethash.cpp @@ -97,9 +97,10 @@ void Ethash::verify( Strictness _s, BlockHeader const& _bi, BlockHeader const& _ BOOST_THROW_EXCEPTION( InvalidBlockFormat() << errinfo_comment( "Paris block header must contain prevRandao and nonce" ) ); - // prevRandao is derived from the previous block's threshold signature at - // construction (SkaleHost::createBlock); verify cannot re-derive it here (no - // consensus access), so only the header shape and the zero nonce are pinned. + // prevRandao is accumulated at construction (SkaleHost::createBlock) from the + // parent's value and the previous block's threshold signature; verify cannot + // re-derive it here (no consensus access), so only the header shape and the + // zero nonce are pinned. if ( nonce( _bi ) != Nonce( 0 ) ) BOOST_THROW_EXCEPTION( InvalidBlockFormat() << errinfo_comment( "Paris block header nonce must be zero" ) ); diff --git a/libethereum/SchainPatch.h b/libethereum/SchainPatch.h index d5259bf05..8393a2cc0 100644 --- a/libethereum/SchainPatch.h +++ b/libethereum/SchainPatch.h @@ -214,14 +214,16 @@ DEFINE_SIMPLE_PATCH( SingleStateCommitPerBlockPatch ); DEFINE_SIMPLE_PATCH( ContractCreationReadOnlyPatch ); /* - * Paris fork (EIP-3675 + EIP-4399): difficulty=0, no uncles, and the header carries - * prevRandao = BLAKE3(thresholdSig(N-1)) — the previous block's consensus threshold - * signature hashed, same derivation as the getBlockRandom precompiled. The value is - * derived once at block construction (SkaleHost::createBlock) and only ever read from - * the header afterwards (PREVRANDAO opcode, replay, historic queries). Genesis and - * block 1 keep zero. Ethash::verify pins the header shape (2 seal fields, nonce=0) - * but cannot re-derive the value; changing the derivation after this patch has - * shipped requires a NEW patch. + * Paris fork (EIP-3675 + EIP-4399): difficulty=0, no uncles, and the header carries a + * RANDAO-style accumulator: prevRandao(N) = prevRandao(N-1) XOR BLAKE3(thresholdSig(N-1)), + * seeded by the zero of pre-Paris parents. Each term is the previous block's consensus + * threshold signature hashed — the same derivation the getBlockRandom precompiled uses — + * and the parent's mix is read from its stored header, so committee rotation adds + * cross-epoch lookahead protection. The value is derived once at block construction + * (SkaleHost::createBlock) and only ever read from the header afterwards (PREVRANDAO + * opcode, replay, historic queries). Genesis and block 1 keep zero. Ethash::verify pins + * the header shape (2 seal fields, nonce=0) but cannot re-derive the value; changing the + * derivation after this patch has shipped requires a NEW patch. */ DEFINE_SIMPLE_PATCH( ParisForkPatch ); diff --git a/libethereum/SkaleHost.cpp b/libethereum/SkaleHost.cpp index b40de3598..166226464 100644 --- a/libethereum/SkaleHost.cpp +++ b/libethereum/SkaleHost.cpp @@ -685,14 +685,19 @@ void SkaleHost::createBlock( const ConsensusExtFace::Transactions& _approvedTran BlockHeader latestInfo = static_cast< const Interface& >( m_client ).blockInfo( LatestBlock ); - // EIP-4399: derive the new block's prevRandao from the previous block's threshold - // signature. This is the only place the value ever crosses from consensus to the EVM; - // execution and replay read it from the header. The previous block was committed to the - // consensus BlockDB one block ago, so the lookup cannot hit DB rotation. Any failure here - // must abort the import (fail closed) — a fallback value would fork replay from history. + // EIP-4399: accumulate the new block's prevRandao RANDAO-style: + // mix[N] = mix[N-1] XOR BLAKE3(thresholdSig(N-1)) + // The parent's mix comes from its stored header (zero for pre-Paris parents, which + // self-seeds the chain at activation), so committee rotation gives cross-epoch + // defense-in-depth: predicting a future mix requires every intervening epoch's terms. + // This is the only place the value ever crosses from consensus to the EVM; execution + // and replay read it from the header. The previous block was committed to the + // consensus BlockDB one block ago, so the lookup cannot hit DB rotation. Any failure + // here must abort the import (fail closed) — a fallback value would fork replay. u256 prevRandao = 0; if ( _blockID > 1 && ParisForkPatch::isEnabledWhen( latestInfo.timestamp() ) ) { - prevRandao = m_consensus->getRandomForBlockId( _blockID - 1 ); + prevRandao = + u256( latestInfo.prevRandao() ) ^ m_consensus->getRandomForBlockId( _blockID - 1 ); } // Keep this outside m_blockImportMutex to avoid lock-order cycles with @@ -1234,7 +1239,9 @@ u256 SkaleHost::getPrevRandaoForPendingBlock() const noexcept { static_cast< const Interface& >( m_client ).blockInfo( LatestBlock ); if ( !ParisForkPatch::isEnabledWhen( latestInfo.timestamp() ) ) return 0; - return m_consensus->getRandomForBlockId( latestNumber ); + // Same accumulator as createBlock, one step ahead: the pending block N+1 + // carries mix[N] XOR random(N). + return u256( latestInfo.prevRandao() ) ^ m_consensus->getRandomForBlockId( latestNumber ); } catch ( ... ) { // Pending-simulation value only; degrading to zero never affects consensus. cwarn << "Could not fetch prevRandao for the pending block; simulations will see 0"; diff --git a/test/api-tests/hardfork-compat/test_hardfork_compat.py b/test/api-tests/hardfork-compat/test_hardfork_compat.py index 0acf62476..a2b8dcb9d 100644 --- a/test/api-tests/hardfork-compat/test_hardfork_compat.py +++ b/test/api-tests/hardfork-compat/test_hardfork_compat.py @@ -264,9 +264,10 @@ def _assert_receipts_after_paris(w3: Web3, receipts: list, timestamp: int, label assert int(block["difficulty"]) == 0, ( f"Post-Paris block {block['number']} has non-zero difficulty {block['difficulty']}" ) - # Post-Paris skaled headers carry prevRandao (in the mixHash position) derived - # from the previous block's BLS threshold signature — non-zero and distinct - # per block for every block after block 1. + # Post-Paris skaled headers carry prevRandao (in the mixHash position): + # a RANDAO-style accumulator of the parent's value XOR the previous block's + # BLS threshold signature hash — non-zero and distinct per block for every + # block after block 1. mix_hash = int.from_bytes(bytes(block["mixHash"]), "big") assert mix_hash != 0, ( f"Post-Paris block {block['number']} has zero prevRandao/mixHash" diff --git a/test/api-tests/hardfork-support/patches/.gitattributes b/test/api-tests/hardfork-support/patches/.gitattributes new file mode 100644 index 000000000..e15bbbce3 --- /dev/null +++ b/test/api-tests/hardfork-support/patches/.gitattributes @@ -0,0 +1,3 @@ +# Unified-diff patch files legitimately contain blank context lines that end in +# a single space; exempt them from git's trailing-whitespace checks. +*.patch -whitespace diff --git a/test/api-tests/hardfork-support/sol/contracts/eips/EIP4399Test.sol b/test/api-tests/hardfork-support/sol/contracts/eips/EIP4399Test.sol index fd4896322..289debd00 100644 --- a/test/api-tests/hardfork-support/sol/contracts/eips/EIP4399Test.sol +++ b/test/api-tests/hardfork-support/sol/contracts/eips/EIP4399Test.sol @@ -3,8 +3,9 @@ pragma solidity ^0.8.20; // EIP-4399: PREVRANDAO opcode. // Post-Paris, opcode 0x44 (formerly DIFFICULTY) returns the beacon RANDAO mix. -// In skaled, prevRandao = BLAKE3 of the previous block's BLS threshold signature, -// stored in the block header — non-zero for every block after block 1. +// In skaled, prevRandao accumulates RANDAO-style: parent's value XOR BLAKE3 of the +// previous block's BLS threshold signature, stored in the block header — non-zero +// for every block after block 1. contract EIP4399Test { function getPrevRandao() external view returns (uint256) { return block.prevrandao; diff --git a/test/api-tests/hardfork-support/subroutine/eip_tests.py b/test/api-tests/hardfork-support/subroutine/eip_tests.py index 78c9b8290..c692a5788 100644 --- a/test/api-tests/hardfork-support/subroutine/eip_tests.py +++ b/test/api-tests/hardfork-support/subroutine/eip_tests.py @@ -2315,9 +2315,10 @@ def test_eip_4399( ) -> EIPTestResult: """EIP-4399: PREVRANDAO opcode is accessible post-Paris. - skaled: prevRandao = BLAKE3 of the previous block's BLS threshold signature, - carried in the header mixHash — non-zero after block 1, and the opcode result - must equal the header field (the EIP-4399 invariant). + skaled: prevRandao is a RANDAO-style accumulator — the parent's value XOR + BLAKE3 of the previous block's BLS threshold signature — carried in the header + mixHash: non-zero after block 1, and the opcode result must equal the header + field (the EIP-4399 invariant). Anvil: returns a non-zero simulated value — any non-zero value is accepted. """ logger.info("=== EIP-4399 PREVRANDAO opcode test ===") @@ -2352,7 +2353,8 @@ def test_eip_4399( # (constructor stores opcode 0x44 into slot 0) and compare the recorded value # with the mixHash of the exact block that mined the deployment. This anchors # the EIP-4399 invariant (opcode == executing header's mixHash) to one block - # with no timing dependence; the earlier eth_call checks the pending context. + # with no timing dependence. The earlier eth_call checks the working-block + # context: non-historic skaled serves both "latest" and "pending" from it. recorder_receipt = _send_tx( w3, deployer, @@ -2403,7 +2405,7 @@ def test_eip_4399( return EIPTestResult( eip="4399", passed=False, - message="eth_call (pending context) returned zero PREVRANDAO", + message="eth_call (working-block context) returned zero PREVRANDAO", details=details, ) return EIPTestResult( diff --git a/test/api-tests/hardfork-support/suite.py b/test/api-tests/hardfork-support/suite.py index bbe107557..1b5403601 100644 --- a/test/api-tests/hardfork-support/suite.py +++ b/test/api-tests/hardfork-support/suite.py @@ -79,40 +79,64 @@ def _ensure_nonce_overflow_stub_patch(project_dir: Path) -> bool: def _apply_support_patches(project_dir: Path) -> list: """Apply every hardfork-support execution-specs patch except the config-gated - nonce-overflow stub patch. Already-applied patches are skipped.""" + nonce-overflow stub patch. Already-applied patches are skipped. Returns the + names of patches this call applied; on failure, reverses those first so a + partial application never leaks into the checkout.""" applied = [] - for patch in sorted(SUPPORT_PATCHES_DIR.glob("*.patch")): - if patch == NONCE_OVERFLOW_PATCH: - continue - check = subprocess.run( - ["git", "apply", "--check", str(patch)], - cwd=project_dir, - capture_output=True, - text=True, - check=False, - ) - if check.returncode == 0: + try: + for patch in sorted(SUPPORT_PATCHES_DIR.glob("*.patch")): + if patch == NONCE_OVERFLOW_PATCH: + continue + check = subprocess.run( + ["git", "apply", "--check", str(patch)], + cwd=project_dir, + capture_output=True, + text=True, + check=False, + ) + if check.returncode == 0: + subprocess.run( + ["git", "apply", str(patch)], + cwd=project_dir, + capture_output=True, + text=True, + check=True, + ) + applied.append(patch.name) + continue + reverse = subprocess.run( + ["git", "apply", "--reverse", "--check", str(patch)], + cwd=project_dir, + capture_output=True, + text=True, + check=False, + ) + if reverse.returncode != 0: + raise RuntimeError( + f"{patch.name}: {check.stderr.strip() or check.stdout.strip()}" + ) + except Exception: + _reverse_support_patches(project_dir, applied) + raise + return applied + + +def _reverse_support_patches(project_dir: Path, applied: list) -> None: + """Reverse the given patches in reverse application order; log-and-continue + on individual failures so one stuck patch does not block the others.""" + for patch_name in reversed(applied): + try: subprocess.run( - ["git", "apply", str(patch)], + ["git", "apply", "--reverse", str(SUPPORT_PATCHES_DIR / patch_name)], cwd=project_dir, capture_output=True, text=True, check=True, ) - applied.append(patch.name) - continue - reverse = subprocess.run( - ["git", "apply", "--reverse", "--check", str(patch)], - cwd=project_dir, - capture_output=True, - text=True, - check=False, - ) - if reverse.returncode != 0: - raise RuntimeError( - f"{patch.name}: {check.stderr.strip() or check.stdout.strip()}" + except (OSError, subprocess.CalledProcessError) as exc: + logger.warning( + "Failed to reverse execution-specs patch %s: %s", patch_name, exc ) - return applied def _restore_nonce_overflow_stub_patch(project_dir: Path) -> None: @@ -217,9 +241,21 @@ def _run_execution_specs( # Apply every other hardfork-support execution-specs patch, mirroring CI # (.github/actions/api-tests-run). The stub patch above stays config-gated. + # Patches applied by this run are reversed in the finally below; on apply + # failure the nonce-overflow patch must be restored here since the early + # return never reaches that finally. + applied_support_patches = [] try: - _apply_support_patches(project_dir) + applied_support_patches = _apply_support_patches(project_dir) except (OSError, RuntimeError, subprocess.CalledProcessError) as exc: + if nonce_patch_applied: + try: + _restore_nonce_overflow_stub_patch(project_dir) + except (OSError, subprocess.CalledProcessError) as restore_exc: + logger.warning( + "Failed to restore execution-specs nonce-overflow patch: %s", + restore_exc, + ) return TestResult( name=test_name, passed=False, @@ -306,6 +342,7 @@ def _run_execution_specs( details={"log": str(log_path)}, ) finally: + _reverse_support_patches(project_dir, applied_support_patches) if nonce_patch_applied: try: _restore_nonce_overflow_stub_patch(project_dir)