From 4b4bc001170f69bf82156733963ee2ec698afb5c Mon Sep 17 00:00:00 2001 From: Tony Chen Date: Fri, 10 Jul 2026 11:17:04 +0800 Subject: [PATCH 1/6] precompile: check supplied gas before decoding dynamic-gas calldata Dynamic-gas precompiles decoded the ABI argument payload in Prepare() before converting the supplied EVM gas into a Cosmos gas meter. The static precompile path checks that the supplied gas covers the work up front (vm.RunPrecompiledContract), but that check does not run on the dynamic-gas path. Align the two paths in RunAndCalculateGas: resolve the target method from the 4-byte selector, derive the Cosmos gas limit from the supplied gas, and require it to cover the calldata read cost before unpacking the arguments. Gas accounting for adequately funded calls is unchanged, so existing gas results for normal usage stay the same. Add a regression test and update the existing dynamic-gas precompile test to supply gas. Co-Authored-By: Claude Opus 4.8 --- precompiles/common/precompiles.go | 51 +++++++++++++++++++++----- precompiles/common/precompiles_test.go | 39 +++++++++++++++++++- 2 files changed, 79 insertions(+), 11 deletions(-) diff --git a/precompiles/common/precompiles.go b/precompiles/common/precompiles.go index 5dd5bce16d..f005906239 100644 --- a/precompiles/common/precompiles.go +++ b/precompiles/common/precompiles.go @@ -103,17 +103,12 @@ func (p Precompile) Prepare(evm *vm.EVM, input []byte) (sdk.Context, *abi.Method if ctxer == nil { return sdk.Context{}, nil, nil, errors.New("cannot get context from EVM") } - methodID, err := ExtractMethodID(input) - if err != nil { - return sdk.Context{}, nil, nil, err - } - method, err := p.MethodById(methodID) + method, err := p.resolveMethod(input) if err != nil { return sdk.Context{}, nil, nil, err } - argsBz := input[4:] - args, err := method.Inputs.Unpack(argsBz) + args, err := method.Inputs.Unpack(input[4:]) if err != nil { return sdk.Context{}, nil, nil, err } @@ -121,6 +116,18 @@ func (p Precompile) Prepare(evm *vm.EVM, input []byte) (sdk.Context, *abi.Method return ctxer.Ctx(), method, args, nil } +// resolveMethod extracts the 4-byte selector from input and resolves the ABI +// method WITHOUT decoding the (attacker-controlled) argument payload. Decoding +// is deliberately deferred so callers can perform it under a gas gate — see +// DynamicGasPrecompile.RunAndCalculateGas. +func (p Precompile) resolveMethod(input []byte) (*abi.Method, error) { + methodID, err := ExtractMethodID(input) + if err != nil { + return nil, err + } + return p.MethodById(methodID) +} + func (p Precompile) GetABI() abi.ABI { return p.ABI } @@ -162,13 +169,39 @@ func (d DynamicGasPrecompile) RunAndCalculateGas(evm *vm.EVM, caller common.Addr err = vm.ErrExecutionReverted } }() - ctx, method, args, err := d.Prepare(evm, input) + ctxer := state.GetDBImpl(evm.StateDB) + if ctxer == nil { + return nil, 0, errors.New("cannot get context from EVM") + } + // Resolve the target method from the 4-byte selector only. The + // argument payload is intentionally NOT decoded yet: ABI decoding of + // attacker-controlled calldata (e.g. a large dynamic array) can be + // expensive, so it must be gated by the gas the caller actually supplied. + // The static-precompile path enforces `suppliedGas < RequiredGas` in + // vm.RunPrecompiledContract before running; that gate is skipped for + // dynamic-gas precompiles, so we apply the equivalent check here before + // performing any decode/allocation work. + method, err := d.resolveMethod(input) if err != nil { return nil, 0, err } + operation = method.Name + + ctx := ctxer.Ctx() + // Convert the supplied EVM gas into a Cosmos gas budget and refuse to decode + // unless it can cover the cost of parsing the calldata. This bounds the + // decode/allocation work by the gas the caller paid, so a call that forwards + // little or no gas cannot force validators to decode large calldata for free. gasLimit := d.executor.EVMKeeper().GetCosmosGasLimitFromEVMGas(ctx.WithGasMeter(sdk.NewInfiniteGasMeterWithMultiplier(ctx)), suppliedGas) + if gasLimit < DefaultGasCost(input, false) { + return nil, 0, vm.ErrOutOfGas + } ctx = ctx.WithGasMeter(sdk.NewGasMeterWithMultiplier(ctx, gasLimit)) - operation = method.Name + + args, err := method.Inputs.Unpack(input[4:]) + if err != nil { + return nil, 0, err + } em := ctx.EventManager() ctx = ctx.WithEventManager(sdk.NewEventManager()) ctx = ctx.WithEVMPrecompileCalledFromDelegateCall(isFromDelegateCall) diff --git a/precompiles/common/precompiles_test.go b/precompiles/common/precompiles_test.go index 47696616e4..eda7f3d8ff 100644 --- a/precompiles/common/precompiles_test.go +++ b/precompiles/common/precompiles_test.go @@ -120,15 +120,50 @@ func TestDynamicGasPrecompileRun(t *testing.T) { require.Nil(t, err) precompile := common.NewDynamicGasPrecompile(newAbi, &MockDynamicGasPrecompileExecutor{throw: false, evmKeeper: k}, ethcommon.Address{}, "test") stateDB := state.NewDBImpl(ctx, k, false) - res, _, err := precompile.RunAndCalculateGas(&vm.EVM{StateDB: stateDB}, ethcommon.Address{}, ethcommon.Address{}, input, 0, big.NewInt(0), nil, false, false) + res, _, err := precompile.RunAndCalculateGas(&vm.EVM{StateDB: stateDB}, ethcommon.Address{}, ethcommon.Address{}, input, 100000, big.NewInt(0), nil, false, false) require.Equal(t, []byte("success"), res) require.Nil(t, err) require.NotEmpty(t, stateDB.Ctx().EventManager().Events()) stateDB.WithCtx(ctx.WithEventManager(sdk.NewEventManager())) precompile = common.NewDynamicGasPrecompile(newAbi, &MockDynamicGasPrecompileExecutor{throw: true, evmKeeper: k}, ethcommon.Address{}, "test") - res, _, err = precompile.RunAndCalculateGas(&vm.EVM{StateDB: stateDB}, ethcommon.Address{}, ethcommon.Address{}, input, 0, big.NewInt(0), nil, false, false) + res, _, err = precompile.RunAndCalculateGas(&vm.EVM{StateDB: stateDB}, ethcommon.Address{}, ethcommon.Address{}, input, 100000, big.NewInt(0), nil, false, false) require.Nil(t, res) require.NotNil(t, err) // should not emit any event require.Empty(t, stateDB.Ctx().EventManager().Events()) } + +// TestDynamicGasPrecompileGasGate is a regression test for a DoS in the dynamic +// precompile framework: the ABI decode of (attacker-controlled) calldata used to +// run before the supplied EVM gas was turned into a gas meter, so a call that +// forwarded ~zero gas could still force every validator to parse and allocate +// the calldata for free. The decode must now be gated by the supplied gas. +func TestDynamicGasPrecompileGasGate(t *testing.T) { + k := &testkeeper.EVMTestApp.EvmKeeper + ctx := testkeeper.EVMTestApp.GetContextForDeliverTx(nil) + abiBz, err := os.ReadFile("erc20_abi.json") + require.Nil(t, err) + newAbi, err := abi.JSON(bytes.NewReader(abiBz)) + require.Nil(t, err) + input, err := newAbi.Pack("decimals") + require.Nil(t, err) + + precompile := common.NewDynamicGasPrecompile(newAbi, &MockDynamicGasPrecompileExecutor{throw: false, evmKeeper: k}, ethcommon.Address{}, "test") + + // Zero supplied gas (the PoC scenario): the call must be rejected before the + // executor runs, so no event is emitted (Execute is what emits it) and no ABI + // decode is performed. + stateDB := state.NewDBImpl(ctx.WithEventManager(sdk.NewEventManager()), k, false) + res, remainingGas, err := precompile.RunAndCalculateGas(&vm.EVM{StateDB: stateDB}, ethcommon.Address{}, ethcommon.Address{}, input, 0, big.NewInt(0), nil, false, false) + require.Nil(t, res) + require.Equal(t, uint64(0), remainingGas) + require.Equal(t, vm.ErrExecutionReverted, err) + require.Empty(t, stateDB.Ctx().EventManager().Events()) + + // With enough gas the same call proceeds through decode and execution. + stateDB = state.NewDBImpl(ctx.WithEventManager(sdk.NewEventManager()), k, false) + res, _, err = precompile.RunAndCalculateGas(&vm.EVM{StateDB: stateDB}, ethcommon.Address{}, ethcommon.Address{}, input, 100000, big.NewInt(0), nil, false, false) + require.Equal(t, []byte("success"), res) + require.Nil(t, err) + require.NotEmpty(t, stateDB.Ctx().EventManager().Events()) +} From 15f02beae297b988b5a92ae04de2b0cc003eed7f Mon Sep 17 00:00:00 2001 From: Tony Chen Date: Tue, 14 Jul 2026 12:44:34 +0800 Subject: [PATCH 2/6] precompile: price dynamic-gas calldata decoding by supplied gas Charge for ABI-decoding a dynamic-gas precompile call before it runs. The decoder's cost is dominated by copying string payloads, and because one string can be referenced by many array/tuple slots the copied volume can grow faster than the input length. Charge ReadCostFlat + ReadCostPerByte*(len(input) + string-copy volume), where the string-copy volume is computed by a structural pre-scan that follows offsets and reads length prefixes without copying, so it stays linear in len(input). The charge is consumed from the supplied-gas meter before Unpack, so a call that cannot afford the decode is rejected before the parse/allocation work runs. Only the current precompile version is affected; historical replay uses the frozen legacy snapshots. Add unit tests for the cost estimator and update the affected precompile gas expectations. Co-Authored-By: Claude Opus 4.8 --- precompiles/bank/bank_test.go | 6 +- precompiles/common/decode_cost.go | 287 ++++++++++++++++++ precompiles/common/decode_cost_test.go | 142 +++++++++ precompiles/common/precompiles.go | 35 ++- precompiles/distribution/distribution_test.go | 8 +- precompiles/ibc/ibc_test.go | 6 +- precompiles/pointer/pointer_test.go | 2 +- 7 files changed, 460 insertions(+), 26 deletions(-) create mode 100644 precompiles/common/decode_cost.go create mode 100644 precompiles/common/decode_cost_test.go diff --git a/precompiles/bank/bank_test.go b/precompiles/bank/bank_test.go index a1cae321e8..4badb3c5da 100644 --- a/precompiles/bank/bank_test.go +++ b/precompiles/bank/bank_test.go @@ -193,9 +193,9 @@ func TestRun(t *testing.T) { sdk.NewAttribute(banktypes.AttributeKeySender, senderAddr.String()), ), // gas refund to the sender - banktypes.NewCoinReceivedEvent(senderAddr, sdk.NewCoins(sdk.NewCoin("usei", sdk.NewInt(132401)))), + banktypes.NewCoinReceivedEvent(senderAddr, sdk.NewCoins(sdk.NewCoin("usei", sdk.NewInt(130879)))), // tip is paid to the validator - banktypes.NewCoinReceivedEvent(sdk.MustAccAddressFromBech32("sei1v4mx6hmrda5kucnpwdjsqqqqqqqqqqqqlve8dv"), sdk.NewCoins(sdk.NewCoin("usei", sdk.NewInt(67599)))), + banktypes.NewCoinReceivedEvent(sdk.MustAccAddressFromBech32("sei1v4mx6hmrda5kucnpwdjsqqqqqqqqqqqqlve8dv"), sdk.NewCoins(sdk.NewCoin("usei", sdk.NewInt(69121)))), } require.EqualValues(t, expectedEvts.ToABCIEvents(), evts) @@ -244,7 +244,7 @@ func TestRun(t *testing.T) { Denom: "ufoo", }, bank.CoinBalance(parsedBalances[0])) require.Equal(t, bank.CoinBalance{ - Amount: big.NewInt(9932390), + Amount: big.NewInt(9930868), Denom: "usei", }, bank.CoinBalance(parsedBalances[1])) diff --git a/precompiles/common/decode_cost.go b/precompiles/common/decode_cost.go new file mode 100644 index 0000000000..2f4c145d10 --- /dev/null +++ b/precompiles/common/decode_cost.go @@ -0,0 +1,287 @@ +package common + +import ( + "encoding/binary" + "math" + "math/big" + + "github.com/ethereum/go-ethereum/accounts/abi" + + storetypes "github.com/sei-protocol/sei-chain/sei-cosmos/store/types" +) + +// maxDecodeWalkOps bounds how many type nodes decodeStringCopyBytes visits. For +// the argument shapes precompiles actually use (single-level dynamic arrays and +// flat tuples of leaves) the walk is linear in len(data); this cap is a +// defensive backstop so a hypothetical deeply-nested type cannot turn the cost +// estimate itself into a super-linear computation. Calldata large enough to hit +// it is infeasible under EVM calldata gas costs. +const maxDecodeWalkOps = 1 << 20 + +// DecodeGasCost returns the gas to charge for ABI-decoding a dynamic precompile +// call's calldata (the full input, including the 4-byte selector) given the +// method's argument list. +// +// The Go ABI decoder's cost is dominated by copying `string` payloads: it +// materializes each string via string(output[begin:end]), and because a single +// string can be referenced by many array/tuple slots, the copied volume can be +// super-linear in len(input) (worst case ~len(input)^2). `bytes` values are +// excluded because the decoder reslices them without copying. The charge is +// therefore a linear pass over the input plus the string-copy volume the +// decoder would produce, priced at the KV read-per-byte rate. +func DecodeGasCost(args abi.Arguments, input []byte) uint64 { + base := DefaultGasCost(input, false) + if len(input) < 4 { + return base + } + strBytes, ok := decodeStringCopyBytes(args, input[4:]) + if !ok { + // Structurally inconsistent, or too deeply nested to price cheaply. The + // real decoder rejects such input (doing bounded work up to the same + // point), so the linear base charge is sufficient. + return base + } + return satAdd(base, satMul(storetypes.KVGasConfig().ReadCostPerByte, strBytes)) +} + +// decodeStringCopyBytes returns the total number of string-payload bytes the ABI +// decoder would copy while unpacking data as args, mirroring go-ethereum's +// unpack traversal. It follows offsets and reads length prefixes but never +// copies payloads, so it runs in time proportional to the number of type nodes +// visited rather than the number of bytes referenced. ok is false when the +// input is structurally inconsistent or hits the traversal cap. +func decodeStringCopyBytes(args abi.Arguments, data []byte) (uint64, bool) { + w := &decodeWalker{} + index := 0 + virtualArgs := 0 + for _, arg := range args { + if arg.Indexed { + continue + } + if abiContainsString(arg.Type) { + w.walk(data, (index+virtualArgs)*32, arg.Type) + if w.failed { + return 0, false + } + } + if (arg.Type.T == abi.ArrayTy || arg.Type.T == abi.TupleTy) && !abiIsDynamic(arg.Type) { + virtualArgs += abiTypeSize(arg.Type)/32 - 1 + } + index++ + } + return w.strBytes, true +} + +type decodeWalker struct { + strBytes uint64 + ops int + failed bool +} + +// walk accounts for the string-copy bytes of a value of type t whose head word +// starts at index within output, mirroring go-ethereum abi.toGoType. Only types +// that can contain a string are ever passed in. +func (w *decodeWalker) walk(output []byte, index int, t abi.Type) { + if w.failed { + return + } + w.ops++ + if w.ops > maxDecodeWalkOps { + w.failed = true + return + } + if index < 0 || index+32 > len(output) { + w.failed = true + return + } + + switch t.T { + case abi.StringTy: + _, length, ok := abiLengthPrefix(index, output) + if !ok { + w.failed = true + return + } + w.strBytes += uint64(length) + case abi.SliceTy: + begin, length, ok := abiLengthPrefix(index, output) + if !ok { + w.failed = true + return + } + w.walkElems(output[begin:], length, *t.Elem) + case abi.ArrayTy: + if abiIsDynamic(*t.Elem) { + offset := int(binary.BigEndian.Uint64(output[index+24 : index+32])) + if offset < 0 || offset > len(output) { + w.failed = true + return + } + w.walkElems(output[offset:], t.Size, *t.Elem) + } else { + w.walkElems(output[index:], t.Size, *t.Elem) + } + case abi.TupleTy: + base := index + if abiIsDynamic(t) { + offset, ok := abiTuplePointsTo(index, output) + if !ok { + w.failed = true + return + } + base = offset + } + w.walkTuple(output[base:], t) + default: + // static scalar (int/uint/bool/address/fixed bytes/...): no string payload + } +} + +func (w *decodeWalker) walkElems(output []byte, size int, elem abi.Type) { + if w.failed { + return + } + if size < 0 { + w.failed = true + return + } + // Mirror forEachUnpack's bound (start + 32*size must fit); written as a + // division to avoid overflowing 32*size for an attacker-supplied size. + if size != 0 && size > len(output)/32 { + w.failed = true + return + } + if !abiContainsString(elem) { + return + } + elemSize := abiTypeSize(elem) + if elemSize <= 0 { + w.failed = true + return + } + for i, j := 0, 0; j < size; i, j = i+elemSize, j+1 { + w.walk(output, i, elem) + if w.failed { + return + } + } +} + +func (w *decodeWalker) walkTuple(output []byte, t abi.Type) { + virtualArgs := 0 + for i, elem := range t.TupleElems { + if abiContainsString(*elem) { + w.walk(output, (i+virtualArgs)*32, *elem) + if w.failed { + return + } + } + if (elem.T == abi.ArrayTy || elem.T == abi.TupleTy) && !abiIsDynamic(*elem) { + virtualArgs += abiTypeSize(*elem)/32 - 1 + } + } +} + +// abiContainsString reports whether decoding a value of type t can copy any +// string payload, i.e. whether the type is or transitively contains a string. +// Used to prune subtrees the walk does not need to descend into. +func abiContainsString(t abi.Type) bool { + switch t.T { + case abi.StringTy: + return true + case abi.SliceTy, abi.ArrayTy: + return t.Elem != nil && abiContainsString(*t.Elem) + case abi.TupleTy: + for _, elem := range t.TupleElems { + if abiContainsString(*elem) { + return true + } + } + return false + default: + return false + } +} + +// abiLengthPrefix mirrors go-ethereum abi.lengthPrefixPointsTo: it interprets +// the word at index as an offset, returns the start of the payload (offset+32) +// and the length read at that offset, with the same bounds checks. ok is false +// if the encoding points outside output. +func abiLengthPrefix(index int, output []byte) (start int, length int, ok bool) { + if index < 0 || index+32 > len(output) { + return 0, 0, false + } + bigOffsetEnd := new(big.Int).SetBytes(output[index : index+32]) + bigOffsetEnd.Add(bigOffsetEnd, big.NewInt(32)) + outputLength := big.NewInt(int64(len(output))) + if bigOffsetEnd.Cmp(outputLength) > 0 || bigOffsetEnd.BitLen() > 63 { + return 0, 0, false + } + offsetEnd := int(bigOffsetEnd.Uint64()) + lengthBig := new(big.Int).SetBytes(output[offsetEnd-32 : offsetEnd]) + totalSize := new(big.Int).Add(bigOffsetEnd, lengthBig) + if totalSize.BitLen() > 63 || totalSize.Cmp(outputLength) > 0 { + return 0, 0, false + } + return offsetEnd, int(lengthBig.Uint64()), true +} + +// abiTuplePointsTo mirrors go-ethereum abi.tuplePointsTo for dynamic tuples. +func abiTuplePointsTo(index int, output []byte) (start int, ok bool) { + if index < 0 || index+32 > len(output) { + return 0, false + } + offset := new(big.Int).SetBytes(output[index : index+32]) + if offset.Cmp(big.NewInt(int64(len(output)))) > 0 || offset.BitLen() > 63 { + return 0, false + } + return int(offset.Uint64()), true +} + +// abiIsDynamic mirrors go-ethereum abi.isDynamicType. +func abiIsDynamic(t abi.Type) bool { + if t.T == abi.TupleTy { + for _, elem := range t.TupleElems { + if abiIsDynamic(*elem) { + return true + } + } + return false + } + return t.T == abi.StringTy || t.T == abi.BytesTy || t.T == abi.SliceTy || (t.T == abi.ArrayTy && abiIsDynamic(*t.Elem)) +} + +// abiTypeSize mirrors go-ethereum abi.getTypeSize: the number of bytes a value +// of type t occupies in the head region of an encoding (32 for dynamic types). +func abiTypeSize(t abi.Type) int { + if t.T == abi.ArrayTy && !abiIsDynamic(*t.Elem) { + if t.Elem.T == abi.ArrayTy || t.Elem.T == abi.TupleTy { + return t.Size * abiTypeSize(*t.Elem) + } + return t.Size * 32 + } else if t.T == abi.TupleTy && !abiIsDynamic(t) { + total := 0 + for _, elem := range t.TupleElems { + total += abiTypeSize(*elem) + } + return total + } + return 32 +} + +func satMul(a, b uint64) uint64 { + if a == 0 || b == 0 { + return 0 + } + if a > math.MaxUint64/b { + return math.MaxUint64 + } + return a * b +} + +func satAdd(a, b uint64) uint64 { + if a > math.MaxUint64-b { + return math.MaxUint64 + } + return a + b +} diff --git a/precompiles/common/decode_cost_test.go b/precompiles/common/decode_cost_test.go new file mode 100644 index 0000000000..535072c04f --- /dev/null +++ b/precompiles/common/decode_cost_test.go @@ -0,0 +1,142 @@ +package common + +import ( + "encoding/binary" + "testing" + + "github.com/ethereum/go-ethereum/accounts/abi" + storetypes "github.com/sei-protocol/sei-chain/sei-cosmos/store/types" + "github.com/stretchr/testify/require" +) + +func mustABIType(t *testing.T, sol string, components []abi.ArgumentMarshaling) abi.Type { + t.Helper() + typ, err := abi.NewType(sol, "", components) + require.NoError(t, err) + return typ +} + +func abiWord(v uint64) []byte { + b := make([]byte, 32) + binary.BigEndian.PutUint64(b[24:], v) + return b +} + +func TestDecodeStringCopyBytes_Honest(t *testing.T) { + // A single string contributes its own length. + strArgs := abi.Arguments{{Type: mustABIType(t, "string", nil)}} + data, err := strArgs.Pack("hello") + require.NoError(t, err) + n, ok := decodeStringCopyBytes(strArgs, data) + require.True(t, ok) + require.Equal(t, uint64(len("hello")), n) + + // bytes are resliced by the decoder, not copied, so they cost nothing here. + bytesArgs := abi.Arguments{{Type: mustABIType(t, "bytes", nil)}} + data, err = bytesArgs.Pack([]byte("a reasonably long bytes payload")) + require.NoError(t, err) + n, ok = decodeStringCopyBytes(bytesArgs, data) + require.True(t, ok) + require.Equal(t, uint64(0), n) + + // string[] sums the lengths of its elements. + arrArgs := abi.Arguments{{Type: mustABIType(t, "string[]", nil)}} + data, err = arrArgs.Pack([]string{"aa", "bbb", "c"}) + require.NoError(t, err) + n, ok = decodeStringCopyBytes(arrArgs, data) + require.True(t, ok) + require.Equal(t, uint64(len("aa")+len("bbb")+len("c")), n) +} + +func TestDecodeStringCopyBytes_Tuple(t *testing.T) { + // Mirrors wasmd execute_batch((string,bytes,bytes)[]): only the string field + // of each tuple is copied; the bytes fields are resliced. + tupleArrArgs := abi.Arguments{{Type: mustABIType(t, "tuple[]", []abi.ArgumentMarshaling{ + {Name: "contractAddress", Type: "string"}, + {Name: "msg", Type: "bytes"}, + {Name: "coins", Type: "bytes"}, + })}} + type execMsg struct { + ContractAddress string `abi:"contractAddress"` + Msg []byte `abi:"msg"` + Coins []byte `abi:"coins"` + } + data, err := tupleArrArgs.Pack([]execMsg{ + {ContractAddress: "contract-one", Msg: []byte("ignored"), Coins: []byte("ignored too")}, + {ContractAddress: "two", Msg: []byte("x"), Coins: []byte("y")}, + }) + require.NoError(t, err) + n, ok := decodeStringCopyBytes(tupleArrArgs, data) + require.True(t, ok) + require.Equal(t, uint64(len("contract-one")+len("two")), n) +} + +// TestDecodeStringCopyBytes_Aliased is the core case: an attacker-crafted +// string[] whose K element offsets all point at the same S-byte string. The +// decoder copies K*S bytes even though the input is only ~(32*K + S) bytes, so +// the copied volume is super-linear in len(input). The estimator must report the +// full K*S so the caller is charged for the real work — while itself running in +// O(K), not O(K*S). +func TestDecodeStringCopyBytes_Aliased(t *testing.T) { + const ( + k = uint64(4) + s = uint64(64) + ) + // sub = element data region (what the decoder addresses relative to it): + // [k head words][length word = s][s bytes of payload] + headTarget := 32 * k // offset within sub of the shared length word + sub := make([]byte, 0, headTarget+32+s) + for range k { + sub = append(sub, abiWord(headTarget)...) // every element points to the same string + } + sub = append(sub, abiWord(s)...) + sub = append(sub, make([]byte, s)...) + + // data = [offset to array data = 32][array length = k][sub] + data := append(abiWord(32), abiWord(k)...) + data = append(data, sub...) + + arrArgs := abi.Arguments{{Type: mustABIType(t, "string[]", nil)}} + + // The estimator reports the full aliased copy volume. + n, ok := decodeStringCopyBytes(arrArgs, data) + require.True(t, ok) + require.Equal(t, k*s, n) + + // Sanity check against the real decoder: it accepts the encoding and + // materializes k strings of length s each (i.e. it really does copy k*s). + vals, err := arrArgs.Unpack(data) + require.NoError(t, err) + strs := vals[0].([]string) + require.Len(t, strs, int(k)) + for _, str := range strs { + require.Equal(t, int(s), len(str)) + } +} + +func TestDecodeStringCopyBytes_Malformed(t *testing.T) { + // A string[] header claiming an offset past the end of the buffer must not + // be scanned as if valid. + arrArgs := abi.Arguments{{Type: mustABIType(t, "string[]", nil)}} + data := append(abiWord(64), abiWord(1)...) // offset 64 into a 64-byte buffer + _, ok := decodeStringCopyBytes(arrArgs, data) + require.False(t, ok) +} + +func TestDecodeGasCost(t *testing.T) { + perByte := storetypes.KVGasConfig().ReadCostPerByte + + // No-arg selector: just the linear base. + noArgs := abi.Arguments{} + input := []byte{0x01, 0x02, 0x03, 0x04} + require.Equal(t, DefaultGasCost(input, false), DecodeGasCost(noArgs, input)) + + // string arg: base over the whole input plus the string-copy volume. + strArgs := abi.Arguments{{Type: mustABIType(t, "string", nil)}} + packed, err := strArgs.Pack("hello world") + require.NoError(t, err) + input = append([]byte{0xaa, 0xbb, 0xcc, 0xdd}, packed...) + want := satAdd(DefaultGasCost(input, false), satMul(perByte, uint64(len("hello world")))) + require.Equal(t, want, DecodeGasCost(strArgs, input)) + require.Greater(t, DecodeGasCost(strArgs, input), DefaultGasCost(input, false)) +} diff --git a/precompiles/common/precompiles.go b/precompiles/common/precompiles.go index f005906239..eeea304906 100644 --- a/precompiles/common/precompiles.go +++ b/precompiles/common/precompiles.go @@ -163,6 +163,13 @@ func NewDynamicGasPrecompile(a abi.ABI, executor DynamicGasPrecompileExecutor, a func (d DynamicGasPrecompile) RunAndCalculateGas(evm *vm.EVM, caller common.Address, callingContract common.Address, input []byte, suppliedGas uint64, value *big.Int, hooks *tracing.Hooks, readOnly bool, isFromDelegateCall bool) (ret []byte, remainingGas uint64, err error) { operation := fmt.Sprintf("%s_unknown", d.name) defer func() { + // Turn any panic — most importantly an out-of-gas panic raised while + // metering the calldata decode below — into a reverted precompile call, + // mirroring how the individual executor methods recover. This keeps a + // single precompile call's failure from aborting the enclosing EVM frame. + if r := recover(); r != nil { + err = fmt.Errorf("%v", r) + } HandlePrecompileError(err, evm, operation) if err != nil { fmt.Printf("precompile %s encountered error: %v\n", d.name, err) @@ -173,14 +180,13 @@ func (d DynamicGasPrecompile) RunAndCalculateGas(evm *vm.EVM, caller common.Addr if ctxer == nil { return nil, 0, errors.New("cannot get context from EVM") } - // Resolve the target method from the 4-byte selector only. The - // argument payload is intentionally NOT decoded yet: ABI decoding of - // attacker-controlled calldata (e.g. a large dynamic array) can be - // expensive, so it must be gated by the gas the caller actually supplied. - // The static-precompile path enforces `suppliedGas < RequiredGas` in - // vm.RunPrecompiledContract before running; that gate is skipped for - // dynamic-gas precompiles, so we apply the equivalent check here before - // performing any decode/allocation work. + // Resolve the target method from the 4-byte selector only. The argument + // payload is intentionally NOT decoded yet: ABI decoding of attacker- + // controlled calldata can cost far more than len(input) (a single string can + // be referenced by many array/tuple slots), so it must be paid for out of the + // gas the caller supplied. The static-precompile path charges RequiredGas in + // vm.RunPrecompiledContract before running; that step is skipped for + // dynamic-gas precompiles, so we apply the equivalent charge here. method, err := d.resolveMethod(input) if err != nil { return nil, 0, err @@ -188,15 +194,14 @@ func (d DynamicGasPrecompile) RunAndCalculateGas(evm *vm.EVM, caller common.Addr operation = method.Name ctx := ctxer.Ctx() - // Convert the supplied EVM gas into a Cosmos gas budget and refuse to decode - // unless it can cover the cost of parsing the calldata. This bounds the - // decode/allocation work by the gas the caller paid, so a call that forwards - // little or no gas cannot force validators to decode large calldata for free. + // Install the gas meter derived from the supplied EVM gas, then charge for + // decoding the calldata BEFORE decoding it. A call that cannot afford the + // decode is rejected here, before the (potentially super-linear) parse and + // allocation work is performed. ConsumeGas panics on out-of-gas; the deferred + // recover above turns that into a normal reverted precompile call. gasLimit := d.executor.EVMKeeper().GetCosmosGasLimitFromEVMGas(ctx.WithGasMeter(sdk.NewInfiniteGasMeterWithMultiplier(ctx)), suppliedGas) - if gasLimit < DefaultGasCost(input, false) { - return nil, 0, vm.ErrOutOfGas - } ctx = ctx.WithGasMeter(sdk.NewGasMeterWithMultiplier(ctx, gasLimit)) + ctx.GasMeter().ConsumeGas(DecodeGasCost(method.Inputs, input), fmt.Sprintf("%s precompile calldata decode", d.name)) args, err := method.Inputs.Unpack(input[4:]) if err != nil { diff --git a/precompiles/distribution/distribution_test.go b/precompiles/distribution/distribution_test.go index 1c7cff7930..0edc9c5612 100644 --- a/precompiles/distribution/distribution_test.go +++ b/precompiles/distribution/distribution_test.go @@ -155,7 +155,7 @@ func TestWithdraw(t *testing.T) { res, err = msgServer.EVMTransaction(sdk.WrapSDKContext(ctx), req) require.Nil(t, err) require.Empty(t, res.VmError) - require.Equal(t, uint64(64124), res.GasUsed) + require.Equal(t, uint64(65667), res.GasUsed) // reinitialized d, found = testApp.StakingKeeper.GetDelegation(ctx, seiAddr, val) @@ -315,7 +315,7 @@ func setWithdrawAddressAndWithdraw( res, err = msgServer.EVMTransaction(sdk.WrapSDKContext(ctx), r) require.Nil(t, err) require.Empty(t, res.VmError) - require.Equal(t, uint64(148290), res.GasUsed) + require.Equal(t, uint64(151087), res.GasUsed) receipt, err := k.GetTransientReceipt(ctx, tx.Hash(), 0) require.Nil(t, err) @@ -1106,7 +1106,7 @@ func TestPrecompile_RunAndCalculateGas_Rewards(t *testing.T) { suppliedGas: uint64(1000000), }, wantRet: emptyCasePackedOutput, - wantRemainingGas: 998877, + wantRemainingGas: 997769, wantErr: false, }, { @@ -1122,7 +1122,7 @@ func TestPrecompile_RunAndCalculateGas_Rewards(t *testing.T) { suppliedGas: uint64(1000000), }, wantRet: happyPathPackedOutput, - wantRemainingGas: 998877, + wantRemainingGas: 997769, wantErr: false, }, } diff --git a/precompiles/ibc/ibc_test.go b/precompiles/ibc/ibc_test.go index 45f43bb80b..25136a2c9e 100644 --- a/precompiles/ibc/ibc_test.go +++ b/precompiles/ibc/ibc_test.go @@ -147,7 +147,7 @@ func TestPrecompile_Run(t *testing.T) { fields: fields{transferKeeper: &MockTransferKeeper{}}, args: commonArgs, wantBz: packedTrue, - wantRemainingGas: 998877, + wantRemainingGas: 995840, wantErr: false, }, { @@ -284,7 +284,7 @@ func TestPrecompile_Run(t *testing.T) { value: nil, }, wantBz: packedTrue, - wantRemainingGas: 998877, + wantRemainingGas: 995720, wantErr: false, }, { @@ -304,7 +304,7 @@ func TestPrecompile_Run(t *testing.T) { value: nil, }, wantBz: packedTrue, - wantRemainingGas: 998877, + wantRemainingGas: 995843, wantErr: false, }, } diff --git a/precompiles/pointer/pointer_test.go b/precompiles/pointer/pointer_test.go index b9c57b3261..6959acb556 100644 --- a/precompiles/pointer/pointer_test.go +++ b/precompiles/pointer/pointer_test.go @@ -56,7 +56,7 @@ func TestAddNative(t *testing.T) { evm = vm.NewEVM(*blockCtx, statedb, cfg, vm.Config{}, testApp.EvmKeeper.CustomPrecompiles(ctx)) ret, g, err := p.RunAndCalculateGas(evm, caller, caller, append(p.GetExecutor().(*pointer.PrecompileExecutor).AddNativePointerID, args...), suppliedGas, nil, nil, false, false) require.Nil(t, err) - require.Equal(t, uint64(8887314), g) + require.Equal(t, uint64(8886002), g) outputs, err := m.Outputs.Unpack(ret) require.Nil(t, err) addr := outputs[0].(common.Address) From 68e4beffbd464bf79ad78d270b3f4cb1a55f5cad Mon Sep 17 00:00:00 2001 From: Tony Chen Date: Tue, 14 Jul 2026 13:12:59 +0800 Subject: [PATCH 3/6] precompile: satisfy gosec on decode-cost conversions; refresh replay gas fixtures Annotate the bounds-checked int/uint64 conversions in decode_cost.go with //nolint:gosec (each value is range-checked immediately before the conversion). Refresh the evmrpc/tests replay gas fixtures that shift under the decode charge. The mock replay harness has no upgrade fork history, so precompile version selection falls back to the latest (current) version; these traces therefore exercise the current precompile and reflect its new gas. Production tracer replay resolves to the frozen legacy snapshots by upgrade height and is unaffected. Co-Authored-By: Claude Opus 4.8 --- evmrpc/tests/regression_test.go | 16 ++++++++-------- precompiles/common/decode_cost.go | 10 +++++----- 2 files changed, 13 insertions(+), 13 deletions(-) diff --git a/evmrpc/tests/regression_test.go b/evmrpc/tests/regression_test.go index 19088e80e7..d8f1a65d6f 100644 --- a/evmrpc/tests/regression_test.go +++ b/evmrpc/tests/regression_test.go @@ -153,7 +153,7 @@ func Test0x362af85584493ed225bf592606c989796ba5f0484f5c80b989b8573f85517ec1(t *t testTx(t, "0x362af85584493ed225bf592606c989796ba5f0484f5c80b989b8573f85517ec1", "v6.0.3", - "0x8214a", + "0x82932", "0x7fd13972", true, ) @@ -311,7 +311,7 @@ func Test0xfdd190eac0ec35a8d52149d1b056ab07221fae6418b1592d12004e773adc83f6(t *t testTx(t, "0xfdd190eac0ec35a8d52149d1b056ab07221fae6418b1592d12004e773adc83f6", "v6.0.0", - "0xba5f", + "0xc051", "0x0000000000000000000000000000000000000000000000000000000000000001", false, ) @@ -321,7 +321,7 @@ func Test0xd30881600c458683d6140ac1b52a6f626f56481b1e157b6b9516049e8bd9a99a(t *t testTx(t, "0xd30881600c458683d6140ac1b52a6f626f56481b1e157b6b9516049e8bd9a99a", "v6.0.1", - "0x345a5", + "0x35173", "0x0000000000000000000000000000000000000000000000000000000000000020000000000000000000000000000000000000000000000000000000000000000100000000000000000000000000000000000000000000000000000000000000200000000000000000000000000000000000000000000000000000000000000000", false, ) @@ -331,7 +331,7 @@ func Test0x1b9ceaabadfc635aa8eb5e6d4a66ee60c826980805fa93af3913872f7b565586(t *t testTx(t, "0x1b9ceaabadfc635aa8eb5e6d4a66ee60c826980805fa93af3913872f7b565586", "v6.0.1", - "0xb8d9", + "0xbecb", "0x0000000000000000000000000000000000000000000000000000000000000001", false, ) @@ -341,7 +341,7 @@ func Test0x29b9a9764a27ba65139ccf06ef51e18358a63dfe143bfa6a16a891127cfc8ab6(t *t testTx(t, "0x29b9a9764a27ba65139ccf06ef51e18358a63dfe143bfa6a16a891127cfc8ab6", "v6.0.2", - "0x91fcd", + "0x9289b", "0x00000000000000000000000000000000000000000000000000000000000000200000000000000000000000000000000000000000000000000000000000000000", false, ) @@ -351,7 +351,7 @@ func Test0xb8bce587144c674e7c12cbfc69266be7436dbbfdf6d7ac186582c0735954cc37(t *t testTx(t, "0xb8bce587144c674e7c12cbfc69266be7436dbbfdf6d7ac186582c0735954cc37", "v6.0.2", - "0xb88e", + "0xbe80", "0x0000000000000000000000000000000000000000000000000000000000000001", false, ) @@ -361,7 +361,7 @@ func Test0x534a7d8dac27858aff75623792fee542dd7cec2539c2ddac92749e85bdc08e84(t *t testTx(t, "0x534a7d8dac27858aff75623792fee542dd7cec2539c2ddac92749e85bdc08e84", "v6.0.3", - "0x9180a", + "0x920d8", "0x00000000000000000000000000000000000000000000000000000000000000200000000000000000000000000000000000000000000000000000000000000000", false, ) @@ -371,7 +371,7 @@ func Test0x2cbbf34f930076000024953b87da7dc119a04f71fc4734a4bfabbe60558a49c6(t *t testTx(t, "0x2cbbf34f930076000024953b87da7dc119a04f71fc4734a4bfabbe60558a49c6", "v6.0.3", - "0xba5f", + "0xc051", "0x0000000000000000000000000000000000000000000000000000000000000001", false, ) diff --git a/precompiles/common/decode_cost.go b/precompiles/common/decode_cost.go index 2f4c145d10..7acaf8f478 100644 --- a/precompiles/common/decode_cost.go +++ b/precompiles/common/decode_cost.go @@ -102,7 +102,7 @@ func (w *decodeWalker) walk(output []byte, index int, t abi.Type) { w.failed = true return } - w.strBytes += uint64(length) + w.strBytes += uint64(length) //nolint:gosec // length is a non-negative ABI length bounded by len(output) in abiLengthPrefix case abi.SliceTy: begin, length, ok := abiLengthPrefix(index, output) if !ok { @@ -112,7 +112,7 @@ func (w *decodeWalker) walk(output []byte, index int, t abi.Type) { w.walkElems(output[begin:], length, *t.Elem) case abi.ArrayTy: if abiIsDynamic(*t.Elem) { - offset := int(binary.BigEndian.Uint64(output[index+24 : index+32])) + offset := int(binary.BigEndian.Uint64(output[index+24 : index+32])) //nolint:gosec // a wrapped-negative value is rejected by the offset<0 check below if offset < 0 || offset > len(output) { w.failed = true return @@ -217,13 +217,13 @@ func abiLengthPrefix(index int, output []byte) (start int, length int, ok bool) if bigOffsetEnd.Cmp(outputLength) > 0 || bigOffsetEnd.BitLen() > 63 { return 0, 0, false } - offsetEnd := int(bigOffsetEnd.Uint64()) + offsetEnd := int(bigOffsetEnd.Uint64()) //nolint:gosec // bigOffsetEnd fits int64 (BitLen<=63 checked above) lengthBig := new(big.Int).SetBytes(output[offsetEnd-32 : offsetEnd]) totalSize := new(big.Int).Add(bigOffsetEnd, lengthBig) if totalSize.BitLen() > 63 || totalSize.Cmp(outputLength) > 0 { return 0, 0, false } - return offsetEnd, int(lengthBig.Uint64()), true + return offsetEnd, int(lengthBig.Uint64()), true //nolint:gosec // lengthBig <= totalSize, which is BitLen<=63 checked above } // abiTuplePointsTo mirrors go-ethereum abi.tuplePointsTo for dynamic tuples. @@ -235,7 +235,7 @@ func abiTuplePointsTo(index int, output []byte) (start int, ok bool) { if offset.Cmp(big.NewInt(int64(len(output)))) > 0 || offset.BitLen() > 63 { return 0, false } - return int(offset.Uint64()), true + return int(offset.Uint64()), true //nolint:gosec // offset fits int64 (BitLen<=63 checked above) } // abiIsDynamic mirrors go-ethereum abi.isDynamicType. From 60f9c77d407ffca16575070f82859f5e01a066a0 Mon Sep 17 00:00:00 2001 From: Tony Chen Date: Tue, 14 Jul 2026 17:44:49 +0800 Subject: [PATCH 4/6] precompile: gate decode scan by input length; reject unparseable calldata early Charge a length-proportional amount before running the calldata scan, so the scan is itself bounded by the supplied gas and can never run for free even if it later becomes costlier; the remaining (string-copy) portion is charged after, so the total is unchanged. DecodeGasCost now reports ok=false for structurally invalid calldata, and the caller rejects it before Unpack rather than charging a fallback and letting Unpack fail. Co-Authored-By: Claude Opus 4.8 --- precompiles/common/decode_cost.go | 19 +++++++++---------- precompiles/common/decode_cost_test.go | 16 +++++++++++++--- precompiles/common/precompiles.go | 22 ++++++++++++++++++---- 3 files changed, 40 insertions(+), 17 deletions(-) diff --git a/precompiles/common/decode_cost.go b/precompiles/common/decode_cost.go index 7acaf8f478..4672bb54e7 100644 --- a/precompiles/common/decode_cost.go +++ b/precompiles/common/decode_cost.go @@ -20,28 +20,27 @@ const maxDecodeWalkOps = 1 << 20 // DecodeGasCost returns the gas to charge for ABI-decoding a dynamic precompile // call's calldata (the full input, including the 4-byte selector) given the -// method's argument list. +// method's argument list. ok is false when the calldata is structurally invalid +// (or too deeply nested to price cheaply); the caller should reject such input +// rather than decode it, since the real decoder would reject it too. // // The Go ABI decoder's cost is dominated by copying `string` payloads: it // materializes each string via string(output[begin:end]), and because a single // string can be referenced by many array/tuple slots, the copied volume can be // super-linear in len(input) (worst case ~len(input)^2). `bytes` values are // excluded because the decoder reslices them without copying. The charge is -// therefore a linear pass over the input plus the string-copy volume the -// decoder would produce, priced at the KV read-per-byte rate. -func DecodeGasCost(args abi.Arguments, input []byte) uint64 { +// therefore a linear pass over the input (DefaultGasCost) plus the string-copy +// volume the decoder would produce, priced at the KV read-per-byte rate. +func DecodeGasCost(args abi.Arguments, input []byte) (uint64, bool) { base := DefaultGasCost(input, false) if len(input) < 4 { - return base + return base, true } strBytes, ok := decodeStringCopyBytes(args, input[4:]) if !ok { - // Structurally inconsistent, or too deeply nested to price cheaply. The - // real decoder rejects such input (doing bounded work up to the same - // point), so the linear base charge is sufficient. - return base + return 0, false } - return satAdd(base, satMul(storetypes.KVGasConfig().ReadCostPerByte, strBytes)) + return satAdd(base, satMul(storetypes.KVGasConfig().ReadCostPerByte, strBytes)), true } // decodeStringCopyBytes returns the total number of string-payload bytes the ABI diff --git a/precompiles/common/decode_cost_test.go b/precompiles/common/decode_cost_test.go index 535072c04f..ca413c7703 100644 --- a/precompiles/common/decode_cost_test.go +++ b/precompiles/common/decode_cost_test.go @@ -129,7 +129,9 @@ func TestDecodeGasCost(t *testing.T) { // No-arg selector: just the linear base. noArgs := abi.Arguments{} input := []byte{0x01, 0x02, 0x03, 0x04} - require.Equal(t, DefaultGasCost(input, false), DecodeGasCost(noArgs, input)) + gas, ok := DecodeGasCost(noArgs, input) + require.True(t, ok) + require.Equal(t, DefaultGasCost(input, false), gas) // string arg: base over the whole input plus the string-copy volume. strArgs := abi.Arguments{{Type: mustABIType(t, "string", nil)}} @@ -137,6 +139,14 @@ func TestDecodeGasCost(t *testing.T) { require.NoError(t, err) input = append([]byte{0xaa, 0xbb, 0xcc, 0xdd}, packed...) want := satAdd(DefaultGasCost(input, false), satMul(perByte, uint64(len("hello world")))) - require.Equal(t, want, DecodeGasCost(strArgs, input)) - require.Greater(t, DecodeGasCost(strArgs, input), DefaultGasCost(input, false)) + gas, ok = DecodeGasCost(strArgs, input) + require.True(t, ok) + require.Equal(t, want, gas) + require.Greater(t, gas, DefaultGasCost(input, false)) + + // Structurally invalid calldata: reported as not-ok so the caller rejects it. + arrArgs := abi.Arguments{{Type: mustABIType(t, "string[]", nil)}} + bad := append([]byte{0xaa, 0xbb, 0xcc, 0xdd}, append(abiWord(64), abiWord(1)...)...) + _, ok = DecodeGasCost(arrArgs, bad) + require.False(t, ok) } diff --git a/precompiles/common/precompiles.go b/precompiles/common/precompiles.go index eeea304906..3b26b58238 100644 --- a/precompiles/common/precompiles.go +++ b/precompiles/common/precompiles.go @@ -196,12 +196,26 @@ func (d DynamicGasPrecompile) RunAndCalculateGas(evm *vm.EVM, caller common.Addr ctx := ctxer.Ctx() // Install the gas meter derived from the supplied EVM gas, then charge for // decoding the calldata BEFORE decoding it. A call that cannot afford the - // decode is rejected here, before the (potentially super-linear) parse and - // allocation work is performed. ConsumeGas panics on out-of-gas; the deferred - // recover above turns that into a normal reverted precompile call. + // decode is rejected here, before the parse/allocation work is performed. + // ConsumeGas panics on out-of-gas; the deferred recover above turns that into + // a normal reverted precompile call. gasLimit := d.executor.EVMKeeper().GetCosmosGasLimitFromEVMGas(ctx.WithGasMeter(sdk.NewInfiniteGasMeterWithMultiplier(ctx)), suppliedGas) ctx = ctx.WithGasMeter(sdk.NewGasMeterWithMultiplier(ctx, gasLimit)) - ctx.GasMeter().ConsumeGas(DecodeGasCost(method.Inputs, input), fmt.Sprintf("%s precompile calldata decode", d.name)) + + // Charge a length-proportional amount up front, before the structural scan + // inside DecodeGasCost runs, so the scan itself is bounded by the supplied gas + // and can never be performed for free even if it later becomes costlier. + scanCost := DefaultGasCost(input, false) + ctx.GasMeter().ConsumeGas(scanCost, fmt.Sprintf("%s precompile calldata scan", d.name)) + decodeCost, ok := DecodeGasCost(method.Inputs, input) + if !ok { + // Calldata is structurally invalid (Unpack would reject it too); reject + // now, without attempting the decode. + return nil, 0, fmt.Errorf("invalid calldata encoding for %s", d.name) + } + // DecodeGasCost already includes scanCost; charge only the remaining + // (string-copy) portion so the decode is priced exactly once. + ctx.GasMeter().ConsumeGas(decodeCost-scanCost, fmt.Sprintf("%s precompile calldata decode", d.name)) args, err := method.Inputs.Unpack(input[4:]) if err != nil { From 3bca1b1f8ac4a8b2eb5c01e0230b17cfc01a2733 Mon Sep 17 00:00:00 2001 From: Tony Chen Date: Tue, 14 Jul 2026 21:57:16 +0800 Subject: [PATCH 5/6] precompile: scope decode panic recovery to gas-meter panics Only convert sdk.ErrorOutOfGas / sdk.ErrorGasOverflow panics (from the calldata decode gas charges) into a reverted call; re-panic anything else so genuine bugs are not masked as reverts. Add a regression test asserting a non-gas panic propagates. Co-Authored-By: Claude Opus 4.8 --- precompiles/common/precompiles.go | 16 +++++++++++----- precompiles/common/precompiles_test.go | 24 ++++++++++++++++++++++++ 2 files changed, 35 insertions(+), 5 deletions(-) diff --git a/precompiles/common/precompiles.go b/precompiles/common/precompiles.go index 3b26b58238..c684b3db52 100644 --- a/precompiles/common/precompiles.go +++ b/precompiles/common/precompiles.go @@ -163,12 +163,18 @@ func NewDynamicGasPrecompile(a abi.ABI, executor DynamicGasPrecompileExecutor, a func (d DynamicGasPrecompile) RunAndCalculateGas(evm *vm.EVM, caller common.Address, callingContract common.Address, input []byte, suppliedGas uint64, value *big.Int, hooks *tracing.Hooks, readOnly bool, isFromDelegateCall bool) (ret []byte, remainingGas uint64, err error) { operation := fmt.Sprintf("%s_unknown", d.name) defer func() { - // Turn any panic — most importantly an out-of-gas panic raised while - // metering the calldata decode below — into a reverted precompile call, - // mirroring how the individual executor methods recover. This keeps a - // single precompile call's failure from aborting the enclosing EVM frame. + // The calldata-decode gas charges below can panic with a gas-meter + // out-of-gas / overflow; turn only those into a reverted precompile call + // (a call that could not afford the decode), keeping the failure from + // aborting the enclosing EVM frame. Re-panic anything else so genuine + // bugs are not masked as reverts. if r := recover(); r != nil { - err = fmt.Errorf("%v", r) + switch r.(type) { + case sdk.ErrorOutOfGas, sdk.ErrorGasOverflow: + err = fmt.Errorf("%v", r) + default: + panic(r) + } } HandlePrecompileError(err, evm, operation) if err != nil { diff --git a/precompiles/common/precompiles_test.go b/precompiles/common/precompiles_test.go index eda7f3d8ff..e949c536dd 100644 --- a/precompiles/common/precompiles_test.go +++ b/precompiles/common/precompiles_test.go @@ -94,11 +94,15 @@ func TestPrecompileRun(t *testing.T) { type MockDynamicGasPrecompileExecutor struct { throw bool + panicWith interface{} evmKeeper utils.EVMKeeper } func (e *MockDynamicGasPrecompileExecutor) Execute(ctx sdk.Context, method *abi.Method, caller ethcommon.Address, callingContract ethcommon.Address, args []interface{}, value *big.Int, readOnly bool, evm *vm.EVM, suppliedGas uint64, _ *tracing.Hooks) (ret []byte, remainingGas uint64, err error) { ctx.EventManager().EmitEvent(sdk.NewEvent("test")) + if e.panicWith != nil { + panic(e.panicWith) + } if e.throw { return nil, 0, errors.New("test") } @@ -133,6 +137,26 @@ func TestDynamicGasPrecompileRun(t *testing.T) { require.Empty(t, stateDB.Ctx().EventManager().Events()) } +// TestDynamicGasPrecompileRepanicsNonGas verifies that only gas-meter panics are +// converted to reverts: a non-gas panic must propagate rather than be masked. +func TestDynamicGasPrecompileRepanicsNonGas(t *testing.T) { + k := &testkeeper.EVMTestApp.EvmKeeper + ctx := testkeeper.EVMTestApp.GetContextForDeliverTx(nil) + abiBz, err := os.ReadFile("erc20_abi.json") + require.Nil(t, err) + newAbi, err := abi.JSON(bytes.NewReader(abiBz)) + require.Nil(t, err) + input, err := newAbi.Pack("decimals") + require.Nil(t, err) + + precompile := common.NewDynamicGasPrecompile(newAbi, &MockDynamicGasPrecompileExecutor{panicWith: "boom", evmKeeper: k}, ethcommon.Address{}, "test") + stateDB := state.NewDBImpl(ctx.WithEventManager(sdk.NewEventManager()), k, false) + // Ample gas so the decode charges pass and the (panicking) executor runs. + require.PanicsWithValue(t, "boom", func() { + _, _, _ = precompile.RunAndCalculateGas(&vm.EVM{StateDB: stateDB}, ethcommon.Address{}, ethcommon.Address{}, input, 100000, big.NewInt(0), nil, false, false) + }) +} + // TestDynamicGasPrecompileGasGate is a regression test for a DoS in the dynamic // precompile framework: the ABI decode of (attacker-controlled) calldata used to // run before the supplied EVM gas was turned into a gas meter, so a call that From a31cb2b309623437996456b4435ab9123bd9f70a Mon Sep 17 00:00:00 2001 From: Tony Chen Date: Wed, 15 Jul 2026 11:13:27 +0800 Subject: [PATCH 6/6] precompile: pin v601/v603 legacy precompiles to frozen legacy/v66 common The v601/v603 snapshots of addr, bank, ibc, json, oracle and wasmd imported the current precompiles/common, so changes to the current common leaked into their replay path. Point them at the frozen precompiles/common/legacy/v66 snapshot (byte-identical to the current common prior to the recent decode-cost change), so historical replay is insulated from current-common changes. This restores the pre-change replay gas for the affected traces, so the evmrpc/tests replay fixtures revert to their original values. Co-Authored-By: Claude Opus 4.8 --- evmrpc/tests/regression_test.go | 16 ++++++++-------- precompiles/addr/legacy/v601/addr.go | 2 +- precompiles/addr/legacy/v603/addr.go | 2 +- precompiles/bank/legacy/v601/bank.go | 2 +- precompiles/bank/legacy/v603/bank.go | 2 +- precompiles/ibc/legacy/v601/ibc.go | 2 +- precompiles/ibc/legacy/v603/ibc.go | 2 +- precompiles/json/legacy/v603/json.go | 2 +- precompiles/oracle/legacy/v601/oracle.go | 2 +- precompiles/oracle/legacy/v603/oracle.go | 2 +- precompiles/wasmd/legacy/v601/wasmd.go | 2 +- precompiles/wasmd/legacy/v603/wasmd.go | 2 +- 12 files changed, 19 insertions(+), 19 deletions(-) diff --git a/evmrpc/tests/regression_test.go b/evmrpc/tests/regression_test.go index d8f1a65d6f..19088e80e7 100644 --- a/evmrpc/tests/regression_test.go +++ b/evmrpc/tests/regression_test.go @@ -153,7 +153,7 @@ func Test0x362af85584493ed225bf592606c989796ba5f0484f5c80b989b8573f85517ec1(t *t testTx(t, "0x362af85584493ed225bf592606c989796ba5f0484f5c80b989b8573f85517ec1", "v6.0.3", - "0x82932", + "0x8214a", "0x7fd13972", true, ) @@ -311,7 +311,7 @@ func Test0xfdd190eac0ec35a8d52149d1b056ab07221fae6418b1592d12004e773adc83f6(t *t testTx(t, "0xfdd190eac0ec35a8d52149d1b056ab07221fae6418b1592d12004e773adc83f6", "v6.0.0", - "0xc051", + "0xba5f", "0x0000000000000000000000000000000000000000000000000000000000000001", false, ) @@ -321,7 +321,7 @@ func Test0xd30881600c458683d6140ac1b52a6f626f56481b1e157b6b9516049e8bd9a99a(t *t testTx(t, "0xd30881600c458683d6140ac1b52a6f626f56481b1e157b6b9516049e8bd9a99a", "v6.0.1", - "0x35173", + "0x345a5", "0x0000000000000000000000000000000000000000000000000000000000000020000000000000000000000000000000000000000000000000000000000000000100000000000000000000000000000000000000000000000000000000000000200000000000000000000000000000000000000000000000000000000000000000", false, ) @@ -331,7 +331,7 @@ func Test0x1b9ceaabadfc635aa8eb5e6d4a66ee60c826980805fa93af3913872f7b565586(t *t testTx(t, "0x1b9ceaabadfc635aa8eb5e6d4a66ee60c826980805fa93af3913872f7b565586", "v6.0.1", - "0xbecb", + "0xb8d9", "0x0000000000000000000000000000000000000000000000000000000000000001", false, ) @@ -341,7 +341,7 @@ func Test0x29b9a9764a27ba65139ccf06ef51e18358a63dfe143bfa6a16a891127cfc8ab6(t *t testTx(t, "0x29b9a9764a27ba65139ccf06ef51e18358a63dfe143bfa6a16a891127cfc8ab6", "v6.0.2", - "0x9289b", + "0x91fcd", "0x00000000000000000000000000000000000000000000000000000000000000200000000000000000000000000000000000000000000000000000000000000000", false, ) @@ -351,7 +351,7 @@ func Test0xb8bce587144c674e7c12cbfc69266be7436dbbfdf6d7ac186582c0735954cc37(t *t testTx(t, "0xb8bce587144c674e7c12cbfc69266be7436dbbfdf6d7ac186582c0735954cc37", "v6.0.2", - "0xbe80", + "0xb88e", "0x0000000000000000000000000000000000000000000000000000000000000001", false, ) @@ -361,7 +361,7 @@ func Test0x534a7d8dac27858aff75623792fee542dd7cec2539c2ddac92749e85bdc08e84(t *t testTx(t, "0x534a7d8dac27858aff75623792fee542dd7cec2539c2ddac92749e85bdc08e84", "v6.0.3", - "0x920d8", + "0x9180a", "0x00000000000000000000000000000000000000000000000000000000000000200000000000000000000000000000000000000000000000000000000000000000", false, ) @@ -371,7 +371,7 @@ func Test0x2cbbf34f930076000024953b87da7dc119a04f71fc4734a4bfabbe60558a49c6(t *t testTx(t, "0x2cbbf34f930076000024953b87da7dc119a04f71fc4734a4bfabbe60558a49c6", "v6.0.3", - "0xc051", + "0xba5f", "0x0000000000000000000000000000000000000000000000000000000000000001", false, ) diff --git a/precompiles/addr/legacy/v601/addr.go b/precompiles/addr/legacy/v601/addr.go index efeb558fb8..10a4d5dbd5 100644 --- a/precompiles/addr/legacy/v601/addr.go +++ b/precompiles/addr/legacy/v601/addr.go @@ -23,7 +23,7 @@ import ( "github.com/ethereum/go-ethereum/core/vm" sdk "github.com/sei-protocol/sei-chain/sei-cosmos/types" - pcommon "github.com/sei-protocol/sei-chain/precompiles/common" + pcommon "github.com/sei-protocol/sei-chain/precompiles/common/legacy/v66" cryptotypes "github.com/sei-protocol/sei-chain/sei-cosmos/crypto/types" "github.com/sei-protocol/sei-chain/utils/metrics" "github.com/sei-protocol/sei-chain/x/evm/types" diff --git a/precompiles/addr/legacy/v603/addr.go b/precompiles/addr/legacy/v603/addr.go index 69f8b00f60..a243234918 100644 --- a/precompiles/addr/legacy/v603/addr.go +++ b/precompiles/addr/legacy/v603/addr.go @@ -23,7 +23,7 @@ import ( putils "github.com/sei-protocol/sei-chain/precompiles/utils" sdk "github.com/sei-protocol/sei-chain/sei-cosmos/types" - pcommon "github.com/sei-protocol/sei-chain/precompiles/common" + pcommon "github.com/sei-protocol/sei-chain/precompiles/common/legacy/v66" cryptotypes "github.com/sei-protocol/sei-chain/sei-cosmos/crypto/types" "github.com/sei-protocol/sei-chain/utils/metrics" "github.com/sei-protocol/sei-chain/x/evm/types" diff --git a/precompiles/bank/legacy/v601/bank.go b/precompiles/bank/legacy/v601/bank.go index 5081639c80..e792bb857f 100644 --- a/precompiles/bank/legacy/v601/bank.go +++ b/precompiles/bank/legacy/v601/bank.go @@ -10,7 +10,7 @@ import ( "github.com/ethereum/go-ethereum/common" "github.com/ethereum/go-ethereum/core/tracing" "github.com/ethereum/go-ethereum/core/vm" - pcommon "github.com/sei-protocol/sei-chain/precompiles/common" + pcommon "github.com/sei-protocol/sei-chain/precompiles/common/legacy/v66" putils "github.com/sei-protocol/sei-chain/precompiles/utils" "github.com/sei-protocol/sei-chain/sei-cosmos/telemetry" sdk "github.com/sei-protocol/sei-chain/sei-cosmos/types" diff --git a/precompiles/bank/legacy/v603/bank.go b/precompiles/bank/legacy/v603/bank.go index 4bde3805c2..0687aa9004 100644 --- a/precompiles/bank/legacy/v603/bank.go +++ b/precompiles/bank/legacy/v603/bank.go @@ -10,7 +10,7 @@ import ( "github.com/ethereum/go-ethereum/common" "github.com/ethereum/go-ethereum/core/tracing" "github.com/ethereum/go-ethereum/core/vm" - pcommon "github.com/sei-protocol/sei-chain/precompiles/common" + pcommon "github.com/sei-protocol/sei-chain/precompiles/common/legacy/v66" putils "github.com/sei-protocol/sei-chain/precompiles/utils" sdk "github.com/sei-protocol/sei-chain/sei-cosmos/types" banktypes "github.com/sei-protocol/sei-chain/sei-cosmos/x/bank/types" diff --git a/precompiles/ibc/legacy/v601/ibc.go b/precompiles/ibc/legacy/v601/ibc.go index 036b1cfeaa..2409bed68b 100644 --- a/precompiles/ibc/legacy/v601/ibc.go +++ b/precompiles/ibc/legacy/v601/ibc.go @@ -16,7 +16,7 @@ import ( clienttypes "github.com/sei-protocol/sei-chain/sei-ibc-go/modules/core/02-client/types" connectiontypes "github.com/sei-protocol/sei-chain/sei-ibc-go/modules/core/03-connection/types" - pcommon "github.com/sei-protocol/sei-chain/precompiles/common" + pcommon "github.com/sei-protocol/sei-chain/precompiles/common/legacy/v66" "github.com/sei-protocol/sei-chain/precompiles/utils" evmtypes "github.com/sei-protocol/sei-chain/x/evm/types" ) diff --git a/precompiles/ibc/legacy/v603/ibc.go b/precompiles/ibc/legacy/v603/ibc.go index 95bf99f5fe..4bf2d17e52 100644 --- a/precompiles/ibc/legacy/v603/ibc.go +++ b/precompiles/ibc/legacy/v603/ibc.go @@ -15,7 +15,7 @@ import ( clienttypes "github.com/sei-protocol/sei-chain/sei-ibc-go/modules/core/02-client/types" connectiontypes "github.com/sei-protocol/sei-chain/sei-ibc-go/modules/core/03-connection/types" - pcommon "github.com/sei-protocol/sei-chain/precompiles/common" + pcommon "github.com/sei-protocol/sei-chain/precompiles/common/legacy/v66" "github.com/sei-protocol/sei-chain/precompiles/utils" evmtypes "github.com/sei-protocol/sei-chain/x/evm/types" ) diff --git a/precompiles/json/legacy/v603/json.go b/precompiles/json/legacy/v603/json.go index d83777c612..79180b98b5 100644 --- a/precompiles/json/legacy/v603/json.go +++ b/precompiles/json/legacy/v603/json.go @@ -12,7 +12,7 @@ import ( "github.com/ethereum/go-ethereum/common" "github.com/ethereum/go-ethereum/core/tracing" "github.com/ethereum/go-ethereum/core/vm" - pcommon "github.com/sei-protocol/sei-chain/precompiles/common" + pcommon "github.com/sei-protocol/sei-chain/precompiles/common/legacy/v66" putils "github.com/sei-protocol/sei-chain/precompiles/utils" sdk "github.com/sei-protocol/sei-chain/sei-cosmos/types" "github.com/sei-protocol/sei-chain/utils" diff --git a/precompiles/oracle/legacy/v601/oracle.go b/precompiles/oracle/legacy/v601/oracle.go index 3c5b25f15a..0615884d6f 100644 --- a/precompiles/oracle/legacy/v601/oracle.go +++ b/precompiles/oracle/legacy/v601/oracle.go @@ -10,7 +10,7 @@ import ( "github.com/ethereum/go-ethereum/common" "github.com/ethereum/go-ethereum/core/tracing" "github.com/ethereum/go-ethereum/core/vm" - pcommon "github.com/sei-protocol/sei-chain/precompiles/common" + pcommon "github.com/sei-protocol/sei-chain/precompiles/common/legacy/v66" "github.com/sei-protocol/sei-chain/precompiles/utils" "github.com/sei-protocol/sei-chain/x/oracle/types" ) diff --git a/precompiles/oracle/legacy/v603/oracle.go b/precompiles/oracle/legacy/v603/oracle.go index 17952b014c..7dca05e896 100644 --- a/precompiles/oracle/legacy/v603/oracle.go +++ b/precompiles/oracle/legacy/v603/oracle.go @@ -11,7 +11,7 @@ import ( "github.com/ethereum/go-ethereum/common" "github.com/ethereum/go-ethereum/core/tracing" "github.com/ethereum/go-ethereum/core/vm" - pcommon "github.com/sei-protocol/sei-chain/precompiles/common" + pcommon "github.com/sei-protocol/sei-chain/precompiles/common/legacy/v66" "github.com/sei-protocol/sei-chain/precompiles/utils" "github.com/sei-protocol/sei-chain/x/oracle/types" ) diff --git a/precompiles/wasmd/legacy/v601/wasmd.go b/precompiles/wasmd/legacy/v601/wasmd.go index 7009f706d1..25a8fbb716 100644 --- a/precompiles/wasmd/legacy/v601/wasmd.go +++ b/precompiles/wasmd/legacy/v601/wasmd.go @@ -14,7 +14,7 @@ import ( sdk "github.com/sei-protocol/sei-chain/sei-cosmos/types" wasmtypes "github.com/sei-protocol/sei-chain/sei-wasmd/x/wasm/types" - pcommon "github.com/sei-protocol/sei-chain/precompiles/common" + pcommon "github.com/sei-protocol/sei-chain/precompiles/common/legacy/v66" "github.com/sei-protocol/sei-chain/precompiles/utils" "github.com/sei-protocol/sei-chain/x/evm/state" "github.com/sei-protocol/sei-chain/x/evm/types" diff --git a/precompiles/wasmd/legacy/v603/wasmd.go b/precompiles/wasmd/legacy/v603/wasmd.go index ebca549de0..2d400366f5 100644 --- a/precompiles/wasmd/legacy/v603/wasmd.go +++ b/precompiles/wasmd/legacy/v603/wasmd.go @@ -14,7 +14,7 @@ import ( sdk "github.com/sei-protocol/sei-chain/sei-cosmos/types" wasmtypes "github.com/sei-protocol/sei-chain/sei-wasmd/x/wasm/types" - pcommon "github.com/sei-protocol/sei-chain/precompiles/common" + pcommon "github.com/sei-protocol/sei-chain/precompiles/common/legacy/v66" "github.com/sei-protocol/sei-chain/precompiles/utils" "github.com/sei-protocol/sei-chain/x/evm/state" "github.com/sei-protocol/sei-chain/x/evm/types"