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/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/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/common/decode_cost.go b/precompiles/common/decode_cost.go new file mode 100644 index 0000000000..4672bb54e7 --- /dev/null +++ b/precompiles/common/decode_cost.go @@ -0,0 +1,286 @@ +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. 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 (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, true + } + strBytes, ok := decodeStringCopyBytes(args, input[4:]) + if !ok { + return 0, false + } + return satAdd(base, satMul(storetypes.KVGasConfig().ReadCostPerByte, strBytes)), true +} + +// 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) //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 { + 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])) //nolint:gosec // a wrapped-negative value is rejected by the offset<0 check below + 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()) //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 //nolint:gosec // lengthBig <= totalSize, which is BitLen<=63 checked above +} + +// 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 //nolint:gosec // offset fits int64 (BitLen<=63 checked above) +} + +// 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..ca413c7703 --- /dev/null +++ b/precompiles/common/decode_cost_test.go @@ -0,0 +1,152 @@ +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} + 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)}} + 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")))) + 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 5dd5bce16d..c684b3db52 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 } @@ -156,19 +163,70 @@ 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() { + // 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 { + switch r.(type) { + case sdk.ErrorOutOfGas, sdk.ErrorGasOverflow: + err = fmt.Errorf("%v", r) + default: + panic(r) + } + } HandlePrecompileError(err, evm, operation) if err != nil { fmt.Printf("precompile %s encountered error: %v\n", d.name, err) 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 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 } + operation = method.Name + + 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 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)) - operation = method.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 { + 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..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") } @@ -120,15 +124,70 @@ 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()) } + +// 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 +// 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()) +} 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/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/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) 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"