Skip to content

Add CanReceiveTransfer recipient-side transfer guard - #10

Merged
tomaszslabon merged 1 commit into
masterfrom
mezo-4351-recipient-guard
May 14, 2026
Merged

tomaszslabon merged 1 commit into
masterfrom
mezo-4351-recipient-guard

Conversation

@lukasz-zimnoch

Copy link
Copy Markdown
Member

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's BlockedAddrs on the bank module) cannot reject such transfers at the EVM frame boundary, so a value-bearing CALL to 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.go introduces a new CanReceiveTransferFunc type and an optional CanReceiveTransfer field on BlockContext, mirroring the existing CanTransferFunc / CanTransfer pair 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-side CanTransfer check and before the StateDB.Snapshot(), so a refusal aborts the frame before any state mutation occurs. On refusal the frame returns the new sentinel ErrTransferNotAllowed (added to core/vm/errors.go) with the full input gas, identical in shape to the existing ErrInsufficientBalance return.

The field is nil-safe: a nil CanReceiveTransfer disables the check entirely, so consumers that do not populate it (including the default NewEVMBlockContext in core/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, and StaticCall are unchanged: DelegateCall and StaticCall perform no value transfer, and CallCode credits caller (not addr), 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 BlockedAddrs check into the callback; that integration is the load-bearing test for the recipient-side semantics and lives in the mezod repo.

@linear-code

linear-code Bot commented May 14, 2026

Copy link
Copy Markdown

MEZO-4351

@lukasz-zimnoch lukasz-zimnoch changed the title core/vm: add CanReceiveTransfer recipient-side transfer guard Add CanReceiveTransfer recipient-side transfer guard May 14, 2026
@lukasz-zimnoch lukasz-zimnoch self-assigned this May 14, 2026
@lukasz-zimnoch
lukasz-zimnoch force-pushed the mezo-4351-recipient-guard branch 2 times, most recently from 01d6199 to 9be4e8d Compare May 14, 2026 09:53
@lukasz-zimnoch
lukasz-zimnoch marked this pull request as ready for review May 14, 2026 09:54
Comment thread core/vm/errors.go
Comment thread core/vm/evm.go
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
lukasz-zimnoch force-pushed the mezo-4351-recipient-guard branch from 9be4e8d to ea47fa8 Compare May 14, 2026 10:32
@tomaszslabon
tomaszslabon merged commit 6e14d6e into master May 14, 2026
3 of 4 checks passed
@lukasz-zimnoch
lukasz-zimnoch deleted the mezo-4351-recipient-guard branch May 14, 2026 10:42
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
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants