Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,15 @@

### BUG FIXES

- Resolve the ABI method at the top of every stateful precompile's `Run`, before `RunNativeAction`
is entered. The native-action preamble takes a cache context, snapshots the multi-store and
commits the state DB cache - replaying the caller's entire dirty account and storage set - and
only then does `SetupABI` get around to resolving the selector and failing. `RequiredGas` returns
0 for an unresolvable selector, so none of that work was charged: a contract could dirty a large
storage set and then hand `bank`, `gov`, `staking`, `distribution`, `ics20`, `slashing`, `erc20`
or `werc20` four random bytes to have the whole set replayed for free, up to 20 times per
transaction. The check goes through the new `cmn.ResolveMethod`, which `SetupABI` now also uses,
so the fallback/receive special cases WERC20 depends on keep working and the revert is unchanged.
- Make `ConvertCoinNativeERC20` atomic. It escrowed the sender's coins onto the erc20 module
account before calling the EVM, and `CallEVMWithData` caches only the EVM execution, so a VM
failure discarded the EVM cache while leaving the bank escrow committed. On the IBC error-ACK /
Expand Down
9 changes: 9 additions & 0 deletions precompiles/bank/bank.go
Original file line number Diff line number Diff line change
Expand Up @@ -107,6 +107,15 @@ func (p Precompile) RequiredGas(input []byte) uint64 {
}

func (p Precompile) Run(evm *vm.EVM, contract *vm.Contract, readonly bool) ([]byte, error) {
// Resolve the target method before RunNativeAction touches any state: the
// native-action preamble (cache context, multi-store snapshot, state DB
// commit) replays the caller's entire dirty set and is not paid for when the
// selector turns out to be unresolvable, because RequiredGas returns 0 for
// one. Resolving here keeps the revert identical while skipping that work.
if _, err := cmn.ResolveMethod(p.ABI, contract); err != nil {
return cmn.ReturnRevertError(evm, err)
}

return p.RunNativeAction(evm, contract, func(ctx sdk.Context) ([]byte, error) {
return p.Execute(ctx, contract, readonly)
})
Expand Down
92 changes: 92 additions & 0 deletions precompiles/bank/bank_selector_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
package bank_test

import (
"math/big"
"testing"

"github.com/ethereum/go-ethereum/common"
"github.com/ethereum/go-ethereum/core/vm"
"github.com/ethereum/go-ethereum/params"
"github.com/holiman/uint256"
"github.com/stretchr/testify/require"

"github.com/cosmos/evm/precompiles/bank"
cmn "github.com/cosmos/evm/precompiles/common"
)

// runPrecompile drives bank.Precompile.Run with a nil StateDB and returns the
// ABI-encoded revert reason.
//
// The early selector guard never looks at evm.StateDB, whereas the first thing
// RunNativeAction does is type-assert on it. A revert reason carrying
// cmn.ErrNotRunInEvm therefore proves the call reached the native-action
// preamble, and any other reason proves it was rejected before it - which is
// what lets these tests tell the two apart without standing up a full app.
func runPrecompile(t *testing.T, input []byte, value *uint256.Int) string {
t.Helper()

evm := vm.NewEVM(vm.BlockContext{BlockNumber: big.NewInt(1)}, nil, params.TestChainConfig, vm.Config{})
contract := vm.NewContract(common.Address{}, common.Address{}, value, 100_000, nil)
contract.Input = input

bz, err := bank.NewPrecompile(nil, nil).Run(evm, contract, false)
require.ErrorIs(t, err, vm.ErrExecutionReverted, "precompile errors are always surfaced as a revert")

return string(bz)
}

// TestRunResolvesSelectorBeforeNativeAction covers F-2026-18786: calldata that
// cannot be dispatched used to run the whole native-action preamble - cache
// context, multi-store snapshot and a state DB commit that replays the caller's
// entire dirty set - before SetupABI got around to failing on the selector, and
// RequiredGas returns 0 for an unresolvable selector so none of it was charged.
func TestRunResolvesSelectorBeforeNativeAction(t *testing.T) {
testCases := []struct {
name string
// input is the calldata handed to the precompile.
input []byte
// reachesNativeAction is true when the calldata is dispatchable and so
// must still be handed to RunNativeAction.
reachesNativeAction bool
// expReason, when set, must appear in the revert reason.
expReason string
}{
{
name: "unknown selector is rejected before the native action",
input: []byte{0xde, 0xad, 0xbe, 0xef},
expReason: "no method with id",
},
{
name: "calldata shorter than a selector is rejected before the native action",
input: []byte{0x01, 0x02, 0x03},
},
{
name: "empty calldata is rejected before the native action",
input: nil,
},
{
name: "a valid selector is still dispatched",
input: bank.ABI.Methods[bank.BalancesMethod].ID,
reachesNativeAction: true,
},
}

for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
reason := runPrecompile(t, tc.input, uint256.NewInt(0))

if tc.reachesNativeAction {
require.Contains(t, reason, cmn.ErrNotRunInEvm,
"a valid selector must still reach RunNativeAction")
return
}

require.NotContains(t, reason, cmn.ErrNotRunInEvm,
"undispatchable calldata must not reach the native-action preamble (F-2026-18786)")

if tc.expReason != "" {
require.Contains(t, reason, tc.expReason)
}
})
}
}
39 changes: 28 additions & 11 deletions precompiles/common/precompile.go
Original file line number Diff line number Diff line change
Expand Up @@ -125,14 +125,19 @@ func (p Precompile) runNativeAction(evm *vm.EVM, contract *vm.Contract, action N
return bz, nil
}

// SetupABI runs the initial setup required to run a transaction or a query.
// It returns the ABI method, initial gas and calling arguments.
func SetupABI(
api abi.ABI,
contract *vm.Contract,
readOnly bool,
isTransaction func(name *abi.Method) bool,
) (method *abi.Method, args []interface{}, err error) {
// ResolveMethod resolves the ABI method targeted by the contract calldata.
//
// It is the selector-resolution half of SetupABI, exported so that a concrete
// precompile can reject calldata it cannot dispatch at the very top of its Run,
// *before* RunNativeAction sets up the native context, snapshots the multi-store
// and commits the state DB cache. That preamble replays the caller's whole dirty
// set and is not charged for an unresolvable selector, since RequiredGas also
// returns 0 for one.
//
// SetupABI delegates to this function so the early check and the in-action check
// can never disagree - notably on the fallback/receive special cases, which the
// werc20 precompile relies on for empty, short and unknown calldata.
func ResolveMethod(api abi.ABI, contract *vm.Contract) (method *abi.Method, err error) {
// NOTE: This is a special case where the calling transaction does not specify a function name.
// In this case we default to a `fallback` or `receive` function on the contract.

Expand All @@ -144,17 +149,29 @@ func SetupABI(
switch {
// Case 1: Calldata is empty
case isEmptyCallData:
method, err = emptyCallData(api, contract)
return emptyCallData(api, contract)

// Case 2: calldata is non-empty but less than 4 bytes needed for a method
case isShortCallData:
method, err = methodIDCallData(api)
return methodIDCallData(api)

// Case 3: calldata is non-empty and contains the minimum 4 bytes needed for a method
case isStandardCallData:
method, err = standardCallData(api, contract)
return standardCallData(api, contract)
}

return nil, vm.ErrExecutionReverted
}

// SetupABI runs the initial setup required to run a transaction or a query.
// It returns the ABI method, initial gas and calling arguments.
func SetupABI(
api abi.ABI,
contract *vm.Contract,
readOnly bool,
isTransaction func(name *abi.Method) bool,
) (method *abi.Method, args []interface{}, err error) {
method, err = ResolveMethod(api, contract)
if err != nil {
return nil, nil, err
}
Expand Down
9 changes: 9 additions & 0 deletions precompiles/distribution/distribution.go
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,15 @@ func (p Precompile) RequiredGas(input []byte) uint64 {
}

func (p Precompile) Run(evm *vm.EVM, contract *vm.Contract, readonly bool) ([]byte, error) {
// Resolve the target method before RunNativeAction touches any state: the
// native-action preamble (cache context, multi-store snapshot, state DB
// commit) replays the caller's entire dirty set and is not paid for when the
// selector turns out to be unresolvable, because RequiredGas returns 0 for
// one. Resolving here keeps the revert identical while skipping that work.
if _, err := cmn.ResolveMethod(p.ABI, contract); err != nil {
return cmn.ReturnRevertError(evm, err)
}

return p.RunNativeAction(evm, contract, func(ctx sdk.Context) ([]byte, error) {
return p.Execute(ctx, evm.StateDB, contract, readonly)
})
Expand Down
92 changes: 92 additions & 0 deletions precompiles/distribution/distribution_selector_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
package distribution_test

import (
"math/big"
"testing"

"github.com/ethereum/go-ethereum/common"
"github.com/ethereum/go-ethereum/core/vm"
"github.com/ethereum/go-ethereum/params"
"github.com/holiman/uint256"
"github.com/stretchr/testify/require"

cmn "github.com/cosmos/evm/precompiles/common"
"github.com/cosmos/evm/precompiles/distribution"
)

// runPrecompile drives distribution.Precompile.Run with a nil StateDB and returns the
// ABI-encoded revert reason.
//
// The early selector guard never looks at evm.StateDB, whereas the first thing
// RunNativeAction does is type-assert on it. A revert reason carrying
// cmn.ErrNotRunInEvm therefore proves the call reached the native-action
// preamble, and any other reason proves it was rejected before it - which is
// what lets these tests tell the two apart without standing up a full app.
func runPrecompile(t *testing.T, input []byte, value *uint256.Int) string {
t.Helper()

evm := vm.NewEVM(vm.BlockContext{BlockNumber: big.NewInt(1)}, nil, params.TestChainConfig, vm.Config{})
contract := vm.NewContract(common.Address{}, common.Address{}, value, 100_000, nil)
contract.Input = input

bz, err := distribution.NewPrecompile(nil, nil, nil, nil, nil, nil).Run(evm, contract, false)
require.ErrorIs(t, err, vm.ErrExecutionReverted, "precompile errors are always surfaced as a revert")

return string(bz)
}

// TestRunResolvesSelectorBeforeNativeAction covers F-2026-18786: calldata that
// cannot be dispatched used to run the whole native-action preamble - cache
// context, multi-store snapshot and a state DB commit that replays the caller's
// entire dirty set - before SetupABI got around to failing on the selector, and
// RequiredGas returns 0 for an unresolvable selector so none of it was charged.
func TestRunResolvesSelectorBeforeNativeAction(t *testing.T) {
testCases := []struct {
name string
// input is the calldata handed to the precompile.
input []byte
// reachesNativeAction is true when the calldata is dispatchable and so
// must still be handed to RunNativeAction.
reachesNativeAction bool
// expReason, when set, must appear in the revert reason.
expReason string
}{
{
name: "unknown selector is rejected before the native action",
input: []byte{0xde, 0xad, 0xbe, 0xef},
expReason: "no method with id",
},
{
name: "calldata shorter than a selector is rejected before the native action",
input: []byte{0x01, 0x02, 0x03},
},
{
name: "empty calldata is rejected before the native action",
input: nil,
},
{
name: "a valid selector is still dispatched",
input: distribution.ABI.Methods[distribution.ValidatorCommissionMethod].ID,
reachesNativeAction: true,
},
}

for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
reason := runPrecompile(t, tc.input, uint256.NewInt(0))

if tc.reachesNativeAction {
require.Contains(t, reason, cmn.ErrNotRunInEvm,
"a valid selector must still reach RunNativeAction")
return
}

require.NotContains(t, reason, cmn.ErrNotRunInEvm,
"undispatchable calldata must not reach the native-action preamble (F-2026-18786)")

if tc.expReason != "" {
require.Contains(t, reason, tc.expReason)
}
})
}
}
9 changes: 9 additions & 0 deletions precompiles/erc20/erc20.go
Original file line number Diff line number Diff line change
Expand Up @@ -140,6 +140,15 @@ func (p Precompile) RequiredGas(input []byte) uint64 {
}

func (p Precompile) Run(evm *vm.EVM, contract *vm.Contract, readonly bool) ([]byte, error) {
// Resolve the target method before RunNativeAction touches any state: the
// native-action preamble (cache context, multi-store snapshot, state DB
// commit) replays the caller's entire dirty set and is not paid for when the
// selector turns out to be unresolvable, because RequiredGas returns 0 for
// one. Resolving here keeps the revert identical while skipping that work.
if _, err := cmn.ResolveMethod(p.ABI, contract); err != nil {
return cmn.ReturnRevertError(evm, err)
}

return p.RunNativeAction(evm, contract, func(ctx sdk.Context) ([]byte, error) {
return p.Execute(ctx, evm.StateDB, contract, readonly)
})
Expand Down
Loading
Loading