From ea47fa85e0dca84036f36adfed5431e3c2c4d4f4 Mon Sep 17 00:00:00 2001 From: Lukasz Zimnoch Date: Thu, 14 May 2026 11:00:57 +0200 Subject: [PATCH] core/vm: add CanReceiveTransfer guard for fail-fast frame rejection Adds an optional recipient-side transfer guard alongside the existing CanTransfer sender-side guard. Chains whose host layer refuses to credit certain recipient addresses can wire CanReceiveTransfer into BlockContext so that value-bearing CALLs to those addresses fail at the EVM frame boundary, before any state mutation, instead of running to commit-time rejection. Changes are confined to the CALL opcode path: * core/vm/errors.go new ErrTransferNotAllowed sentinel * core/vm/evm.go new CanReceiveTransferFunc type, optional CanReceiveTransfer field on BlockContext, new guard inside (*EVM).Call positioned after the sender-side CanTransfer check and before the StateDB snapshot CallCode, DelegateCall, StaticCall, and the create path are unchanged. The default CanTransfer in core/evm.go and NewEVMBlockContext are also unchanged; a nil CanReceiveTransfer preserves upstream behavior, so non-integrating consumers see no behavioral difference. --- core/vm/errors.go | 1 + core/vm/evm.go | 25 ++++++++++++ core/vm/evm_test.go | 97 +++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 123 insertions(+) create mode 100644 core/vm/evm_test.go diff --git a/core/vm/errors.go b/core/vm/errors.go index e33c9fcb853c..05c648fc22c1 100644 --- a/core/vm/errors.go +++ b/core/vm/errors.go @@ -28,6 +28,7 @@ var ( ErrCodeStoreOutOfGas = errors.New("contract creation code storage out of gas") ErrDepth = errors.New("max call depth exceeded") ErrInsufficientBalance = errors.New("insufficient balance for transfer") + ErrTransferNotAllowed = errors.New("transfer not allowed to recipient") ErrContractAddressCollision = errors.New("contract address collision") ErrExecutionReverted = errors.New("execution reverted") ErrMaxCodeSizeExceeded = errors.New("max code size exceeded") diff --git a/core/vm/evm.go b/core/vm/evm.go index 49ca51351e76..1e2e01750dad 100644 --- a/core/vm/evm.go +++ b/core/vm/evm.go @@ -34,6 +34,10 @@ import ( type ( // CanTransferFunc is the signature of a transfer guard function CanTransferFunc func(StateDB, common.Address, *uint256.Int) bool + // CanReceiveTransferFunc is the signature of a recipient-side transfer + // guard function. The address argument is the recipient (credited + // account), not the sender. + CanReceiveTransferFunc func(StateDB, common.Address, *uint256.Int) bool // TransferFunc is the signature of a transfer function TransferFunc func(StateDB, common.Address, common.Address, *uint256.Int) // GetHashFunc returns the n'th block hash in the blockchain @@ -61,6 +65,14 @@ type BlockContext struct { BaseFee *big.Int // Provides information for BASEFEE (0 if vm runs with NoBaseFee flag and 0 gas price) BlobBaseFee *big.Int // Provides information for BLOBBASEFEE (0 if vm runs with NoBaseFee flag and 0 blob gas price) Random *common.Hash // Provides information for PREVRANDAO + + // CanReceiveTransfer is a Mezo-specific fork addition (does not exist + // in upstream go-ethereum). Optional recipient-side guard invoked by + // Call to fail-fast on transfers the host chain refuses to credit. + // Nil-safe: a nil value disables the check, preserving upstream + // behavior. NewEVMBlockContext leaves it nil; only chain integrators + // (e.g. mezod) populate it. + CanReceiveTransfer CanReceiveTransferFunc } // TxContext provides the EVM with information about a transaction. @@ -249,6 +261,19 @@ func (evm *EVM) Call(caller common.Address, addr common.Address, input []byte, g if !value.IsZero() && !evm.Context.CanTransfer(evm.StateDB, caller, value) { return nil, gas, ErrInsufficientBalance } + // Recipient-side guard (Mezo-specific fork addition; nil-safe). Gated + // in Call only: DelegateCall and StaticCall perform no value transfer, + // CallCode credits the caller rather than the target, and CREATE / + // CREATE2 credit a freshly-derived contract address that by construction + // cannot collide with the well-known host-chain accounts (module + // accounts, etc.) that this guard is intended to protect — so none of + // those frames need the check. Placed before the StateDB snapshot so + // a refusal aborts the frame cleanly with no state mutation, instead + // of being caught at commit time. + if !value.IsZero() && evm.Context.CanReceiveTransfer != nil && + !evm.Context.CanReceiveTransfer(evm.StateDB, addr, value) { + return nil, gas, ErrTransferNotAllowed + } snapshot := evm.StateDB.Snapshot() p, isPrecompile := evm.precompile(addr) diff --git a/core/vm/evm_test.go b/core/vm/evm_test.go new file mode 100644 index 000000000000..6fd1024508b8 --- /dev/null +++ b/core/vm/evm_test.go @@ -0,0 +1,97 @@ +// Copyright 2026 The go-ethereum Authors +// This file is part of the go-ethereum library. +// +// The go-ethereum library is free software: you can redistribute it and/or modify +// it under the terms of the GNU Lesser General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// The go-ethereum library 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 Lesser General Public License for more details. +// +// You should have received a copy of the GNU Lesser General Public License +// along with the go-ethereum library. If not, see . + +package vm + +import ( + "errors" + "testing" + + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/core/state" + "github.com/ethereum/go-ethereum/core/types" + "github.com/ethereum/go-ethereum/params" + "github.com/holiman/uint256" + "github.com/stretchr/testify/require" +) + +// TestEVMCallCanReceiveTransferGuard verifies that a non-nil +// CanReceiveTransfer returning false aborts a value-bearing CALL with +// ErrTransferNotAllowed before any state mutation: gas is left intact, +// the recipient is not created, and Transfer is not invoked. +func TestEVMCallCanReceiveTransferGuard(t *testing.T) { + var ( + caller = common.HexToAddress("0x01") + recipient = common.HexToAddress("0x02") + gas = uint64(100_000) + value = uint256.NewInt(1) + ) + + statedb, err := state.New(types.EmptyRootHash, state.NewDatabaseForTesting()) + require.NoError(t, err) + + var transferCalled bool + evm := NewEVM(BlockContext{ + CanTransfer: func(StateDB, common.Address, *uint256.Int) bool { + return true + }, + CanReceiveTransfer: func(StateDB, common.Address, *uint256.Int) bool { + return false + }, + Transfer: func(StateDB, common.Address, common.Address, *uint256.Int) { + transferCalled = true + }, + }, statedb, params.AllEthashProtocolChanges, Config{}) + + _, leftOverGas, err := evm.Call(caller, recipient, nil, gas, value) + + require.True(t, errors.Is(err, ErrTransferNotAllowed)) + require.Equal(t, gas, leftOverGas) + require.False(t, statedb.Exist(recipient)) + require.False(t, transferCalled) +} + +// TestEVMCallCanReceiveTransferGuardIgnoresZeroValue verifies that the +// recipient-side guard is short-circuited by the value.IsZero() check +// and is not invoked for zero-value CALLs. +func TestEVMCallCanReceiveTransferGuardIgnoresZeroValue(t *testing.T) { + var ( + caller = common.HexToAddress("0x01") + recipient = common.HexToAddress("0x02") + gas = uint64(100_000) + value = uint256.NewInt(0) + ) + + statedb, err := state.New(types.EmptyRootHash, state.NewDatabaseForTesting()) + require.NoError(t, err) + + var guardCalled bool + evm := NewEVM(BlockContext{ + CanTransfer: func(StateDB, common.Address, *uint256.Int) bool { + return true + }, + CanReceiveTransfer: func(StateDB, common.Address, *uint256.Int) bool { + guardCalled = true + return false + }, + Transfer: func(StateDB, common.Address, common.Address, *uint256.Int) {}, + }, statedb, params.AllEthashProtocolChanges, Config{}) + + _, _, err = evm.Call(caller, recipient, nil, gas, value) + + require.NoError(t, err) + require.False(t, guardCalled) +}