From 432764be287fff320546c6422c3997c239a0dd85 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 29 Aug 2026 06:19:27 +0000 Subject: [PATCH] fix(evm): reload the cached memory base whenever the cached size is reloaded The multipass JIT caches EVM memory base and size in function-entry locals and refreshes them on the memory-expansion branch. It also refreshes the size alone after every runtime helper that can grow memory - the CALL family, CREATE, CREATE2, LOG, KECCAK256, CALLDATACOPY, CODECOPY, EXTCODECOPY, RETURNDATACOPY. The base was never refreshed there, and that is unsound, because those helpers can move the base. EVM memory is allocated lazily: a frame starts with MemoryBase == nullptr and the 16 MB buffer is created by ensureMemoryBuffer() on the frame's first real growth. When that first growth happens inside a runtime helper rather than through expandMemoryIR, the helper allocates the buffer and sets MemoryBase, the JIT reloads the size, and the cached base keeps the entry-time null. A later memory access then combines the two: the reloaded size is large enough that the expansion branch - the only other place the base is refreshed - is not taken, and a precheck-covered access reads the cached base and stores through null. The write lands on page zero, which the trap handler reports as an internal error and which, before it was reclassified, was laundered into an EVMC_INVALID_MEMORY_ACCESS halt and a wrong gas number. Make the pairing structural instead of incidental: reloadMemoryBaseFromInstance() is now one helper used by both the expansion branch and the post-helper reload, and reloadMemorySizeFromInstance() becomes reloadMemoryCachesFromInstance(), which refreshes both halves of the snapshot. The cost is one 8-byte load after a call that has already gone out to a runtime helper. The added differential test is a 22-byte contract with no nested call: a CALLDATACOPY with a dynamic length grows memory inside the generic helper, and two constant-offset stores in the next block share a block precheck whose expansion is already satisfied, so they address memory through the cached base. It segfaults under multipass without this change and matches the interpreter with it. Verified on mainnet blocks 25818502 and 25818530, the two blocks out of a 1000-block window that failed under DTVM only in JIT mode. Both now complete with post-state-root verification. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01Hg7u14XK36yqjEidE3o2rY --- .../evm_frontend/evm_mir_compiler.cpp | 79 +++++++++++-------- src/compiler/evm_frontend/evm_mir_compiler.h | 7 +- src/tests/evm_differential_tests.cpp | 45 +++++++++++ 3 files changed, 96 insertions(+), 35 deletions(-) diff --git a/src/compiler/evm_frontend/evm_mir_compiler.cpp b/src/compiler/evm_frontend/evm_mir_compiler.cpp index f12c392b..a346a958 100644 --- a/src/compiler/evm_frontend/evm_mir_compiler.cpp +++ b/src/compiler/evm_frontend/evm_mir_compiler.cpp @@ -4773,7 +4773,7 @@ void EVMMirBuilder::handleCodeCopy(Operand DestOffsetComponents, } #endif if (!UsePreparedMemory) { - reloadMemorySizeFromInstance(); + reloadMemoryCachesFromInstance(); } } @@ -5579,7 +5579,7 @@ void EVMMirBuilder::handleLogWithTopics(Operand OffsetOp, Operand SizeOp, #ifdef ZEN_ENABLE_EVM_GAS_REGISTER reloadGasFromMemory(); #endif - reloadMemorySizeFromInstance(); + reloadMemoryCachesFromInstance(); } typename EVMMirBuilder::Operand @@ -5596,7 +5596,7 @@ EVMMirBuilder::handleCreate(Operand ValueOp, Operand OffsetOp, Operand SizeOp) { #ifdef ZEN_ENABLE_EVM_GAS_REGISTER reloadGasFromMemory(); #endif - reloadMemorySizeFromInstance(); + reloadMemoryCachesFromInstance(); return Result; } @@ -5616,7 +5616,7 @@ typename EVMMirBuilder::Operand EVMMirBuilder::handleCreate2(Operand ValueOp, #ifdef ZEN_ENABLE_EVM_GAS_REGISTER reloadGasFromMemory(); #endif - reloadMemorySizeFromInstance(); + reloadMemoryCachesFromInstance(); return Result; } @@ -5698,7 +5698,7 @@ EVMMirBuilder::handleCall(Operand GasOp, Operand ToAddrOp, Operand ValueOp, #ifdef ZEN_ENABLE_EVM_GAS_REGISTER reloadGasFromMemory(); #endif - reloadMemorySizeFromInstance(); + reloadMemoryCachesFromInstance(); return Result; } @@ -5730,7 +5730,7 @@ EVMMirBuilder::handleCallCode(Operand GasOp, Operand ToAddrOp, Operand ValueOp, #ifdef ZEN_ENABLE_EVM_GAS_REGISTER reloadGasFromMemory(); #endif - reloadMemorySizeFromInstance(); + reloadMemoryCachesFromInstance(); return Result; } @@ -5798,7 +5798,7 @@ EVMMirBuilder::handleDelegateCall(Operand GasOp, Operand ToAddrOp, #ifdef ZEN_ENABLE_EVM_GAS_REGISTER reloadGasFromMemory(); #endif - reloadMemorySizeFromInstance(); + reloadMemoryCachesFromInstance(); return Result; } @@ -5828,7 +5828,7 @@ EVMMirBuilder::handleStaticCall(Operand GasOp, Operand ToAddrOp, #ifdef ZEN_ENABLE_EVM_GAS_REGISTER reloadGasFromMemory(); #endif - reloadMemorySizeFromInstance(); + reloadMemoryCachesFromInstance(); return Result; } @@ -6011,7 +6011,7 @@ EVMMirBuilder::handleKeccak256(Operand OffsetComponents, reloadGasFromMemory(); #endif if (!UsePreparedMemory) { - reloadMemorySizeFromInstance(); + reloadMemoryCachesFromInstance(); } return Result; } @@ -6043,7 +6043,7 @@ EVMMirBuilder::handleKeccak256TwoWord(Operand OffsetComponents, Operand Word0, reloadGasFromMemory(); #endif if (!UsePreparedMemory) { - reloadMemorySizeFromInstance(); + reloadMemoryCachesFromInstance(); } return Result; } @@ -6076,7 +6076,7 @@ typename EVMMirBuilder::Operand EVMMirBuilder::handleKeccak256CallDataConstSlot( reloadGasFromMemory(); #endif if (!UsePreparedMemory) { - reloadMemorySizeFromInstance(); + reloadMemoryCachesFromInstance(); } return Result; } @@ -6108,7 +6108,7 @@ EVMMirBuilder::handleKeccak256CallerConstSlot(Operand OffsetComponents, reloadGasFromMemory(); #endif if (!UsePreparedMemory) { - reloadMemorySizeFromInstance(); + reloadMemoryCachesFromInstance(); } return Result; } @@ -7410,7 +7410,7 @@ void EVMMirBuilder::handleCallDataCopy(Operand DestOffsetComponents, } #endif if (!UsePreparedMemory) { - reloadMemorySizeFromInstance(); + reloadMemoryCachesFromInstance(); } } @@ -7448,7 +7448,7 @@ void EVMMirBuilder::handleExtCodeCopy(Operand AddressComponents, #ifdef ZEN_ENABLE_EVM_GAS_REGISTER reloadGasFromMemory(); #endif - reloadMemorySizeFromInstance(); + reloadMemoryCachesFromInstance(); } void EVMMirBuilder::handleReturnDataCopy(Operand DestOffsetComponents, @@ -7490,7 +7490,7 @@ void EVMMirBuilder::handleReturnDataCopy(Operand DestOffsetComponents, #ifdef ZEN_ENABLE_EVM_GAS_REGISTER reloadGasFromMemory(); #endif - reloadMemorySizeFromInstance(); + reloadMemoryCachesFromInstance(); } typename EVMMirBuilder::Operand EVMMirBuilder::handleReturnDataSize() { @@ -9524,22 +9524,42 @@ MInstruction *EVMMirBuilder::getMemorySize() { return getInstanceElement(I64Type, MemorySizeOffset); } -void EVMMirBuilder::reloadMemorySizeFromInstance() { +void EVMMirBuilder::reloadMemoryBaseFromInstance() { + if (!MemoryBaseVar) { + return; + } + MPointerType *VoidPtrType = createVoidPtrType(); + const int32_t MemoryBaseOffset = + zen::runtime::EVMInstance::getMemoryBaseOffset(); + MInstruction *MemPtr = getInstanceElement(VoidPtrType, MemoryBaseOffset); + MInstruction *MemBaseInt = createInstruction( + false, OP_ptrtoint, &Ctx.I64Type, MemPtr); + createInstruction(true, &(Ctx.VoidType), MemBaseInt, + MemoryBaseVar->getVarIdx()); +} + +void EVMMirBuilder::reloadMemoryCachesFromInstance() { #ifdef ZEN_ENABLE_MULTIPASS_JIT_LOGGING ++MemStats.ReloadMemorySizeCount; if (CurBlockMemStats.Active) { CurBlockMemStats.ReloadMemSizeCount++; } #endif // ZEN_ENABLE_MULTIPASS_JIT_LOGGING - if (!MemorySizeVar) { - return; + if (MemorySizeVar) { + MType *I64Type = &Ctx.I64Type; + const int32_t MemorySizeOffset = + zen::runtime::EVMInstance::getMemorySizeOffset(); + MInstruction *MemSize = getInstanceElement(I64Type, MemorySizeOffset); + createInstruction(true, &(Ctx.VoidType), MemSize, + MemorySizeVar->getVarIdx()); } - MType *I64Type = &Ctx.I64Type; - const int32_t MemorySizeOffset = - zen::runtime::EVMInstance::getMemorySizeOffset(); - MInstruction *MemSize = getInstanceElement(I64Type, MemorySizeOffset); - createInstruction(true, &(Ctx.VoidType), MemSize, - MemorySizeVar->getVarIdx()); + // The base must be reloaded with the size, never on its own schedule. A + // runtime helper that grows memory performs the frame's lazy first + // allocation, which moves MemoryBase from null to the new buffer. Refreshing + // only the size leaves a cached null base that later memory ops then trust, + // because a large enough cached size makes them skip expandMemoryIR - the + // only other place the base is refreshed. + reloadMemoryBaseFromInstance(); } MInstruction * @@ -9783,16 +9803,7 @@ void EVMMirBuilder::expandMemoryIR(MInstruction *RequiredSize, createInstruction(true, &(Ctx.VoidType), AlignedSize, MemorySizeVar->getVarIdx()); } - if (MemoryBaseVar) { - MPointerType *VoidPtrType = createVoidPtrType(); - const int32_t MemoryBaseOffset = - zen::runtime::EVMInstance::getMemoryBaseOffset(); - MInstruction *MemPtr = getInstanceElement(VoidPtrType, MemoryBaseOffset); - MInstruction *MemBaseInt = createInstruction( - false, OP_ptrtoint, I64Type, MemPtr); - createInstruction(true, &(Ctx.VoidType), MemBaseInt, - MemoryBaseVar->getVarIdx()); - } + reloadMemoryBaseFromInstance(); createInstruction(true, Ctx, ContinueBB); addSuccessor(ContinueBB); diff --git a/src/compiler/evm_frontend/evm_mir_compiler.h b/src/compiler/evm_frontend/evm_mir_compiler.h index 02450d30..655bfe8e 100644 --- a/src/compiler/evm_frontend/evm_mir_compiler.h +++ b/src/compiler/evm_frontend/evm_mir_compiler.h @@ -1889,7 +1889,12 @@ class EVMMirBuilder final { MInstruction *getConstBlockDirectMemoryBasePtr(); MInstruction *getLargeStaticWorkspaceDirectMemoryBasePtr(); MInstruction *getMemorySize(); - void reloadMemorySizeFromInstance(); + // Refresh the cached EVM memory base from the instance. Callers must keep + // this paired with the cached size: the two are one snapshot of the frame's + // memory, and refreshing either alone lets generated code combine a fresh + // size with a stale base. + void reloadMemoryBaseFromInstance(); + void reloadMemoryCachesFromInstance(); void expandMemoryIR(MInstruction *RequiredSize, MInstruction *Overflow); void chargeWordCopyGasIR(MInstruction *Size); void chargeDynamicGasIR(MInstruction *GasCost); diff --git a/src/tests/evm_differential_tests.cpp b/src/tests/evm_differential_tests.cpp index 39be402f..86613343 100644 --- a/src/tests/evm_differential_tests.cpp +++ b/src/tests/evm_differential_tests.cpp @@ -284,6 +284,51 @@ TEST(EVMPreparedCopyFallbackDifferential, EXPECT_EQ(Output, "60045F5F"); } +TEST(EVMMemoryBaseCacheDifferential, + HelperGrownMemoryIsAddressableFromALaterBlock) { + // The JIT caches the EVM memory base and size in function-entry locals. A + // frame starts with a null base, because EVM memory is allocated lazily on + // the frame's first growth, and the expansion branch is the only place the + // base cache is refreshed. + // + // Here the first growth happens inside the CALLDATACOPY runtime helper - a + // dynamic copy length keeps it on the generic, memory-growing helper rather + // than a prepared one - so the helper performs the lazy allocation and moves + // the instance's base off null. The two constant-offset stores in the next + // block share one block precheck, whose expansion is already satisfied by + // the reloaded size, so they address memory through the cached base without + // taking the expansion branch. If that cache was not refreshed alongside the + // size it still holds the entry-time null and the stores dereference it. + const std::vector Bytecode = { + 0x36, // PC0: CALLDATASIZE, dynamic copy length + 0x5f, // PC1: PUSH0 calldata offset + 0x5f, // PC2: PUSH0 destination offset + 0x37, // PC3: CALLDATACOPY, grows memory inside the helper + 0x60, 0x08, // PC4: PUSH1 successor + 0x56, // PC6: JUMP + 0x5b, // PC7: unreachable padding + 0x5b, // PC8: JUMPDEST + 0x60, 0x01, // PC9: PUSH1 1 + 0x5f, // PC11: PUSH0 store offset + 0x52, // PC12: MSTORE through the cached base + 0x60, 0x02, // PC13: PUSH1 2 + 0x60, 0x20, // PC15: PUSH1 store offset + 0x52, // PC17: MSTORE, second op sharing the block precheck + 0x60, 0x40, // PC18: PUSH1 return length + 0x5f, // PC20: PUSH0 return offset + 0xf3, // PC21: RETURN + }; + // 64 bytes of calldata, so the helper grows memory to exactly the 64 bytes + // the two stores need and the block precheck finds nothing left to expand. + const std::vector CallData(64, 0xab); + + const auto Output = expectInterpMatchesMultipassWithGas( + "memory_base_cache_after_helper_growth", Bytecode, CallData); + EXPECT_EQ(Output, + "0000000000000000000000000000000000000000000000000000000000000001" + "0000000000000000000000000000000000000000000000000000000000000002"); +} + TEST(EVMKeccakMemoryProofDifferential, CrossBlockProofReusePreservesHashAndGas) { const std::vector Bytecode = {