Repository navigation
Add CanReceiveTransfer recipient-side transfer guard - #10
Merged
Merged
Conversation
lukasz-zimnoch
force-pushed
the
mezo-4351-recipient-guard
branch
2 times, most recently
from
May 14, 2026 09:53
01d6199 to
9be4e8d
Compare
lukasz-zimnoch
marked this pull request as ready for review
May 14, 2026 09:54
8 of 9 tasks
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.
lukasz-zimnoch
force-pushed
the
mezo-4351-recipient-guard
branch
from
May 14, 2026 10:32
9be4e8d to
ea47fa8
Compare
tomaszslabon
approved these changes
May 14, 2026
tomaszslabon
added a commit
to mezo-org/mezod
that referenced
this pull request
May 14, 2026
Closes: [MEZO-4351](https://linear.app/thesis-co/issue/MEZO-4351) Depends on: mezo-org/go-ethereum#10 ### Introduction Mezo's bank keeper maintains a set of blocked addresses — Cosmos module accounts such as `fee_collector`, `bridge`, `evm`, and `poa` — that may not be credited via `SendCoinsFromModuleToAccount`. Before this PR, a value-bearing EVM CALL whose recipient was one of those module accounts ran all the way to commit time, where the bank keeper refused the credit and emitted `"is not allowed to receive funds"`. CometBFT surfaced that as an outer error, the indexer dropped the transaction, and `eth_getTransactionByHash` returned `null` for a hash that observably went out over the wire. After this PR the rejection happens inside the EVM call frame the same way a stock geth revert does: the snapshot is rolled back, the outer transaction surfaces a normal failed receipt with a queryable hash, and the inner-CALL case bubbles the failure to the calling contract via the conventional success bit rather than crashing the outer tx. ### Changes #### `go.mod` / `go.sum`: pin go-ethereum to v1.16.9-mezo1 The mezo fork of go-ethereum gains a new `CanReceiveTransferFunc` field on `vm.BlockContext` and a new `ErrTransferNotAllowed` error in `core/vm`. Both ship under the `v1.16.9-mezo1` tag, which the replace now pins. #### `x/evm/types/interfaces.go`: extend BankKeeper A single new method, `BlockedAddr(addr sdk.AccAddress) bool`, lifts the existing cosmos-sdk `x/bank` predicate onto the EVM keeper's view of the bank keeper. The implementation is the stock cosmos-sdk method — no wrapper. #### `x/evm/keeper/state_transition.go` and `simulate_v1.go`: wire the guard A new `(k *Keeper).CanReceiveTransfer` method consults `bankKeeper.BlockedAddr`. It is plugged into `vm.BlockContext` for both the live-tx path (`NewEVMWithOverrides`) and the `eth_call` simulate path (`processSimBlock`). The geth fork only invokes the hook on value-bearing CALLs (`value > 0` and the hook is non-nil), so zero-value CALLs — the vast majority of contract-to-contract traffic — pay no overhead. #### `x/evm/keeper/state_transition_test.go`: keeper-level coverage Three new tests on `KeeperTestSuite` cover the observable paths. They target the `poa` module account, which is blocked, carries no minter/burner perms, and stays untouched by the fee-routing path: - `TestRecipientGuard_DirectSend` — a top-level value send to the blocked recipient fails with `ErrTransferNotAllowed`; the recipient's bank balance is unchanged. - `TestRecipientGuard_InnerCallForwarder` — a hand-rolled value-forwarder runtime pre-deployed via `StateDB.SetCode` issues a value-bearing inner CALL to the blocked recipient. The outer tx mines with a successful receipt; the value stops at the forwarder (proving the outer transfer happened and the inner one rolled back). - `TestRecipientGuard_AllowedRecipient` — a value send to a freshly chosen non-blocked address still credits the recipient, pinning the negative side of the guard. #### `tests/system/contracts/RecipientGuard.sol`, `tests/system/deploy/14_deploy_recipient_guard.ts`, `tests/system/test/RecipientGuard.test.ts`: live-tx end-to-end coverage A single-method `Forwarder` contract issues a value-bearing CALL to its argument. The Hardhat suite pins three observable scenarios against the `poa` blocked address: - Inner CALL via `Forwarder` — `receipt.status === 1`, blocked recipient balance unchanged, value remains at the forwarder, sender debited by value + gas. A `staticCall` probe on the same method returns `ok === false`, pinning the inner CALL as the failing site without perturbing on-chain state. - EOA direct send to a blocked recipient — the receipt is a real object with non-zero `gasUsed` and `status === 0`; `eth_getTransactionByHash` resolves to a real tx whose `value` matches the input (the pre-fix shape returned `null`); the sender is debited by gas only (value is not transferred) and the sender's nonce is incremented; the blocked recipient's balance is unchanged. - Allowed recipient — a fresh random address still receives, regressing against an over-broad guard. The blocked recipient is derived in TypeScript by hashing the module name (`sha256(name)[:20]`, mirroring `authtypes.NewModuleAddress`). `poa` is preferred over `fee_collector` because the latter receives gas fees via `SendCoinsFromAccountToModule` in the ante handler, a path that bypasses `BlockedAddr` — so its balance drifts under any non-zero gas price and the strict "balance unchanged" assertion cannot hold on localnode. #### `tests/system/test/SimulateV1_MezoDivergence.test.ts`: simulate-path end-to-end coverage Two new contexts in the existing `SimulateV1_MezoDivergence` suite pin the recipient guard under `eth_simulateV1`. The guard is a mezod intentional deviation from geth's reference surface, so it lives in the same conceptual bucket as the existing per-call CanTransfer divergence: - Inner CALL via `Forwarder` simulated — the request returns blocks without a top-level error, the outer call reports `status === "0x1"`, and the decoded `returnData` `(bool ok)` is `false`. - Top-level value transfer to a blocked recipient with `validation` omitted — the request does not abort with a top-level fatal; the per-call result has `status === "0x0"` and `error.code === -32015` (`SimErrCodeVMError`, the non-revert VM-error code emitted by `NewSimVMError`). ### Testing `make test-unit` exercises the new `TestRecipientGuard_*` methods on the EVM keeper. The Hardhat suites under `tests/system/test/RecipientGuard.test.ts` and `tests/system/test/SimulateV1_MezoDivergence.test.ts` run against a localnode or testnet built from this branch. --- ### Author's checklist - [x] Provided the appropriate description of the pull request - [x] Updated relevant unit and integration tests - [x] Updated relevant documentation (`docs/`) or specification (`x/<module>/spec/`) - [x] Assigned myself in the `Assignees` field - [x] Assigned `mezod-developers` in the `Reviewers` field and notified them on Discord ### Reviewer's checklist - [x] Confirmed all author's checklist items have been addressed - [x] Considered security implications of the code changes - [x] Considered performance implications of the code changes - [ ] Tested the changes and summarized covered scenarios and results in a comment
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
References: MEZO-4351
Introduction
Upstream go-ethereum only checks whether the sender can spend (
CanTransfer); there is no symmetric hook to check whether the recipient is allowed to be credited. Chains whose host layer maintains a blocked-recipient set (e.g. mezod'sBlockedAddrson the bank module) cannot reject such transfers at the EVM frame boundary, so a value-bearingCALLto a blocked address runs through the frame and is only refused at commit time. Refusing the credit later — after the frame has already snapshotted and mutated state — produces a worse failure mode than failing fast: the revert path is the wrong shape (commit-time host error vs. clean frame revert), gas accounting is harder to reason about, and the EVM has already done work that ends up discarded.Changes
core/vm/evm.gointroduces a newCanReceiveTransferFunctype and an optionalCanReceiveTransferfield onBlockContext, mirroring the existingCanTransferFunc/CanTransferpair but with the address argument bound to the recipient rather than the sender. The guard is invoked inside(*EVM).Call, positioned immediately after the sender-sideCanTransfercheck and before theStateDB.Snapshot(), so a refusal aborts the frame before any state mutation occurs. On refusal the frame returns the new sentinelErrTransferNotAllowed(added tocore/vm/errors.go) with the full input gas, identical in shape to the existingErrInsufficientBalancereturn.The field is nil-safe: a nil
CanReceiveTransferdisables the check entirely, so consumers that do not populate it (including the defaultNewEVMBlockContextincore/evm.go, which is left unchanged) see no behavioral difference from upstream. Only chain integrators that wire a non-nil callback opt in. The check is also value-gated —!value.IsZero()short-circuits before the nil load on the dominant valueless-call path, keeping the hot path free of any new indirect call for valueless CALLs.Scope is deliberately confined to
Call.CallCode,DelegateCall, andStaticCallare unchanged:DelegateCallandStaticCallperform no value transfer, andCallCodecreditscaller(notaddr), so the recipient-guard semantics do not apply.Testing
go build ./core/...— clean.go test ./core/vm/— passes (3.979s); the existing core/vm test suite covers the CALL frame path and remains green with the additive change.The new guard is exercised end-to-end by the integrating chain (mezod), which wires its bank-module
BlockedAddrscheck into the callback; that integration is the load-bearing test for the recipient-side semantics and lives in the mezod repo.