diff --git a/CHANGELOG.md b/CHANGELOG.md index 0be370dd6..6407d42a9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 / diff --git a/precompiles/bank/bank.go b/precompiles/bank/bank.go index 376eb5357..82781cac6 100644 --- a/precompiles/bank/bank.go +++ b/precompiles/bank/bank.go @@ -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) }) diff --git a/precompiles/bank/bank_selector_test.go b/precompiles/bank/bank_selector_test.go new file mode 100644 index 000000000..95d8a21b0 --- /dev/null +++ b/precompiles/bank/bank_selector_test.go @@ -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) + } + }) + } +} diff --git a/precompiles/common/precompile.go b/precompiles/common/precompile.go index 1554fe1b3..81a0d061f 100644 --- a/precompiles/common/precompile.go +++ b/precompiles/common/precompile.go @@ -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. @@ -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 } diff --git a/precompiles/distribution/distribution.go b/precompiles/distribution/distribution.go index f363fa7c9..d6c0faf17 100644 --- a/precompiles/distribution/distribution.go +++ b/precompiles/distribution/distribution.go @@ -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) }) diff --git a/precompiles/distribution/distribution_selector_test.go b/precompiles/distribution/distribution_selector_test.go new file mode 100644 index 000000000..9383c75ea --- /dev/null +++ b/precompiles/distribution/distribution_selector_test.go @@ -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) + } + }) + } +} diff --git a/precompiles/erc20/erc20.go b/precompiles/erc20/erc20.go index 12121b7c4..3d90afcaf 100644 --- a/precompiles/erc20/erc20.go +++ b/precompiles/erc20/erc20.go @@ -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) }) diff --git a/precompiles/erc20/erc20_selector_test.go b/precompiles/erc20/erc20_selector_test.go new file mode 100644 index 000000000..87b546e34 --- /dev/null +++ b/precompiles/erc20/erc20_selector_test.go @@ -0,0 +1,94 @@ +package erc20_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/erc20" + + erc20types "github.com/cosmos/evm/x/erc20/types" +) + +// runPrecompile drives erc20.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 := erc20.NewPrecompile(erc20types.TokenPair{}, 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: erc20.ABI.Methods[erc20.NameMethod].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) + } + }) + } +} diff --git a/precompiles/gov/gov.go b/precompiles/gov/gov.go index baf84d22b..c157874e6 100644 --- a/precompiles/gov/gov.go +++ b/precompiles/gov/gov.go @@ -90,6 +90,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) }) diff --git a/precompiles/gov/gov_selector_test.go b/precompiles/gov/gov_selector_test.go new file mode 100644 index 000000000..70e7effcb --- /dev/null +++ b/precompiles/gov/gov_selector_test.go @@ -0,0 +1,92 @@ +package gov_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/gov" +) + +// runPrecompile drives gov.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 := gov.NewPrecompile(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: gov.ABI.Methods[gov.GetProposalMethod].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) + } + }) + } +} diff --git a/precompiles/ics20/ics20.go b/precompiles/ics20/ics20.go index b184fd674..59ff4f6f4 100644 --- a/precompiles/ics20/ics20.go +++ b/precompiles/ics20/ics20.go @@ -89,6 +89,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) }) diff --git a/precompiles/ics20/ics20_selector_test.go b/precompiles/ics20/ics20_selector_test.go new file mode 100644 index 000000000..0685671a4 --- /dev/null +++ b/precompiles/ics20/ics20_selector_test.go @@ -0,0 +1,92 @@ +package ics20_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/ics20" +) + +// runPrecompile drives ics20.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 := ics20.NewPrecompile(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: ics20.ABI.Methods[ics20.DenomsMethod].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) + } + }) + } +} diff --git a/precompiles/slashing/slashing.go b/precompiles/slashing/slashing.go index bc99e984e..47d2ad15c 100644 --- a/precompiles/slashing/slashing.go +++ b/precompiles/slashing/slashing.go @@ -90,6 +90,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) }) diff --git a/precompiles/slashing/slashing_selector_test.go b/precompiles/slashing/slashing_selector_test.go new file mode 100644 index 000000000..4bc530ca9 --- /dev/null +++ b/precompiles/slashing/slashing_selector_test.go @@ -0,0 +1,92 @@ +package slashing_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/slashing" +) + +// runPrecompile drives slashing.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 := slashing.NewPrecompile(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: slashing.ABI.Methods[slashing.GetParamsMethod].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) + } + }) + } +} diff --git a/precompiles/staking/staking.go b/precompiles/staking/staking.go index 9dd71a6a4..649954b0d 100644 --- a/precompiles/staking/staking.go +++ b/precompiles/staking/staking.go @@ -91,6 +91,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) }) diff --git a/precompiles/staking/staking_selector_test.go b/precompiles/staking/staking_selector_test.go new file mode 100644 index 000000000..9f1f5496f --- /dev/null +++ b/precompiles/staking/staking_selector_test.go @@ -0,0 +1,92 @@ +package staking_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/staking" +) + +// runPrecompile drives staking.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 := staking.NewPrecompile(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: staking.ABI.Methods[staking.DelegationMethod].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) + } + }) + } +} diff --git a/precompiles/werc20/werc20.go b/precompiles/werc20/werc20.go index b9e67623e..616788fcf 100644 --- a/precompiles/werc20/werc20.go +++ b/precompiles/werc20/werc20.go @@ -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) }) diff --git a/precompiles/werc20/werc20_selector_test.go b/precompiles/werc20/werc20_selector_test.go new file mode 100644 index 000000000..51fa8a0f9 --- /dev/null +++ b/precompiles/werc20/werc20_selector_test.go @@ -0,0 +1,91 @@ +package werc20_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/werc20" + erc20types "github.com/cosmos/evm/x/erc20/types" +) + +// runPrecompile drives werc20.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 := werc20.NewPrecompile(erc20types.TokenPair{}, nil, nil, nil).Run(evm, contract, false) + require.ErrorIs(t, err, vm.ErrExecutionReverted, "precompile errors are always surfaced as a revert") + + return string(bz) +} + +// TestRunPreservesFallbackAndReceive is the regression guard for F-2026-18786. +// +// WERC20 is the one affected precompile whose ABI declares a `fallback` and a +// `receive`, and it leans on all three of SetupABI's special cases: empty +// calldata, calldata shorter than a selector, and an unknown selector all +// resolve to fallback/receive and behave like `deposit`. A naive +// "len(input) < 4 || MethodById fails -> revert" guard at the top of Run would +// have broken every one of them, so the guard resolves the method through +// cmn.ResolveMethod - the same helper SetupABI itself uses - instead. +func TestRunPreservesFallbackAndReceive(t *testing.T) { + testCases := []struct { + name string + input []byte + value *uint256.Int + }{ + { + name: "empty calldata with value resolves to receive", + input: nil, + value: uint256.NewInt(1), + }, + { + name: "empty calldata without value resolves to fallback", + input: nil, + value: uint256.NewInt(0), + }, + { + name: "calldata shorter than a selector resolves to fallback", + input: []byte{0x01, 0x02, 0x03}, + value: uint256.NewInt(1), + }, + { + name: "unknown selector resolves to fallback", + input: []byte("nonExistingMethod"), + value: uint256.NewInt(1), + }, + { + name: "a valid selector is still dispatched", + input: werc20.ABI.Methods[werc20.DepositMethod].ID, + value: uint256.NewInt(1), + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + reason := runPrecompile(t, tc.input, tc.value) + + require.Contains(t, reason, cmn.ErrNotRunInEvm, + "WERC20 dispatches this calldata via fallback/receive, so it must still reach RunNativeAction") + require.NotContains(t, reason, "no method with id", + "the early selector guard must not reject calldata that fallback/receive would have handled") + }) + } +}