diff --git a/.golangci.yml b/.golangci.yml index 45bfa3a261..18519d7873 100644 --- a/.golangci.yml +++ b/.golangci.yml @@ -9,6 +9,7 @@ linters: default: none enable: - bodyclose + - depguard - dogsled - errcheck - goconst @@ -20,6 +21,19 @@ linters: - staticcheck - unconvert - misspell + settings: + depguard: + rules: + # evmrpc/ethrpcerrors owns the eth_sendRawTransaction parity table. + # Cosmos SDK errors must not be built or rendered elsewhere under evmrpc. + evmrpc-eth-error-parity: + list-mode: lax + files: + - "**/evmrpc/**" + - "!**/evmrpc/ethrpcerrors/**" + deny: + - pkg: github.com/sei-protocol/sei-chain/sei-cosmos/types/errors + desc: translate errors with evmrpc/ethrpcerrors instead of emitting Cosmos SDK errors from evmrpc exclusions: paths: - ".*\\.pb\\.go$" diff --git a/app/ante/evm_checktx.go b/app/ante/evm_checktx.go index 900485c83b..f25630d01c 100644 --- a/app/ante/evm_checktx.go +++ b/app/ante/evm_checktx.go @@ -115,9 +115,11 @@ func EvmStatelessChecks(ctx sdk.Context, tx sdk.Tx, chainID *big.Int) error { return err } if etx.Gas() < intrGas { + if ctx.IsCheckTx() { + return fmt.Errorf("%w: gas %v, minimum needed %v", core.ErrIntrinsicGas, etx.Gas(), intrGas) + } return core.ErrIntrinsicGas } - if etx.Type() == ethtypes.BlobTxType { return sdkerrors.ErrUnsupportedTxType } @@ -131,6 +133,16 @@ func EvmStatelessChecks(ctx sdk.Context, tx sdk.Tx, chainID *big.Int) error { } } + // Floor data gas is a CheckTx/txpool rejection; DeliverTx keeps the execution-time failure as consensus behaviour. + if ctx.IsCheckTx() { + ethCfg := evmtypes.DefaultChainConfig().EthereumConfig(chainID) + if ethCfg.IsPrague(big.NewInt(ctx.BlockHeight()), uint64(ctx.BlockTime().Unix())) { //nolint:gosec + if err := checkFloorDataGas(etx); err != nil { + return err + } + } + } + if txData.GetGasTipCap().Sign() < 0 { return sdkerrors.Wrapf(sdkerrors.ErrInvalidRequest, "gas fee cap cannot be negative") } @@ -142,12 +154,18 @@ func EvmStatelessChecks(ctx sdk.Context, tx sdk.Tx, chainID *big.Int) error { // legacy either can have a zero or correct chain ID if txChainID.Cmp(big.NewInt(0)) != 0 && txChainID.Cmp(chainID) != 0 { logger.Debug("chainID mismatch", "txChainID", txChainID, "chainID", chainID) + if ctx.IsCheckTx() { + return sdkerrors.Wrapf(sdkerrors.ErrInvalidChainID, "%v: have %d want %d", ethtypes.ErrInvalidChainId, txChainID, chainID) + } return sdkerrors.ErrInvalidChainID } default: // after legacy, all transactions must have the correct chain ID if txChainID.Cmp(chainID) != 0 { logger.Debug("chainID mismatch", "txChainID", txChainID, "chainID", chainID) + if ctx.IsCheckTx() { + return sdkerrors.Wrapf(sdkerrors.ErrInvalidChainID, "%v: have %d want %d", ethtypes.ErrInvalidChainId, txChainID, chainID) + } return sdkerrors.ErrInvalidChainID } } @@ -159,6 +177,18 @@ func EvmStatelessChecks(ctx sdk.Context, tx sdk.Tx, chainID *big.Int) error { return nil } +// checkFloorDataGas checks that etx's gas limit is at least the EIP-7623 floor data gas for its calldata. +func checkFloorDataGas(etx *ethtypes.Transaction) error { + floorDataGas, err := core.FloorDataGas(etx.Data()) + if err != nil { + return err + } + if etx.Gas() < floorDataGas { + return fmt.Errorf("%w: gas %v, minimum needed %v", core.ErrFloorDataGas, etx.Gas(), floorDataGas) + } + return nil +} + func DecorateContext(ctx sdk.Context, ek *evmkeeper.Keeper, tx sdk.Tx, txData ethtx.TxData, etx *ethtypes.Transaction, sender common.Address, seiSender sdk.AccAddress) sdk.Context { ctx = ctx.WithPriority(CalculatePriority(ctx, txData, ek).Int64()) @@ -232,6 +262,9 @@ func CheckAndDecodeSignature(ctx sdk.Context, txData ethtx.TxData, chainID *big. } evmAddr, seiAddr, seiPubkey, err := helpers.GetAddresses(V, R, S, txHash) if err != nil { + if ctx.IsCheckTx() { + return common.Address{}, sdk.AccAddress{}, nil, 0, sdkerrors.Wrap(sdkerrors.ErrInvalidChainID, err.Error()) + } return common.Address{}, sdk.AccAddress{}, nil, 0, sdkerrors.ErrInvalidChainID } return evmAddr, seiAddr, seiPubkey, version, nil @@ -284,10 +317,18 @@ func AssociateAuthorizationAuthorities(ctx sdk.Context, ek *evmkeeper.Keeper, et } func EvmCheckAndChargeFees(ctx sdk.Context, sender common.Address, ek *evmkeeper.Keeper, upgradeKeeper *upgradekeeper.Keeper, txData ethtx.TxData, etx *ethtypes.Transaction, msg *evmtypes.MsgEVMTransaction, version derived.SignerVersion, statelessChecks bool) (*state.DBImpl, error) { - if txData.GetGasFeeCap().Cmp(GetBaseFee(ctx, ek, upgradeKeeper)) < 0 { + baseFee := GetBaseFee(ctx, ek, upgradeKeeper) + if txData.GetGasFeeCap().Cmp(baseFee) < 0 { + if ctx.IsCheckTx() { + return nil, sdkerrors.Wrapf(sdkerrors.ErrInsufficientFee, "address %s, maxFeePerGas: %s, baseFee: %s", sender.Hex(), txData.GetGasFeeCap(), baseFee) + } return nil, sdkerrors.ErrInsufficientFee } - if txData.GetGasFeeCap().Cmp(GetMinimumFee(ctx, ek)) < 0 { + minimumFee := GetMinimumFee(ctx, ek) + if txData.GetGasFeeCap().Cmp(minimumFee) < 0 { + if ctx.IsCheckTx() { + return nil, sdkerrors.Wrapf(sdkerrors.ErrInsufficientFee, "address %s, maxFeePerGas: %s, minimumFeePerGas: %s", sender.Hex(), txData.GetGasFeeCap(), minimumFee) + } return nil, sdkerrors.ErrInsufficientFee } ethCfg := evmtypes.DefaultChainConfig().EthereumConfig(ek.ChainID(ctx)) @@ -329,7 +370,7 @@ func CheckNonce(ctx sdk.Context, ek *evmkeeper.Keeper, etx *ethtypes.Transaction txNonce := etx.Nonce() nextNonce := ek.GetNonce(ctx, evmAddr) if txNonce < nextNonce { - return ctx, sdkerrors.ErrWrongSequence + return ctx, sdkerrors.Wrapf(sdkerrors.ErrWrongSequence, "next nonce %d, tx nonce %d", nextNonce, txNonce) } ctx = ctx.WithEVMRequiredBalance(fee) diff --git a/app/ante/evm_checktx_test.go b/app/ante/evm_checktx_test.go index 58319b550f..ba8851e6a2 100644 --- a/app/ante/evm_checktx_test.go +++ b/app/ante/evm_checktx_test.go @@ -1,10 +1,12 @@ package ante import ( + "bytes" "math/big" "testing" "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/core" sdk "github.com/sei-protocol/sei-chain/sei-cosmos/types" evmtypes "github.com/sei-protocol/sei-chain/x/evm/types" "github.com/sei-protocol/sei-chain/x/evm/types/ethtx" @@ -51,3 +53,51 @@ func TestEvmStatelessChecksRejectsEmptySetCodeAuthList(t *testing.T) { err = EvmStatelessChecks(sdk.Context{}, evmStatelessCheckTx{msgs: []sdk.Msg{msg}}, big.NewInt(1)) require.ErrorContains(t, err, "auth list cannot be empty") } + +func legacyTransferForStatelessChecks(t *testing.T, gas uint64, data []byte) evmStatelessCheckTx { + t.Helper() + gasPrice := sdk.NewInt(1) + amount := sdk.NewInt(0) + legacyTx := ðtx.LegacyTx{ + GasPrice: &gasPrice, + GasLimit: gas, + To: common.Address{'a'}.Hex(), + Amount: &amount, + Data: data, + V: []byte{27}, + R: []byte{5}, + S: []byte{7}, + } + msg, err := evmtypes.NewMsgEVMTransaction(legacyTx) + require.NoError(t, err) + return evmStatelessCheckTx{msgs: []sdk.Msg{msg}} +} + +func TestEvmStatelessChecksIntrinsicGasTooLow(t *testing.T) { + tx := legacyTransferForStatelessChecks(t, 1000, nil) + err := EvmStatelessChecks(sdk.Context{}.WithIsCheckTx(true), tx, big.NewInt(1)) + require.ErrorIs(t, err, core.ErrIntrinsicGas) + require.Equal(t, "intrinsic gas too low: gas 1000, minimum needed 21000", err.Error()) + + err = EvmStatelessChecks(sdk.Context{}, tx, big.NewInt(1)) + require.Equal(t, core.ErrIntrinsicGas, err) +} + +func TestEvmStatelessChecksFloorDataGasCheckTxOnly(t *testing.T) { + data := bytes.Repeat([]byte{1}, 1000) + tx := legacyTransferForStatelessChecks(t, 40000, data) + + err := EvmStatelessChecks(sdk.Context{}.WithIsCheckTx(true), tx, big.NewInt(1)) + require.ErrorIs(t, err, core.ErrFloorDataGas) + require.Equal(t, "insufficient gas for floor data gas cost: gas 40000, minimum needed 61000", err.Error()) + + err = EvmStatelessChecks(sdk.Context{}.WithIsReCheckTx(true), tx, big.NewInt(1)) + require.ErrorIs(t, err, core.ErrFloorDataGas) + + err = EvmStatelessChecks(sdk.Context{}, tx, big.NewInt(1)) + require.NoError(t, err) + + atFloor := legacyTransferForStatelessChecks(t, 61000, data) + err = EvmStatelessChecks(sdk.Context{}.WithIsCheckTx(true), atFloor, big.NewInt(1)) + require.NoError(t, err) +} diff --git a/contracts/test/EVMCompatabilityTest.js b/contracts/test/EVMCompatabilityTest.js index 66f3aeb6af..f66ad7d5e8 100644 --- a/contracts/test/EVMCompatabilityTest.js +++ b/contracts/test/EVMCompatabilityTest.js @@ -374,7 +374,7 @@ describe("EVM Test", function () { blobVersionedHashes: [blobHash], } - await expect(signer.sendTransaction(tx)).to.be.rejectedWith("unsupported transaction type"); + await expect(signer.sendTransaction(tx)).to.be.rejectedWith("transaction type not supported"); }) it("trace balance diff matches up with actual balance change", async function() { @@ -1337,7 +1337,7 @@ describe("EVM Validations ", function() { id: 1, jsonrpc: "2.0" }) - expect(response.data.error.message).to.include("invalid chain-id") + expect(response.data.error.message).to.include("invalid sender: invalid chain id for signer") }); it("should prevent wrong chainId for legacy txs", async function() { @@ -1362,7 +1362,7 @@ describe("EVM Validations ", function() { jsonrpc: "2.0" }) - expect(response.data.error.message).to.include("invalid chain-id") + expect(response.data.error.message).to.include("invalid sender: invalid chain id for signer") }); it("should not allow empty chainId for legacy txs", async function() { diff --git a/contracts/test/lib.js b/contracts/test/lib.js index 3f6ef26f0a..015e601357 100644 --- a/contracts/test/lib.js +++ b/contracts/test/lib.js @@ -875,8 +875,8 @@ async function deployEvmContract(name, args=[]) { return contract; } -// Wrap a signer's sendTransaction with retry on "incorrect account -// sequence". Under Autobahn the post-commit window in which +// Wrap a signer's sendTransaction with retry on the old and new nonce errors. +// Under Autobahn the post-commit window in which // eth_getTransactionCount may briefly return a stale nonce is wider // than under CometBFT, so an ethers-managed send right after an // awaited prior tx can hit a one-off nonce mismatch even though the @@ -894,7 +894,7 @@ function _wrapSignerWithNonceRetry(signer) { return await original(...args) } catch (e) { lastErr = e - if (!/incorrect account sequence/i.test(e?.message || '')) throw e + if (!/(?:incorrect account sequence|nonce too low)/i.test(e?.message || '')) throw e await new Promise(r => setTimeout(r, TX_NONCE_RETRY_DELAY_MS)) } } diff --git a/evmrpc/AGENTS.md b/evmrpc/AGENTS.md index aab3df8e2d..c2b537499b 100644 --- a/evmrpc/AGENTS.md +++ b/evmrpc/AGENTS.md @@ -52,6 +52,86 @@ Legacy **`sei_*`** JSON-RPC (EVM HTTP only) are **gated** by the `[evm].enabled_ **Tracer gating (deviation from geth defaults):** caller-supplied `TraceConfig.Tracer` values on `debug_traceCall` / `debug_traceTransaction` / `debug_traceBlockBy*` / `debug_traceTransactionProfile` are gated by `[evm]` config in `app.toml`. `trace_allowed_tracers` lists the native geth tracer names callers may request (validated native-only at startup; `muxTracer` nested tracer names are validated recursively with a bounded depth). `trace_allow_js_tracers` (default `false`) is a separate explicit opt-in for request-supplied JavaScript tracer source — upstream geth accepts JS tracers by default, Sei does not. Enabling JS does **not** widen the native allowlist. Validation runs in `validateTraceTracer` (`tracers.go`) before trace-cache lookups and before any tracer is constructed; the default struct logger (no `tracer` field) is always available. `trace_bake_tracers` is held to the same native-only rule at startup. +## Error parity with go-ethereum (`eth_sendRawTransaction`) + +Every error leaving `SendAPI.SendRawTransaction` after transaction decoding carries a +go-ethereum message and JSON-RPC code. One package owns that contract: +`evmrpc/ethrpcerrors`. + +- `ethrpcerrors.Translate(err)` runs once, at the single exit of `SendAPI.SendRawTransaction`, + on the error of the `submit` step (proxied call, Cosmos encoding, both broadcast branches). + Decode errors from `tx.UnmarshalBinary` are go-ethereum's own and are returned untranslated. +- `ethrpcerrors.TranslateABCI(codespace, code, log)` replaces the old + `sdkerrors.ABCIError(RootCodespace, code, "")`, which discarded the codespace and log and + rendered `": incorrect account sequence"` / `": unknown"`. +- The returned `*ethrpcerrors.Error` implements `rpc.Error` and `rpc.DataError` and **must be the + top-level return value**: go-ethereum's `errorMessage` uses `err.(Error)`, so a wrapped one is + encoded as `-32000` with the wrapper's text. Do not `fmt.Errorf("...: %w", translated)`. +- An error that already implements `rpc.Error`, such as a remote node's `*jsonError` on the + proxied branch, passes through unchanged. +- Anything the table does not know becomes `-32603 internal error`, increments + `evmrpc_untranslated_error_total` and logs the original text at Warn. That includes transport + failures on the proxied branch (`Post "": …`), so an internal address never + reaches a client. When the counter moves, add a row to the table rather than widening a match. +- `depguard` (`.golangci.yml`) denies `sei-cosmos/types/errors` under `evmrpc/` except in + `evmrpc/ethrpcerrors`. It does not cover `_test.go` files (`run.tests: false`) and cannot catch a + forwarded `Log` string, which is why the golden test in `ethrpcerrors` and the negative + assertion (no Cosmos vocabulary, no leading `": "`) exist. Extend both when adding a mapping. + +**Message format.** The go-ethereum sentinel is the prefix; detail follows after `: `. Producers +in `app/ante` keep their Cosmos SDK error codes and wrap CheckTx-only detail with +`sdkerrors.Wrapf`; the boundary strips the SDK description and prepends the sentinel for the +code. A producer that already emits go-ethereum text passes through verbatim. + +**Parity table (submit path).** Codes are `-32000` unless stated. + +| Condition | Message | go-ethereum | +|---|---|---| +| Nonce below account nonce | `nonce too low: next nonce N, tx nonce M` | same | +| Same nonce already pending (classic mempool) | `replacement transaction underpriced` | same | +| Nonce gap (autobahn only; classic admits) | `nonce too high: tx nonce N, gapped nonce M` | admitted (see divergences) | +| Insufficient balance | `insufficient funds for gas * price + value: address 0x… have X want Y` | `…: balance X, tx cost Y, overshot Z` (see divergences) | +| Fee cap below base fee | `max fee per gas less than block base fee: address 0x…, maxFeePerGas: X, baseFee: Y` | admitted by the txpool when its tip meets the minimum; execution uses this sentinel | +| Fee cap below Sei minimum fee | `max fee per gas less than block base fee: address 0x…, maxFeePerGas: X, minimumFeePerGas: Y` | no analogue; nearest sentinel, Sei detail | +| Intrinsic gas too low | `intrinsic gas too low: gas N, minimum needed M` | identical | +| Floor data gas too low (EIP-7623, CheckTx only) | `insufficient gas for floor data gas cost: gas N, minimum needed M` | identical | +| Init code exceeds max | `max initcode size exceeded: code size N, limit 49152` | identical | +| Empty EIP-7702 authorization list | `set code tx must have at least one authorization tuple` | identical | +| Unprotected legacy tx | `only replay-protected (EIP-155) transactions allowed over RPC` | identical | +| Gas limit above block max | `exceeds block gas limit: tx gas limit N exceeds block max gas M` | same sentinel | +| Blob tx / type not enabled | `transaction type not supported` | same | +| Chain ID mismatch | `invalid sender: invalid chain id for signer: have N want M` | identical | +| Signature recovery failure | `invalid sender: ` | same | +| Tip above fee cap | `max priority fee per gas higher than max fee per gas (X > Y)` | same sentinel, no detail | +| `cap * gas` or value beyond 2^256-1 | `insufficient funds for gas * price + value: fee out of bound` / `…: value overflow` | no such check; fails the balance check | +| Fee cap / tip beyond 2^256-1 | `max fee per gas higher than 2^256-1` / `max priority fee per gas higher than 2^256-1` | same | +| Already in mempool cache, duplicate | `already known` | same | +| Mempool full (classic or autobahn) | `txpool is full` | same | +| Priority below reservoir cutoff | `transaction underpriced` | same (different mechanism) | +| Tx bytes above mempool limit | `oversized data: ` | same sentinel | +| Request or autobahn wait cancelled, commit wait timed out | `-32002 request timed out` | same | +| Anything else (`not producing`, proxy/RPC-layer internals, transport errors, unknown) | `-32603 internal error` | n/a | + +**Deliberate divergences** (documented rather than changed): + +- Fee before nonce: `EvmCheckAndChargeFees` runs before `CheckNonce`, so an underfunded account + replaying a stale nonce gets `insufficient funds…` where go-ethereum says `nonce too low`. +- Sei rejects a fee cap below the current base fee during CheckTx. go-ethereum's legacy txpool + can retain that transaction for a later base-fee drop when its tip meets the pool minimum. +- Insufficient-funds detail is go-ethereum's execution-path shape (`address … have X want Y`, from + the fork's `BuyGas`), not the txpool's `balance X, tx cost Y, overshot Z`. The sentinel matches. +- Autobahn requires strictly sequential nonces per sender within a produce session; go-ethereum's + legacy/1559 pools admit gaps. The classic mempool matches go-ethereum. Which path a client hits + depends on node configuration. +- `BroadcastTxCommit` is refused under Autobahn; `evm.slow` still submits via `BroadcastTx` there. +- Per-sender pending caps use a priority reservoir and utilisation threshold, not go-ethereum's + slot count, so `account limit exceeded` is never emitted; overdraft across queued transactions is + only partially covered by the mempool's required-balance tracking. +- The tip-above-fee-cap detail is parenthesized (`(X > Y)`), produced by `x/evm/types/ethtx` + validation; the leading sentinel is identical. +- The fallback code is `-32603` where these errors used to be `-32000`; text-matching clients see + every mapped condition change string, code-matching clients only the fallback. + ## Consistency RPC responses for historical heights should never change as the blockchain progresses, or as the blockchain code gets upgraded. diff --git a/evmrpc/ethrpcerrors/errors.go b/evmrpc/ethrpcerrors/errors.go new file mode 100644 index 0000000000..29f49c1760 --- /dev/null +++ b/evmrpc/ethrpcerrors/errors.go @@ -0,0 +1,349 @@ +// Package ethrpcerrors maps EVM transaction submission errors onto the go-ethereum +// errors a JSON-RPC client expects. +package ethrpcerrors + +import ( + "context" + "errors" + "fmt" + "strings" + "unicode" + "unicode/utf8" + + "github.com/ethereum/go-ethereum/core" + "github.com/ethereum/go-ethereum/core/txpool" + "github.com/ethereum/go-ethereum/core/txpool/legacypool" + ethtypes "github.com/ethereum/go-ethereum/core/types" + "github.com/ethereum/go-ethereum/rpc" + "github.com/sei-protocol/seilog" + "go.opentelemetry.io/otel" + "go.opentelemetry.io/otel/metric" + + sdkerrors "github.com/sei-protocol/sei-chain/sei-cosmos/types/errors" +) + +// JSON-RPC error codes go-ethereum's rpc package assigns but does not export. +const ( + // CodeDefault is the code of every handler error that carries no code of its own. + CodeDefault = -32000 + // CodeTimeout is the code go-ethereum's server writes when a call exceeds its deadline. + CodeTimeout = -32002 + // CodeInternal is the JSON-RPC internal-error code used for unmapped server failures. + CodeInternal = -32603 +) + +// Message literals go-ethereum defines inline rather than as exported sentinels. +const ( + msgInternal = "internal error" + msgTimeout = "request timed out" + // msgUnprotected is ethapi.SubmitTransaction's rejection of a pre-EIP-155 transaction. + msgUnprotected = "only replay-protected (EIP-155) transactions allowed over RPC" + // msgEmptyAuthList is txpool.ValidateTransaction's rejection of an empty EIP-7702 authorization list. + msgEmptyAuthList = "set code tx must have at least one authorization tuple" +) + +var ( + logger = seilog.NewLogger("evmrpc", "ethrpcerrors") + + untranslatedCount = must(otel.Meter("evmrpc").Int64Counter( + "evmrpc_untranslated_error_total", + metric.WithDescription("Number of EVM submission errors with no go-ethereum mapping, returned as -32603 internal error"), + metric.WithUnit("{count}"), + )) +) + +// Error is a top-level JSON-RPC error carrying a go-ethereum message and code. +type Error struct { + code int + message string + // sentinel is the go-ethereum error the message is built on, or nil for internal errors. + sentinel error +} + +var ( + _ rpc.Error = (*Error)(nil) + _ rpc.DataError = (*Error)(nil) +) + +func (e *Error) Error() string { return e.message } + +// ErrorCode returns the JSON-RPC error code. +func (e *Error) ErrorCode() int { return e.code } + +// ErrorData returns nil; go-ethereum attaches no data to this class of error. +func (e *Error) ErrorData() interface{} { return nil } + +// Unwrap returns the go-ethereum sentinel the message is built on, so errors.Is can select on it. +func (e *Error) Unwrap() error { return e.sentinel } + +// Translate returns the go-ethereum error a client expects in place of err. An error that +// already implements rpc.Error, such as a remote node's JSON-RPC error or a typed evmrpc +// error, is returned unchanged. A nil err returns nil. +func Translate(err error) error { + if err == nil { + return nil + } + // A direct assertion, not errors.As, mirrors what go-ethereum's server will do with the + // value: only a top-level rpc.Error keeps its code, so only that is already at parity. + if _, ok := err.(rpc.Error); ok { + return err + } + if errors.Is(err, context.DeadlineExceeded) || errors.Is(err, context.Canceled) { + return timeout() + } + codespace, code, log := sdkerrors.ABCIInfo(err, false) + return TranslateABCI(codespace, code, log) +} + +// TranslateABCI returns the go-ethereum error a client expects for a CheckTx rejection, or nil +// when code reports success. +func TranslateABCI(codespace string, code uint32, log string) error { + if code == sdkerrors.SuccessABCICode { + return nil + } + text := strings.TrimSpace(log) + if codespace == sdkerrors.RootCodespace { + if r, ok := sdkRuleFor(code); ok { + return r.translate(text) + } + } + return translateText(text) +} + +// sdkRule maps a registered Cosmos SDK error onto the go-ethereum sentinel for the same condition. +type sdkRule struct { + sdkErr *sdkerrors.Error + sentinel error + // defaultDetail is the detail go-ethereum attaches when the producer supplied none. + defaultDetail string +} + +var sdkRules = []sdkRule{ + {sdkErr: sdkerrors.ErrWrongSequence, sentinel: core.ErrNonceTooLow}, + {sdkErr: sdkerrors.ErrInsufficientFunds, sentinel: core.ErrInsufficientFunds}, + {sdkErr: sdkerrors.ErrInsufficientFee, sentinel: core.ErrFeeCapTooLow}, + {sdkErr: sdkerrors.ErrUnsupportedTxType, sentinel: core.ErrTxTypeNotSupported}, + {sdkErr: sdkerrors.ErrInvalidChainID, sentinel: txpool.ErrInvalidSender, defaultDetail: ethtypes.ErrInvalidChainId.Error()}, + {sdkErr: sdkerrors.ErrOutOfGas, sentinel: txpool.ErrGasLimit}, + {sdkErr: sdkerrors.ErrInvalidCoins, sentinel: txpool.ErrNegativeValue}, + {sdkErr: sdkerrors.ErrTxInMempoolCache, sentinel: txpool.ErrAlreadyKnown}, + {sdkErr: sdkerrors.ErrMempoolIsFull, sentinel: legacypool.ErrTxPoolOverflow}, + {sdkErr: sdkerrors.ErrTxTooLarge, sentinel: txpool.ErrOversizedData}, +} + +func sdkRuleFor(code uint32) (sdkRule, bool) { + for _, r := range sdkRules { + if r.sdkErr.ABCICode() == code { + return r, true + } + } + return sdkRule{}, false +} + +// translate builds the message from the detail the producer wrapped around the SDK error. +func (r sdkRule) translate(log string) *Error { + detail := trimDescription(log, r.sdkErr.Error()) + // A producer that already speaks go-ethereum, such as an ante handler forwarding + // go-ethereum's own StatelessChecks error, needs no sentinel prepended. + if e, ok := matchGeth(detail); ok { + return e + } + if detail == "" { + detail = r.defaultDetail + } + return fromSentinel(r.sentinel, detail) +} + +// trimDescription removes the registered description that sdkerrors.Wrap appends to an error's +// text, leaving the producer's own detail. +func trimDescription(log, description string) string { + if log == description { + return "" + } + return strings.TrimSuffix(log, ": "+description) +} + +// gethSentinels are the go-ethereum errors a Sei producer may emit verbatim. A text that begins +// with one is already at parity and passes through under the default code. +var gethSentinels = []error{ + core.ErrNonceTooLow, + core.ErrNonceTooHigh, + core.ErrNonceMax, + core.ErrIntrinsicGas, + core.ErrFloorDataGas, + core.ErrMaxInitCodeSizeExceeded, + core.ErrInsufficientFunds, + core.ErrInsufficientFundsForTransfer, + core.ErrGasUintOverflow, + core.ErrGasLimitReached, + core.ErrTipAboveFeeCap, + core.ErrTipVeryHigh, + core.ErrFeeCapVeryHigh, + core.ErrFeeCapTooLow, + core.ErrBlobFeeCapTooLow, + core.ErrSenderNoEOA, + core.ErrTxTypeNotSupported, + txpool.ErrAlreadyKnown, + txpool.ErrInvalidSender, + txpool.ErrUnderpriced, + txpool.ErrReplaceUnderpriced, + txpool.ErrAccountLimitExceeded, + txpool.ErrGasLimit, + txpool.ErrNegativeValue, + txpool.ErrOversizedData, + legacypool.ErrTxPoolOverflow, +} + +func matchGeth(text string) (*Error, bool) { + for _, s := range gethSentinels { + if hasSentinelPrefix(text, s.Error()) { + return &Error{code: CodeDefault, message: text, sentinel: s}, true + } + } + if text == msgUnprotected { + return &Error{code: CodeDefault, message: text}, true + } + return nil, false +} + +// seiRule maps the text of a Sei-side error onto its go-ethereum equivalent. A message matches +// when it is prefix or continues it at a word boundary; the remainder after ": " is the detail. +type seiRule struct { + prefix string + to func(detail string) *Error +} + +var seiRules = []seiRule{ + // EVM ante and transaction-conversion errors that carry no ABCI code. + {prefix: "unsupported tx type: unsafe legacy tx", to: literal(CodeDefault, msgUnprotected)}, + {prefix: "auth list cannot be empty", to: literal(CodeDefault, msgEmptyAuthList)}, + {prefix: "invalid v: too long", to: sentinelWithReason(txpool.ErrInvalidSender, ethtypes.ErrInvalidSig.Error())}, + {prefix: "invalid r: too long", to: sentinelWithReason(txpool.ErrInvalidSender, ethtypes.ErrInvalidSig.Error())}, + {prefix: "invalid s: too long", to: sentinelWithReason(txpool.ErrInvalidSender, ethtypes.ErrInvalidSig.Error())}, + {prefix: "tx gas exceeds max", to: sentinel(txpool.ErrGasLimit)}, + // x/evm/types/ethtx validation of 256-bit overflow. go-ethereum has no check for a fee or + // value beyond 2^256-1; such a transaction fails its balance check instead. + {prefix: "fee out of bound", to: sentinelWithReason(core.ErrInsufficientFunds, "fee out of bound")}, + {prefix: "value overflow", to: sentinelWithReason(core.ErrInsufficientFunds, "value overflow")}, + {prefix: "gas price overflow", to: sentinel(core.ErrFeeCapVeryHigh)}, + {prefix: "gas tip cap overflow", to: sentinel(core.ErrTipVeryHigh)}, + // sei-tendermint/internal/mempool, the classic mempool. + {prefix: "tx already exists in cache", to: sentinel(txpool.ErrAlreadyKnown)}, + {prefix: "duplicate tx", to: sentinel(txpool.ErrAlreadyKnown)}, + {prefix: "tx with this nonce already in mempool", to: sentinel(txpool.ErrReplaceUnderpriced)}, + {prefix: "tx too large", to: sentinelWithDetail(txpool.ErrOversizedData)}, + {prefix: "nonce too old", to: sentinel(core.ErrNonceTooLow)}, + {prefix: "mempool full", to: sentinel(legacypool.ErrTxPoolOverflow)}, + {prefix: "priority not high enough for mempool", to: sentinel(txpool.ErrUnderpriced)}, + {prefix: "gas wanted exceeds max gas", to: sentinelWithDetail(txpool.ErrGasLimit)}, + {prefix: "txmp.txConstraintsFetcher()", to: internal}, + {prefix: "negative gas wanted", to: internal}, + // sei-tendermint/internal/autobahn/producer, the autobahn mempool. + {prefix: "transaction too large", to: sentinel(txpool.ErrOversizedData)}, + {prefix: "mempool is full", to: sentinel(legacypool.ErrTxPoolOverflow)}, + {prefix: "bad nonce", to: badNonce}, + {prefix: "not producing", to: internal}, + {prefix: context.Canceled.Error(), to: literal(CodeTimeout, msgTimeout)}, + {prefix: context.DeadlineExceeded.Error(), to: literal(CodeTimeout, msgTimeout)}, + // sei-tendermint/internal/proxy. + {prefix: "panic recovered in CheckTxSafe", to: internal}, + {prefix: "nil response", to: internal}, + {prefix: "EVM response missing", to: internal}, + // sei-tendermint/internal/rpc/core and evmrpc/send.go. + {prefix: "autobahn fullnode has no local mempool", to: internal}, + {prefix: "mempool is not available", to: internal}, + {prefix: "cannot confirm transaction because kvEventSink is not enabled", to: internal}, + {prefix: "broadcast_tx_commit is not supported", to: internal}, + {prefix: "timeout waiting for commit of tx", to: literal(CodeTimeout, msgTimeout)}, + {prefix: "missing broadcast response", to: internal}, +} + +func translateText(text string) *Error { + if e, ok := matchGeth(text); ok { + return e + } + for _, r := range seiRules { + if hasSentinelPrefix(text, r.prefix) { + return r.to(strings.TrimPrefix(strings.TrimPrefix(text, r.prefix), ": ")) + } + } + return untranslated(text) +} + +// hasSentinelPrefix reports whether text is sentinel or continues it at a word boundary, so that +// "nonce too low: next nonce 5, tx nonce 3" matches "nonce too low" and "mempool is full" does +// not match "mempool". +func hasSentinelPrefix(text, sentinel string) bool { + if !strings.HasPrefix(text, sentinel) { + return false + } + if len(text) == len(sentinel) { + return true + } + next, _ := utf8.DecodeRuneInString(text[len(sentinel):]) + return !unicode.IsLetter(next) && !unicode.IsDigit(next) +} + +func fromSentinel(s error, detail string) *Error { + message := s.Error() + if detail != "" { + message += ": " + detail + } + return &Error{code: CodeDefault, message: message, sentinel: s} +} + +func sentinel(s error) func(string) *Error { + return func(string) *Error { return fromSentinel(s, "") } +} + +func sentinelWithDetail(s error) func(string) *Error { + return func(detail string) *Error { return fromSentinel(s, detail) } +} + +// sentinelWithReason maps a Sei-only rejection onto the nearest go-ethereum sentinel, keeping +// the Sei condition as the detail. +func sentinelWithReason(s error, reason string) func(string) *Error { + return func(string) *Error { return fromSentinel(s, reason) } +} + +func literal(code int, message string) func(string) *Error { + return func(string) *Error { return &Error{code: code, message: message} } +} + +func timeout() *Error { + return &Error{code: CodeTimeout, message: msgTimeout} +} + +// internal maps a Sei condition a go-ethereum node cannot be in onto its internal error. +func internal(detail string) *Error { + logger.Debug("internal eth RPC error", "err", detail) + return &Error{code: CodeInternal, message: msgInternal} +} + +// badNonce maps the autobahn producer's strict-sequence rejection, "got N, want M", onto the +// txpool error for the direction of the mismatch. +func badNonce(detail string) *Error { + var got, want uint64 + if _, err := fmt.Sscanf(detail, "got %d, want %d", &got, &want); err != nil { + return untranslated("bad nonce: " + detail) + } + if got < want { + return fromSentinel(core.ErrNonceTooLow, fmt.Sprintf("next nonce %d, tx nonce %d", want, got)) + } + return fromSentinel(core.ErrNonceTooHigh, fmt.Sprintf("tx nonce %d, gapped nonce %d", got, want)) +} + +// untranslated is the fallback for an error the table does not know. The client sees an +// internal error; the original text is kept on the server side, where it is actionable. +func untranslated(text string) *Error { + untranslatedCount.Add(context.Background(), 1) + logger.Warn("untranslated eth RPC error", "err", text) + return &Error{code: CodeInternal, message: msgInternal} +} + +func must[V any](v V, err error) V { + if err != nil { + panic(err) + } + return v +} diff --git a/evmrpc/ethrpcerrors/errors_test.go b/evmrpc/ethrpcerrors/errors_test.go new file mode 100644 index 0000000000..9664ca2f1f --- /dev/null +++ b/evmrpc/ethrpcerrors/errors_test.go @@ -0,0 +1,713 @@ +package ethrpcerrors_test + +import ( + "context" + "errors" + "fmt" + "net/url" + "strings" + "testing" + + "github.com/ethereum/go-ethereum/core" + "github.com/ethereum/go-ethereum/core/txpool" + "github.com/ethereum/go-ethereum/core/txpool/legacypool" + ethtypes "github.com/ethereum/go-ethereum/core/types" + "github.com/ethereum/go-ethereum/rpc" + "github.com/stretchr/testify/require" + "go.opentelemetry.io/otel" + sdkmetric "go.opentelemetry.io/otel/sdk/metric" + "go.opentelemetry.io/otel/sdk/metric/metricdata" + + "github.com/sei-protocol/sei-chain/evmrpc/ethrpcerrors" + sdkerrors "github.com/sei-protocol/sei-chain/sei-cosmos/types/errors" +) + +const ( + msgInternal = "internal error" + msgTimeout = "request timed out" + msgUnprotected = "only replay-protected (EIP-155) transactions allowed over RPC" + msgEmptyAuthList = "set code tx must have at least one authorization tuple" + leakedDialURL = "http://10.0.0.1:8545" +) + +type goldenCase struct { + name string + in func() error + code int + message string +} + +func abci(codespace string, code uint32, log string) func() error { + return func() error { return ethrpcerrors.TranslateABCI(codespace, code, log) } +} + +func from(err error) func() error { + return func() error { return ethrpcerrors.Translate(err) } +} + +func fromText(msg string) func() error { + return from(errors.New(msg)) +} + +func withDetail(sentinel error, detail string) string { + if detail == "" { + return sentinel.Error() + } + return sentinel.Error() + ": " + detail +} + +func goldenCases() []goldenCase { + return []goldenCase{ + { + name: "abci wrong sequence", + in: abci("sdk", 32, "incorrect account sequence"), + code: -32000, + message: core.ErrNonceTooLow.Error(), + }, + { + name: "abci wrong sequence with next/tx nonce", + in: abci("sdk", 32, "next nonce 5, tx nonce 3: incorrect account sequence"), + code: -32000, + message: withDetail(core.ErrNonceTooLow, "next nonce 5, tx nonce 3"), + }, + { + name: "abci nonce too high detail", + in: abci("sdk", 32, "nonce too high: address 0xabc, tx: 7 state: 5: incorrect account sequence"), + code: -32000, + message: withDetail(core.ErrNonceTooHigh, "address 0xabc, tx: 7 state: 5"), + }, + { + name: "abci insufficient funds", + in: abci("sdk", 5, "insufficient funds"), + code: -32000, + message: core.ErrInsufficientFunds.Error(), + }, + { + name: "abci insufficient funds with address", + in: abci("sdk", 5, "insufficient funds for gas * price + value: address 0xabc have 0 want 21000: insufficient funds"), + code: -32000, + message: withDetail(core.ErrInsufficientFunds, "address 0xabc have 0 want 21000"), + }, + { + name: "abci insufficient fee", + in: abci("sdk", 13, "insufficient fee"), + code: -32000, + message: core.ErrFeeCapTooLow.Error(), + }, + { + name: "abci insufficient fee baseFee", + in: abci("sdk", 13, "address 0xabc, maxFeePerGas: 1, baseFee: 1000000000: insufficient fee"), + code: -32000, + message: withDetail(core.ErrFeeCapTooLow, "address 0xabc, maxFeePerGas: 1, baseFee: 1000000000"), + }, + { + name: "abci insufficient fee minimumFeePerGas", + in: abci("sdk", 13, "address 0xabc, maxFeePerGas: 1, minimumFeePerGas: 1000000000: insufficient fee"), + code: -32000, + message: withDetail(core.ErrFeeCapTooLow, "address 0xabc, maxFeePerGas: 1, minimumFeePerGas: 1000000000"), + }, + { + name: "abci unsupported tx type", + in: abci("sdk", 44, "unsupported transaction type"), + code: -32000, + message: core.ErrTxTypeNotSupported.Error(), + }, + { + name: "abci unsupported tx type with pool detail", + in: abci("sdk", 44, "tx type 4 not supported by this pool: unsupported transaction type"), + code: -32000, + message: withDetail(core.ErrTxTypeNotSupported, "tx type 4 not supported by this pool"), + }, + { + name: "abci invalid chain-id", + in: abci("sdk", 28, "invalid chain-id"), + code: -32000, + message: withDetail(txpool.ErrInvalidSender, ethtypes.ErrInvalidChainId.Error()), + }, + { + name: "abci invalid chain-id have/want", + in: abci("sdk", 28, "invalid chain id for signer: have 999999 want 1329: invalid chain-id"), + code: -32000, + message: withDetail(txpool.ErrInvalidSender, ethtypes.ErrInvalidChainId.Error()+": have 999999 want 1329"), + }, + { + name: "abci invalid v,r,s under chain-id", + in: abci("sdk", 28, "invalid transaction v, r, s values: invalid chain-id"), + code: -32000, + message: withDetail(txpool.ErrInvalidSender, ethtypes.ErrInvalidSig.Error()), + }, + { + name: "abci out of gas exceeds block limit", + in: abci("sdk", 11, "tx gas limit 20000000 exceeds block max gas 12500000: out of gas"), + code: -32000, + message: withDetail(txpool.ErrGasLimit, "tx gas limit 20000000 exceeds block max gas 12500000"), + }, + { + name: "abci invalid coins", + in: abci("sdk", 10, "invalid coins"), + code: -32000, + message: txpool.ErrNegativeValue.Error(), + }, + { + name: "abci tx already in mempool", + in: abci("sdk", 19, "tx already in mempool"), + code: -32000, + message: txpool.ErrAlreadyKnown.Error(), + }, + { + name: "abci mempool is full", + in: abci("sdk", 20, "mempool is full"), + code: -32000, + message: legacypool.ErrTxPoolOverflow.Error(), + }, + { + name: "abci tx too large", + in: abci("sdk", 21, "tx too large"), + code: -32000, + message: txpool.ErrOversizedData.Error(), + }, + { + name: "abci sdk code with no geth analogue", + in: abci("sdk", 18, "not EVM message: invalid request"), + code: -32603, + message: msgInternal, + }, + { + name: "abci sdk code 1 empty log", + in: abci("sdk", 1, ""), + code: -32603, + message: msgInternal, + }, + { + name: "abci foreign codespace", + in: abci("test", 3, "log"), + code: -32603, + message: msgInternal, + }, + { + name: "abci undefined intrinsic gas with detail", + in: abci("undefined", 1, "intrinsic gas too low: gas 1000, minimum needed 21000"), + code: -32000, + message: withDetail(core.ErrIntrinsicGas, "gas 1000, minimum needed 21000"), + }, + { + name: "abci undefined intrinsic gas", + in: abci("undefined", 1, "intrinsic gas too low"), + code: -32000, + message: core.ErrIntrinsicGas.Error(), + }, + { + name: "abci undefined floor data gas", + in: abci("undefined", 1, "insufficient gas for floor data gas cost: gas 40000, minimum needed 61000"), + code: -32000, + message: withDetail(core.ErrFloorDataGas, "gas 40000, minimum needed 61000"), + }, + { + name: "abci undefined max initcode size", + in: abci("undefined", 1, "max initcode size exceeded: code size 49153, limit 49152"), + code: -32000, + message: withDetail(core.ErrMaxInitCodeSizeExceeded, "code size 49153, limit 49152"), + }, + { + name: "abci undefined unsafe legacy tx", + in: abci("undefined", 1, "unsupported tx type: unsafe legacy tx"), + code: -32000, + message: msgUnprotected, + }, + { + name: "text empty set-code authorization list", + in: fromText("auth list cannot be empty"), + code: -32000, + message: msgEmptyAuthList, + }, + { + name: "text oversized signature v", + in: fromText("invalid v: too long"), + code: -32000, + message: withDetail(txpool.ErrInvalidSender, ethtypes.ErrInvalidSig.Error()), + }, + { + name: "text oversized signature r", + in: fromText("invalid r: too long"), + code: -32000, + message: withDetail(txpool.ErrInvalidSender, ethtypes.ErrInvalidSig.Error()), + }, + { + name: "text oversized signature s", + in: fromText("invalid s: too long"), + code: -32000, + message: withDetail(txpool.ErrInvalidSender, ethtypes.ErrInvalidSig.Error()), + }, + { + name: "abci undefined tip above fee cap", + in: abci("undefined", 1, "max priority fee per gas higher than max fee per gas (400000000000 > 200000000000)"), + code: -32000, + message: core.ErrTipAboveFeeCap.Error() + " (400000000000 > 200000000000)", + }, + { + name: "abci undefined fee out of bound", + in: abci("undefined", 1, "fee out of bound"), + code: -32000, + message: withDetail(core.ErrInsufficientFunds, "fee out of bound"), + }, + { + name: "abci undefined value overflow", + in: abci("undefined", 1, "value overflow"), + code: -32000, + message: withDetail(core.ErrInsufficientFunds, "value overflow"), + }, + { + name: "abci undefined gas price overflow", + in: abci("undefined", 1, "gas price overflow"), + code: -32000, + message: core.ErrFeeCapVeryHigh.Error(), + }, + { + name: "abci undefined gas tip cap overflow", + in: abci("undefined", 1, "gas tip cap overflow"), + code: -32000, + message: core.ErrTipVeryHigh.Error(), + }, + { + name: "abci undefined tx gas exceeds max", + in: abci("undefined", 1, "tx gas exceeds max"), + code: -32000, + message: txpool.ErrGasLimit.Error(), + }, + { + name: "abci oracle unauthorized voter", + in: abci("oracle", 5, "unauthorized voter"), + code: -32603, + message: msgInternal, + }, + { + name: "text tx already exists in cache", + in: fromText("tx already exists in cache"), + code: -32000, + message: txpool.ErrAlreadyKnown.Error(), + }, + { + name: "text duplicate tx", + in: fromText("duplicate tx"), + code: -32000, + message: txpool.ErrAlreadyKnown.Error(), + }, + { + name: "text tx with this nonce already in mempool", + in: fromText("tx with this nonce already in mempool"), + code: -32000, + message: txpool.ErrReplaceUnderpriced.Error(), + }, + { + name: "text tx too large classic", + in: fromText("tx too large: max size is 100, but got 200"), + code: -32000, + message: withDetail(txpool.ErrOversizedData, "max size is 100, but got 200"), + }, + { + name: "text tx too large alt", + in: fromText("tx too large: tx size is too big: 200, max: 100"), + code: -32000, + message: withDetail(txpool.ErrOversizedData, "tx size is too big: 200, max: 100"), + }, + { + name: "text nonce too old", + in: fromText("nonce too old"), + code: -32000, + message: core.ErrNonceTooLow.Error(), + }, + { + name: "text mempool full", + in: fromText("mempool full"), + code: -32000, + message: legacypool.ErrTxPoolOverflow.Error(), + }, + { + name: "text mempool is full", + in: fromText("mempool is full"), + code: -32000, + message: legacypool.ErrTxPoolOverflow.Error(), + }, + { + name: "text priority not high enough", + in: fromText("priority not high enough for mempool"), + code: -32000, + message: txpool.ErrUnderpriced.Error(), + }, + { + name: "text gas wanted exceeds max gas", + in: fromText("gas wanted exceeds max gas: gas wanted 30000000 is greater than max gas 12500000"), + code: -32000, + message: withDetail(txpool.ErrGasLimit, "gas wanted 30000000 is greater than max gas 12500000"), + }, + { + name: "text transaction too large", + in: fromText("transaction too large"), + code: -32000, + message: txpool.ErrOversizedData.Error(), + }, + { + name: "text bad nonce too low", + in: fromText("bad nonce: got 3, want 5"), + code: -32000, + message: withDetail(core.ErrNonceTooLow, "next nonce 5, tx nonce 3"), + }, + { + name: "text bad nonce too high", + in: fromText("bad nonce: got 9, want 5"), + code: -32000, + message: withDetail(core.ErrNonceTooHigh, "tx nonce 9, gapped nonce 5"), + }, + { + name: "text bad nonce garbage", + in: fromText("bad nonce: garbage"), + code: -32603, + message: msgInternal, + }, + { + name: "text not producing", + in: fromText("not producing"), + code: -32603, + message: msgInternal, + }, + { + name: "text autobahn fullnode no mempool", + in: fromText("autobahn fullnode has no local mempool; broadcast_tx_* must be sent to a validator"), + code: -32603, + message: msgInternal, + }, + { + name: "text context canceled", + in: fromText("context canceled"), + code: -32002, + message: msgTimeout, + }, + { + name: "text context deadline exceeded", + in: fromText("context deadline exceeded"), + code: -32002, + message: msgTimeout, + }, + { + name: "text timeout waiting for commit", + in: fromText("timeout waiting for commit of tx 0xabc (1.5s)"), + code: -32002, + message: msgTimeout, + }, + { + name: "text panic recovered in CheckTxSafe", + in: fromText("panic recovered in CheckTxSafe"), + code: -32603, + message: msgInternal, + }, + { + name: "text nil response", + in: fromText("nil response"), + code: -32603, + message: msgInternal, + }, + { + name: "text EVM response missing EVMHash", + in: fromText("EVM response missing EVMHash"), + code: -32603, + message: msgInternal, + }, + { + name: "text EVM response missing SeiSenderAddress", + in: fromText("EVM response missing SeiSenderAddress"), + code: -32603, + message: msgInternal, + }, + { + name: "text missing broadcast response", + in: fromText("missing broadcast response"), + code: -32603, + message: msgInternal, + }, + { + name: "text mempool is not available", + in: fromText("mempool is not available"), + code: -32603, + message: msgInternal, + }, + { + name: "text kvEventSink not enabled", + in: fromText("cannot confirm transaction because kvEventSink is not enabled"), + code: -32603, + message: msgInternal, + }, + { + name: "text broadcast_tx_commit unsupported", + in: fromText("broadcast_tx_commit is not supported on Autobahn; use broadcast_tx_sync"), + code: -32603, + message: msgInternal, + }, + { + name: "text txConstraintsFetcher", + in: fromText("txmp.txConstraintsFetcher(): boom"), + code: -32603, + message: msgInternal, + }, + { + name: "text negative gas wanted", + in: fromText("negative gas wanted: -5"), + code: -32603, + message: msgInternal, + }, + { + name: "url.Error connection refused", + in: from(&url.Error{ + Op: "Post", + URL: leakedDialURL, + Err: errors.New("dial tcp: connection refused"), + }), + code: -32603, + message: msgInternal, + }, + { + name: "rpc.HTTPError 502", + in: from(&rpc.HTTPError{ + StatusCode: 502, + Status: "502 Bad Gateway", + Body: []byte("upstream"), + }), + code: -32603, + message: msgInternal, + }, + { + name: "text unknown mempoolish", + in: fromText("mempoolish"), + code: -32603, + message: msgInternal, + }, + { + name: "context.DeadlineExceeded", + in: from(context.DeadlineExceeded), + code: -32002, + message: msgTimeout, + }, + { + name: "wrapped context.Canceled", + in: from(fmt.Errorf("wait: %w", context.Canceled)), + code: -32002, + message: msgTimeout, + }, + { + name: "sdk ErrWrongSequence", + in: from(sdkerrors.ErrWrongSequence), + code: -32000, + message: core.ErrNonceTooLow.Error(), + }, + { + name: "sdk Wrapf ErrWrongSequence", + in: from(sdkerrors.Wrapf(sdkerrors.ErrWrongSequence, "next nonce %d, tx nonce %d", 5, 3)), + code: -32000, + message: withDetail(core.ErrNonceTooLow, "next nonce 5, tx nonce 3"), + }, + { + name: "sdk Wrap ErrInsufficientFunds", + in: from(sdkerrors.Wrap(sdkerrors.ErrInsufficientFunds, + "insufficient funds for gas * price + value: address 0xabc have 0 want 21000")), + code: -32000, + message: withDetail(core.ErrInsufficientFunds, "address 0xabc have 0 want 21000"), + }, + { + name: "wrapped core.ErrIntrinsicGas", + in: from(fmt.Errorf("%w: gas %v, minimum needed %v", core.ErrIntrinsicGas, 1000, 21000)), + code: -32000, + message: withDetail(core.ErrIntrinsicGas, "gas 1000, minimum needed 21000"), + }, + } +} + +func assertTranslated(t *testing.T, err error, code int, message string) { + t.Helper() + require.NotNil(t, err) + require.IsType(t, (*ethrpcerrors.Error)(nil), err) + rpcErr, ok := err.(rpc.Error) + require.True(t, ok) + require.Equal(t, code, rpcErr.ErrorCode()) + require.Equal(t, message, err.Error()) + dataErr, ok := err.(rpc.DataError) + require.True(t, ok) + require.Nil(t, dataErr.ErrorData()) +} + +func TestGolden(t *testing.T) { + for _, tc := range goldenCases() { + t.Run(tc.name, func(t *testing.T) { + err := tc.in() + assertTranslated(t, err, tc.code, tc.message) + if tc.name == "url.Error connection refused" { + require.NotContains(t, err.Error(), leakedDialURL) + } + }) + } +} + +func TestUnwrapsGethSentinel(t *testing.T) { + require.ErrorIs(t, ethrpcerrors.Translate(sdkerrors.ErrWrongSequence), core.ErrNonceTooLow) + require.ErrorIs(t, ethrpcerrors.Translate(sdkerrors.ErrInsufficientFee), core.ErrFeeCapTooLow) + require.ErrorIs(t, ethrpcerrors.Translate(errors.New("tx already exists in cache")), txpool.ErrAlreadyKnown) + require.ErrorIs(t, ethrpcerrors.Translate(errors.New("mempool full")), legacypool.ErrTxPoolOverflow) + require.Nil(t, errors.Unwrap(ethrpcerrors.Translate(errors.New("mempoolish")))) +} + +type alreadyRPCError struct { + code int + msg string +} + +func (e *alreadyRPCError) Error() string { return e.msg } +func (e *alreadyRPCError) ErrorCode() int { return e.code } + +func TestPassThrough(t *testing.T) { + require.Nil(t, ethrpcerrors.Translate(nil)) + require.Nil(t, ethrpcerrors.TranslateABCI("sdk", 0, "")) + + in := &alreadyRPCError{code: 3, msg: "already a json-rpc error"} + require.Same(t, in, ethrpcerrors.Translate(in)) + + httpErr := &rpc.HTTPError{StatusCode: 502, Status: "502 Bad Gateway", Body: []byte("upstream")} + got := ethrpcerrors.Translate(httpErr) + require.NotSame(t, httpErr, got) + assertTranslated(t, got, -32603, msgInternal) +} + +var cosmosVocabulary = []string{ + "incorrect account sequence", + "insufficient fee", + "invalid coins", + "unknown request", + "tx parse error", + "codespace", + "rpc error: code =", + "panic recovered in CheckTxSafe", + "bad nonce", + "mempool is full", + "mempool full", + "not producing", + "transaction too large", + "tx too large", + "nil response", + "EVM response missing", + "missing broadcast response", + "broadcast_tx_commit is not supported", + "transaction rejected with code", + `Post "`, + "unsupported transaction type", + "invalid chain-id", + "out of gas", + "tx already exists in cache", + "unsafe legacy tx", + "auth list cannot be empty", + "invalid v: too long", + "invalid r: too long", + "invalid s: too long", + "unknown", +} + +func TestNoCosmosVocabulary(t *testing.T) { + for _, tc := range goldenCases() { + t.Run(tc.name, func(t *testing.T) { + err := tc.in() + require.NotNil(t, err) + msg := err.Error() + require.NotEmpty(t, msg) + require.False(t, strings.HasPrefix(msg, ": ")) + for _, banned := range cosmosVocabulary { + require.NotContains(t, msg, banned) + } + }) + } +} + +type roundTripService struct{} + +func (*roundTripService) Translated(context.Context) (string, error) { + return "", ethrpcerrors.TranslateABCI("sdk", 32, "next nonce 5, tx nonce 3: incorrect account sequence") +} + +func (*roundTripService) Internal(context.Context) (string, error) { + return "", ethrpcerrors.Translate(errors.New("mempoolish")) +} + +func (*roundTripService) Wrapped(context.Context) (string, error) { + return "", fmt.Errorf("outer: %w", ethrpcerrors.TranslateABCI("sdk", 32, "incorrect account sequence")) +} + +func TestJSONRPCRoundTrip(t *testing.T) { + srv := rpc.NewServer() + t.Cleanup(srv.Stop) + require.NoError(t, srv.RegisterName("test", &roundTripService{})) + client := rpc.DialInProc(srv) + t.Cleanup(client.Close) + + t.Run("translated", func(t *testing.T) { + var res string + err := client.CallContext(t.Context(), &res, "test_translated") + rpcErr, ok := err.(rpc.Error) + require.True(t, ok) + require.Equal(t, -32000, rpcErr.ErrorCode()) + require.Equal(t, withDetail(core.ErrNonceTooLow, "next nonce 5, tx nonce 3"), err.Error()) + dataErr, ok := err.(rpc.DataError) + require.True(t, ok) + require.Nil(t, dataErr.ErrorData()) + }) + t.Run("internal", func(t *testing.T) { + var res string + err := client.CallContext(t.Context(), &res, "test_internal") + rpcErr, ok := err.(rpc.Error) + require.True(t, ok) + require.Equal(t, -32603, rpcErr.ErrorCode()) + require.Equal(t, msgInternal, err.Error()) + }) + t.Run("wrapped", func(t *testing.T) { + var res string + err := client.CallContext(t.Context(), &res, "test_wrapped") + rpcErr, ok := err.(rpc.Error) + require.True(t, ok) + // A wrapped *ethrpcerrors.Error loses its ErrorCode; go-ethereum then + // encodes the default -32000. Translate must be the top-level return. + require.Equal(t, -32000, rpcErr.ErrorCode()) + require.Equal(t, "outer: "+core.ErrNonceTooLow.Error(), err.Error()) + }) +} + +func untranslatedTotal(t *testing.T, reader *sdkmetric.ManualReader) int64 { + t.Helper() + var rm metricdata.ResourceMetrics + require.NoError(t, reader.Collect(t.Context(), &rm)) + for _, sm := range rm.ScopeMetrics { + for _, m := range sm.Metrics { + if m.Name != "evmrpc_untranslated_error_total" { + continue + } + sum, ok := m.Data.(metricdata.Sum[int64]) + require.True(t, ok) + var total int64 + for _, dp := range sum.DataPoints { + total += dp.Value + } + return total + } + } + return 0 +} + +func TestUntranslatedCounter(t *testing.T) { + reader := sdkmetric.NewManualReader() + provider := sdkmetric.NewMeterProvider(sdkmetric.WithReader(reader)) + prev := otel.GetMeterProvider() + otel.SetMeterProvider(provider) + t.Cleanup(func() { + _ = provider.Shutdown(t.Context()) + otel.SetMeterProvider(prev) + }) + + before := untranslatedTotal(t, reader) + _ = ethrpcerrors.Translate(errors.New("unknown xyz")) + _ = ethrpcerrors.Translate(errors.New("unknown xyz")) + after := untranslatedTotal(t, reader) + require.Equal(t, before+2, after) +} diff --git a/evmrpc/send.go b/evmrpc/send.go index 7a217f4251..a488bb1857 100644 --- a/evmrpc/send.go +++ b/evmrpc/send.go @@ -16,11 +16,11 @@ import ( "github.com/ethereum/go-ethereum/signer/core/apitypes" "github.com/sei-protocol/sei-chain/app/legacyabci" + "github.com/sei-protocol/sei-chain/evmrpc/ethrpcerrors" "github.com/sei-protocol/sei-chain/precompiles/wasmd" "github.com/sei-protocol/sei-chain/sei-cosmos/baseapp" "github.com/sei-protocol/sei-chain/sei-cosmos/client" sdk "github.com/sei-protocol/sei-chain/sei-cosmos/types" - sdkerrors "github.com/sei-protocol/sei-chain/sei-cosmos/types/errors" "github.com/sei-protocol/sei-chain/sei-tendermint/libs/utils" "github.com/sei-protocol/sei-chain/x/evm/keeper" "github.com/sei-protocol/sei-chain/x/evm/types" @@ -91,32 +91,65 @@ func (s *SendAPI) SendRawTransaction(ctx context.Context, input hexutil.Bytes) ( recordMetricsWithError(ctx, "eth_sendRawTransaction", s.connectionType, startTime, err, recover()) }() tx := new(ethtypes.Transaction) + // go-ethereum's own decode errors are already what a client expects; they are returned untranslated. if err = tx.UnmarshalBinary(input); err != nil { return } hash = tx.Hash() + // Every error past decoding is Sei's. One translation at the exit covers each submit branch, + // including branches added later, and runs before the deferred metric reads err. + err = ethrpcerrors.Translate(s.submit(ctx, tx, input)) + return +} + +func (s *SendAPI) submit(ctx context.Context, tx *ethtypes.Transaction, input hexutil.Bytes) error { // getSender fails for AccessListTx, in which case we are not able to proxy or simulate, // but we still need to handle it. sender, senderErr := getSender(tx, s.keeper.ChainID(s.ctxProvider(LatestCtxHeight))) if senderErr == nil { if client, ok := s.tmClient.EvmProxy(sender).Get(); ok { recordRedirectedRequest(ctx, "eth_sendRawTransaction", string(s.connectionType)) + var remoteHash common.Hash + return client.CallContext(ctx, &remoteHash, "eth_sendRawTransaction", input) + } + } + + txbz, err := s.encodeCosmosTx(ctx, tx, sender, senderErr) + if err != nil { + return err + } - if err := client.CallContext(ctx, &hash, "eth_sendRawTransaction", input); err != nil { - // No error wrapping, because evm server is too dumb to handle wrapped error. - return hash, err - } - return hash, nil + // Autobahn rejects BroadcastTxCommit before InsertTx. evm.slow still + // submits via BroadcastTx so eth_sendRawTransaction lands the tx; only + // seid -b block / broadcast_tx_commit itself fail-fasts. + if s.sendConfig.slow && !s.sendConfig.autobahn { + res, err := s.tmClient.BroadcastTxCommit(ctx, txbz) + if err != nil { + return err } + if res == nil { + return errors.New("missing broadcast response") + } + return ethrpcerrors.TranslateABCI(res.CheckTx.Codespace, res.CheckTx.Code, res.CheckTx.Log) + } + res, err := s.tmClient.BroadcastTx(ctx, txbz) + if err != nil { + return err + } + if res == nil { + return errors.New("missing broadcast response") } + return ethrpcerrors.TranslateABCI(res.Codespace, res.Code, res.Log) +} +func (s *SendAPI) encodeCosmosTx(ctx context.Context, tx *ethtypes.Transaction, sender common.Address, senderErr error) ([]byte, error) { txData, err := ethtx.NewTxDataFromTx(tx) if err != nil { - return hash, err + return nil, err } msg, err := types.NewMsgEVMTransaction(txData) if err != nil { - return hash, err + return nil, err } gasUsedEstimate := tx.Gas() // if issue simulating, fallback to gas limit if s.sendConfig.enableSimulation && senderErr == nil { // simulation requires sender. @@ -126,37 +159,10 @@ func (s *SendAPI) SendRawTransaction(ctx context.Context, input hexutil.Bytes) ( } txBuilder := s.txConfigProvider(LatestCtxHeight).NewTxBuilder() if err := txBuilder.SetMsgs(msg); err != nil { - return hash, err + return nil, err } txBuilder.SetGasEstimate(gasUsedEstimate) - txbz, encodeErr := s.txConfigProvider(LatestCtxHeight).TxEncoder()(txBuilder.GetTx()) - if encodeErr != nil { - return hash, encodeErr - } - - // Autobahn rejects BroadcastTxCommit before InsertTx. evm.slow still - // submits via BroadcastTx so eth_sendRawTransaction lands the tx; only - // seid -b block / broadcast_tx_commit itself fail-fasts. - if s.sendConfig.slow && !s.sendConfig.autobahn { - res, broadcastError := s.tmClient.BroadcastTxCommit(ctx, txbz) - if broadcastError != nil { - err = broadcastError - } else if res == nil { - err = errors.New("missing broadcast response") - } else if res.CheckTx.Code != 0 { - err = sdkerrors.ABCIError(sdkerrors.RootCodespace, res.CheckTx.Code, "") - } - } else { - res, broadcastError := s.tmClient.BroadcastTx(ctx, txbz) - if broadcastError != nil { - err = broadcastError - } else if res == nil { - err = errors.New("missing broadcast response") - } else if res.Code != 0 { - err = sdkerrors.ABCIError(sdkerrors.RootCodespace, res.Code, "") - } - } - return + return s.txConfigProvider(LatestCtxHeight).TxEncoder()(txBuilder.GetTx()) } func getSender(tx *ethtypes.Transaction, chainID *big.Int) (common.Address, error) { diff --git a/evmrpc/send_test.go b/evmrpc/send_test.go index 39648f86e5..7611b1fa82 100644 --- a/evmrpc/send_test.go +++ b/evmrpc/send_test.go @@ -4,17 +4,21 @@ import ( "context" "encoding/hex" "encoding/json" + "errors" "math/big" "net/http" "net/http/httptest" + "strings" "testing" "time" "github.com/ethereum/go-ethereum/common" "github.com/ethereum/go-ethereum/common/hexutil" + "github.com/ethereum/go-ethereum/core" ethtypes "github.com/ethereum/go-ethereum/core/types" "github.com/ethereum/go-ethereum/crypto" "github.com/ethereum/go-ethereum/rpc" + "github.com/holiman/uint256" "github.com/stretchr/testify/require" legacyabci "github.com/sei-protocol/sei-chain/app/legacyabci" @@ -22,6 +26,7 @@ import ( "github.com/sei-protocol/sei-chain/sei-cosmos/client" "github.com/sei-protocol/sei-chain/sei-cosmos/crypto/hd" sdk "github.com/sei-protocol/sei-chain/sei-cosmos/types" + abci "github.com/sei-protocol/sei-chain/sei-tendermint/abci/types" "github.com/sei-protocol/sei-chain/sei-tendermint/libs/utils" "github.com/sei-protocol/sei-chain/sei-tendermint/rpc/coretypes" tmtypes "github.com/sei-protocol/sei-chain/sei-tendermint/types" @@ -59,6 +64,34 @@ func (c *sendCaptureClient) BroadcastTxCommit(_ context.Context, tx tmtypes.Tx) return &coretypes.ResultBroadcastTxCommit{}, nil } +type sendRejectClient struct { + *MockClient + res *coretypes.ResultBroadcastTx + err error +} + +func (c *sendRejectClient) BroadcastTx(context.Context, tmtypes.Tx) (*coretypes.ResultBroadcastTx, error) { + return c.res, c.err +} + +type sendCommitRejectClient struct { + *MockClient + res *coretypes.ResultBroadcastTxCommit + err error +} + +func (c *sendCommitRejectClient) BroadcastTxCommit(context.Context, tmtypes.Tx) (*coretypes.ResultBroadcastTxCommit, error) { + return c.res, c.err +} + +func requireRPCError(t *testing.T, err error, code int, message string) { + t.Helper() + rpcErr, ok := err.(rpc.Error) + require.True(t, ok, "err should implement rpc.Error, got %T: %v", err, err) + require.Equal(t, code, rpcErr.ErrorCode()) + require.Equal(t, message, rpcErr.Error()) +} + func newTestSendAPI(tmClient client.LocalClient, sendConfig *evmrpc.SendConfig) *evmrpc.SendAPI { return evmrpc.NewSendAPI( tmClient, @@ -126,7 +159,8 @@ func TestSendRawTransaction(t *testing.T) { // bad server resObj = sendRequestBad(t, "sendRawTransaction", payload) errMap = resObj["error"].(map[string]interface{}) - require.Equal(t, ": invalid sequence", errMap["message"].(string)) + require.Equal(t, "internal error", errMap["message"].(string)) + require.Equal(t, float64(-32603), errMap["code"].(float64)) } func TestSendRawTransactionUsesProxy(t *testing.T) { @@ -249,6 +283,169 @@ func TestSendRawTransactionSlowOnCometUsesBroadcastTxCommit(t *testing.T) { require.Equal(t, 1, tmClient.commitCount) } +func TestSendRawTransactionTranslatesBroadcastErrors(t *testing.T) { + ethTxBytes, _ := mustSignTestTx(t) + tests := []struct { + name string + res *coretypes.ResultBroadcastTx + broadcast error + code int + message string + sentinel error + }{ + { + name: "nonce too low", + res: &coretypes.ResultBroadcastTx{Codespace: "sdk", Code: 32, Log: "next nonce 5, tx nonce 3: incorrect account sequence"}, + code: -32000, + message: "nonce too low: next nonce 5, tx nonce 3", + sentinel: core.ErrNonceTooLow, + }, + { + name: "insufficient fee", + res: &coretypes.ResultBroadcastTx{Codespace: "sdk", Code: 13, Log: "insufficient fee"}, + code: -32000, + message: "max fee per gas less than block base fee", + }, + { + name: "already known", + broadcast: errors.New("tx already exists in cache"), + code: -32000, + message: "already known", + }, + { + name: "unmapped broadcast error", + broadcast: errors.New("some unexpected internal thing"), + code: -32603, + message: "internal error", + }, + { + name: "missing broadcast response", + code: -32603, + message: "internal error", + }, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + sendAPI := newTestSendAPI( + &sendRejectClient{MockClient: &MockClient{}, res: tc.res, err: tc.broadcast}, + evmrpc.NewSendConfig(false, false, false), + ) + _, err := sendAPI.SendRawTransaction(context.Background(), hexutil.Bytes(ethTxBytes)) + requireRPCError(t, err, tc.code, tc.message) + if tc.sentinel != nil { + require.True(t, errors.Is(err, tc.sentinel)) + } + }) + } +} + +func TestSendRawTransactionSlowCommitTranslatesNonceTooLow(t *testing.T) { + ethTxBytes, _ := mustSignTestTx(t) + sendAPI := newTestSendAPI( + &sendCommitRejectClient{ + MockClient: &MockClient{}, + res: &coretypes.ResultBroadcastTxCommit{ + CheckTx: abci.ResponseCheckTx{ + Codespace: "sdk", + Code: 32, + Log: "next nonce 5, tx nonce 3: incorrect account sequence", + }, + }, + }, + evmrpc.NewSendConfig(true, false, false), + ) + _, err := sendAPI.SendRawTransaction(context.Background(), hexutil.Bytes(ethTxBytes)) + requireRPCError(t, err, -32000, "nonce too low: next nonce 5, tx nonce 3") +} + +func TestSendRawTransactionProxyPassesThroughRemoteError(t *testing.T) { + ethTxBytes, _ := mustSignTestTx(t) + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + defer r.Body.Close() + w.Header().Set("Content-Type", "application/json") + require.NoError(t, json.NewEncoder(w).Encode(map[string]any{ + "jsonrpc": "2.0", + "id": 1, + "error": map[string]any{ + "code": -32000, + "message": "nonce too low: next nonce 5, tx nonce 3", + }, + })) + })) + defer server.Close() + + proxyClient, err := rpc.DialContext(t.Context(), server.URL) + require.NoError(t, err) + t.Cleanup(proxyClient.Close) + + sendAPI := newTestSendAPI( + &sendProxyClient{MockClient: &MockClient{}, proxyClient: proxyClient}, + &evmrpc.SendConfig{}, + ) + _, err = sendAPI.SendRawTransaction(context.Background(), hexutil.Bytes(ethTxBytes)) + requireRPCError(t, err, -32000, "nonce too low: next nonce 5, tx nonce 3") +} + +func TestSendRawTransactionProxyHidesTransportURL(t *testing.T) { + ethTxBytes, _ := mustSignTestTx(t) + server := httptest.NewServer(http.HandlerFunc(func(http.ResponseWriter, *http.Request) {})) + proxyClient, err := rpc.DialContext(t.Context(), server.URL) + require.NoError(t, err) + t.Cleanup(proxyClient.Close) + server.Close() + + sendAPI := newTestSendAPI( + &sendProxyClient{MockClient: &MockClient{}, proxyClient: proxyClient}, + &evmrpc.SendConfig{}, + ) + _, err = sendAPI.SendRawTransaction(context.Background(), hexutil.Bytes(ethTxBytes)) + requireRPCError(t, err, -32603, "internal error") + require.False(t, strings.Contains(err.Error(), server.URL)) +} + +func TestSendRawTransactionMapsEmptySetCodeAuthorizationList(t *testing.T) { + chainID := EVMKeeper.ChainID(Ctx) + key, err := crypto.HexToECDSA(strings.Repeat("46", 32)) + require.NoError(t, err) + tx, err := ethtypes.SignTx(ethtypes.NewTx(ðtypes.SetCodeTx{ + ChainID: uint256.MustFromBig(chainID), + GasTipCap: uint256.NewInt(1), + GasFeeCap: uint256.NewInt(2), + Gas: 21000, + To: common.Address{1}, + Value: uint256.NewInt(0), + AuthList: nil, + }), ethtypes.NewPragueSigner(chainID), key) + require.NoError(t, err) + raw, err := tx.MarshalBinary() + require.NoError(t, err) + + sendAPI := newTestSendAPI(&MockClient{}, evmrpc.NewSendConfig(false, false, false)) + _, err = sendAPI.SendRawTransaction(t.Context(), raw) + requireRPCError(t, err, -32000, "set code tx must have at least one authorization tuple") +} + +func TestSendRawTransactionMapsOversizedSignature(t *testing.T) { + chainID := EVMKeeper.ChainID(Ctx) + v := new(big.Int).Add(new(big.Int).Mul(chainID, big.NewInt(2)), big.NewInt(35)) + to := common.Address{1} + tx := ethtypes.NewTx(ðtypes.LegacyTx{ + GasPrice: big.NewInt(1), + Gas: 21000, + To: &to, + Value: new(big.Int), + V: v, + R: new(big.Int).Lsh(big.NewInt(1), 256), + S: big.NewInt(1), + }) + raw, err := tx.MarshalBinary() + require.NoError(t, err) + + sendAPI := newTestSendAPI(&MockClient{}, evmrpc.NewSendConfig(false, false, false)) + _, err = sendAPI.SendRawTransaction(t.Context(), raw) + requireRPCError(t, err, -32000, "invalid sender: invalid transaction v, r, s values") +} + func mustSignTestTx(t *testing.T) ([]byte, *ethtypes.Transaction) { t.Helper() to := common.HexToAddress("010203") diff --git a/evmrpc/tracers_test.go b/evmrpc/tracers_test.go index be23891fc8..ab5fe07feb 100644 --- a/evmrpc/tracers_test.go +++ b/evmrpc/tracers_test.go @@ -40,7 +40,7 @@ func TestTraceTransaction(t *testing.T) { require.Equal(t, "0x5b4eba929f3811980f5ae0c5d04fa200f837df4e", strings.ToLower(result["from"].(string))) require.Equal(t, "0x3e8", result["gas"]) require.Equal(t, "0x616263", result["input"]) // hex of "abc" (Data field in test tx) - require.Contains(t, result["error"].(string), "intrinsic gas too low") + require.Equal(t, "transaction failed ante handler due to intrinsic gas too low", result["error"]) require.Equal(t, "0x0000000000000000000000000000000000010203", result["to"]) if callType, ok := result["type"]; ok { require.Equal(t, "CALL", callType) diff --git a/integration_test/evm_module/rpc_io_test/RPC_IO_README.md b/integration_test/evm_module/rpc_io_test/RPC_IO_README.md index 2d1d6534f7..c256247035 100644 --- a/integration_test/evm_module/rpc_io_test/RPC_IO_README.md +++ b/integration_test/evm_module/rpc_io_test/RPC_IO_README.md @@ -5,7 +5,7 @@ Integration tests for Sei EVM RPC compatibility with Ethereum JSON-RPC. The suit ### `.io` vs `.iox` - **`.io`** - vanilla JSON-RPC fixtures (`>>` / `<<`): no Sei-specific harness directives such as `@ expect_body_contains`. -- **`.iox`** - same line format, plus Sei extensions: `@ bind` / `<< @ ref_pair`, `@ expect_body_contains`, `@ expect_response_header`, `not-supported.iox` (documented `-32000` errors), and other non-vanilla tags the parser accepts. +- **`.iox`** - same line format, plus Sei extensions: `@ bind` / `<< @ ref_pair`, response/error assertions, `not-supported.iox` (documented `-32000` errors), and other non-vanilla tags the parser accepts. ## How to run @@ -19,6 +19,8 @@ When the target is localhost, the script sends one EVM tx and deploys one contra **Legacy `sei_*` gating:** The docker localnet `app.toml` enables every remaining gated method. Deprecation is asserted in `testdata/sei_legacy_deprecation/*.iox`: **gate errors** use `error.data` `legacy_sei_deprecated` and messages mentioning disabled + deprecated; **forwarded** allowlisted calls use `@ expect_response_header Sei-Legacy-RPC-Deprecation`, including when the inner JSON-RPC handler returns an error. **`batch-nonobject-tail-gate.iox`** posts a JSON-RPC batch with an unregistered `sei_*` method and a trailing non-object; it asserts `legacy_sei_deprecated`, `Invalid Request`, and `-32600` in the raw body. Directives: - `@ expect_body_contains substring` - response body must contain the substring. +- `@ expect_error_code N` - response must contain a JSON-RPC error with exactly this code. +- `@ expect_error_message message` - response must contain a JSON-RPC error with exactly this message. - `@ expect_response_header Header-Name` - response must include that HTTP header (case-insensitive lookup). Production `seid init` defaults remain the three-method allowlist (`sei_getSeiAddress`, `sei_getEVMAddress`, `sei_getCosmosTx`). @@ -70,9 +72,9 @@ For a fair comparison, both endpoints should serve the **same chain** (same gene | Kind | Count | Description | | --------- | ----- | ---------------------------------------------------------------------------------------------------------------------------------------- | -| **.io** | 96 | Request/response fixtures; curated from [ethereum/execution-apis](https://github.com/ethereum/execution-apis) plus Sei-added. | -| **.iox** | 60 | Sei-generated; use `@ bind` and optional `@ ref_pair N` so data comes from a first request; includes `not-supported.iox`, `sei_legacy_deprecation/*.iox`. | -| **Total** | 156 | All under `testdata/`; runner executes every .io and .iox file. | +| **.io** | 90 | Request/response fixtures; curated from [ethereum/execution-apis](https://github.com/ethereum/execution-apis) plus Sei-added. | +| **.iox** | 64 | Sei-generated; use `@ bind` and optional `@ ref_pair N` so data comes from a first request; includes `not-supported.iox`, `sei_legacy_deprecation/*.iox`. | +| **Total** | 154 | All under `testdata/`; runner executes every .io and .iox file. | Fixtures live in `testdata/`; see `testdata/README.md` (do not overwrite with a raw copy from execution-apis). @@ -88,11 +90,11 @@ The following fixtures were **removed** (no longer in the suite) because they de | `eth_estimateGas/estimate-call-abi-error.io` | Same fixed address, expects revert error | `eth_estimateGas/estimate-call-abi-error-sei.iox` (uses `__REVERTER__`) | | `eth_estimateGas/estimate-failed-call.io` | Fixed address `0x17e7ee...`, expects revert error | Revert (Error) and panic covered by `estimate-call-abi-error-sei.iox` and `estimate-call-abi-panic-sei.iox` (same `__REVERTER__`, input `0x01` / `0x02`) | -The total count reflects the current `.io`/`.iox` set under `testdata/` (156 files: main baseline plus three sei deprecation `.iox`, including batch regression). +The total count reflects the current `.io`/`.iox` set under `testdata/` (154 files: main baseline plus three sei deprecation `.iox`, including batch regression). ## What is checked -**Spec-only:** For each request/response pair, the runner only checks that the response *kind* matches the expected one: presence of `result` vs `error`. Response values are not compared. +**Spec-only:** For each request/response pair, the runner checks that the response *kind* matches the expected one: presence of `result` vs `error`. Response values are not compared unless an `.iox` assertion directive requires it. ## Outcomes @@ -311,11 +313,11 @@ Use a comma-separated list to run up to a few files, e.g. `debug_getRawTransacti | Metric | Count | | ------------------------------------ | -------------------------------------------------------------------------------------------------------------------------- | -| **Total endpoint folders** | 69 | -| **Endpoint folders with ≥1 passing test** | 69 | +| **Total endpoint folders** | 57 | +| **Endpoint folders with ≥1 passing test** | 57 | | **Missing / untested endpoints** | None in this suite. Count = top-level directories under `testdata/` (each has ≥1 `.io`/`.iox`). On **eth parity + legacy batch gate** runs, every folder has at least one passing test. | -**eth_simulateV1**: that folder (1 endpoint, 64 fixtures) is no longer under `testdata/`, it was removed, so the current suite has **69** top-level endpoint folders under `testdata/`. +**eth_simulateV1**: that folder (1 endpoint, 64 fixtures) is no longer under `testdata/`, it was removed, so the current suite has **57** top-level endpoint folders under `testdata/`. *Re-run `./integration_test/evm_module/scripts/evm_rpc_tests.sh` to refresh counts; **sei_* fix** through **eth parity + legacy batch gate (Mar 2026)** columns assume docker localnet with expanded `[evm].enabled_legacy_sei_apis` (see `docker/localnode/config/app.toml`) and a node image that includes the `evmrpc` parity + legacy batch gate fixes.* diff --git a/integration_test/evm_module/rpc_io_test/io.go b/integration_test/evm_module/rpc_io_test/io.go index b77ea64fff..7f976e54d1 100644 --- a/integration_test/evm_module/rpc_io_test/io.go +++ b/integration_test/evm_module/rpc_io_test/io.go @@ -8,6 +8,7 @@ import ( "os" "path/filepath" "sort" + "strconv" "strings" "sync" "testing" @@ -45,6 +46,9 @@ type ioxPair struct { // ExpectBodyContains: each substring must appear in the response body (UTF-8). ExpectBodyContains []string + ExpectErrorCode *int + ExpectErrorMessage *string + // ExpectResponseHeaders: each name must be present on the HTTP response (case-insensitive). ExpectResponseHeaders []string } @@ -52,7 +56,8 @@ type ioxPair struct { // parseIOFile parses .io/.iox content. Markers are >>, <<, and @; optional ASCII whitespace after // each marker is trimmed away with the rest of the payload (TrimSpace). // Supports ">> request", "<< expected", "@ bind var = path", -// "<< @ ref_pair N", "@ expect_body_contains substring", "@ expect_response_header Header-Name". +// "<< @ ref_pair N", "@ expect_body_contains substring", "@ expect_error_code N", +// "@ expect_error_message message", and "@ expect_response_header Header-Name". func parseIOFile(content string) ([]ioxPair, error) { var pairs []ioxPair var curReq []byte @@ -120,6 +125,22 @@ func parseIOFile(content string) ([]ioxPair, error) { pairs[lastIdx].ExpectBodyContains = append(pairs[lastIdx].ExpectBodyContains, sub) continue } + if after, ok := strings.CutPrefix(rest, "expect_error_code "); ok { + code, err := strconv.Atoi(strings.TrimSpace(after)) + if err != nil { + return nil, fmt.Errorf("expect_error_code needs an integer: %q", trimmed) + } + pairs[lastIdx].ExpectErrorCode = &code + continue + } + if after, ok := strings.CutPrefix(rest, "expect_error_message "); ok { + message := strings.TrimSpace(after) + if message == "" { + return nil, fmt.Errorf("expect_error_message needs a non-empty message: %q", trimmed) + } + pairs[lastIdx].ExpectErrorMessage = &message + continue + } if after, ok := strings.CutPrefix(rest, "expect_response_header "); ok { name := strings.TrimSpace(after) if name == "" { @@ -134,7 +155,7 @@ func parseIOFile(content string) ([]ioxPair, error) { return pairs, nil } -// assertPairBodyDirectives checks optional @ expect_body_contains rules for one .io pair. +// assertPairBodyDirectives checks optional body and JSON-RPC error directives for one .io pair. func assertPairBodyDirectives(t *testing.T, pair ioxPair, body []byte) { t.Helper() for _, sub := range pair.ExpectBodyContains { @@ -142,6 +163,27 @@ func assertPairBodyDirectives(t *testing.T, pair ioxPair, body []byte) { t.Fatalf("expected response body to contain %q", sub) } } + if pair.ExpectErrorCode == nil && pair.ExpectErrorMessage == nil { + return + } + var response struct { + Error *struct { + Code int `json:"code"` + Message string `json:"message"` + } `json:"error"` + } + if err := json.Unmarshal(body, &response); err != nil { + t.Fatalf("decode JSON-RPC error response: %v", err) + } + if response.Error == nil { + t.Fatal("expected JSON-RPC error response") + } + if pair.ExpectErrorCode != nil && response.Error.Code != *pair.ExpectErrorCode { + t.Fatalf("expected JSON-RPC error code %d, got %d", *pair.ExpectErrorCode, response.Error.Code) + } + if pair.ExpectErrorMessage != nil && response.Error.Message != *pair.ExpectErrorMessage { + t.Fatalf("expected JSON-RPC error message %q, got %q", *pair.ExpectErrorMessage, response.Error.Message) + } } func assertPairHeaderDirectives(t *testing.T, pair ioxPair, hdr http.Header) { diff --git a/integration_test/evm_module/rpc_io_test/io_parse_test.go b/integration_test/evm_module/rpc_io_test/io_parse_test.go index 0b9af24f1a..f2f7ab22d5 100644 --- a/integration_test/evm_module/rpc_io_test/io_parse_test.go +++ b/integration_test/evm_module/rpc_io_test/io_parse_test.go @@ -114,6 +114,28 @@ func TestParseIOFile_ExpectBodyContains(t *testing.T) { } } +func TestParseIOFile_ExpectError(t *testing.T) { + content := `>> {"jsonrpc":"2.0","id":1,"method":"eth_sendRawTransaction","params":["0x03"]} +<< {"jsonrpc":"2.0","id":1,"error":{}} +@ expect_error_code -32000 +@ expect_error_message transaction type not supported +` + pairs, err := parseIOFile(content) + if err != nil { + t.Fatalf("parse: %v", err) + } + if len(pairs) != 1 { + t.Fatalf("expected 1 pair, got %d", len(pairs)) + } + if pairs[0].ExpectErrorCode == nil || *pairs[0].ExpectErrorCode != -32000 { + t.Fatalf("ExpectErrorCode: %v", pairs[0].ExpectErrorCode) + } + if pairs[0].ExpectErrorMessage == nil || *pairs[0].ExpectErrorMessage != "transaction type not supported" { + t.Fatalf("ExpectErrorMessage: %v", pairs[0].ExpectErrorMessage) + } + assertPairBodyDirectives(t, pairs[0], []byte(`{"jsonrpc":"2.0","id":1,"error":{"code":-32000,"message":"transaction type not supported"}}`)) +} + func TestParseIOFile_BareLTLTEmptyExpected(t *testing.T) { // A line that is only << (empty body after the marker) still ends the pair with zero-length Expected; // @ expect_body_* / @ expect_response_* can assert on the raw HTTP body (e.g. JSON array batch). @@ -513,6 +535,35 @@ func TestRunnerFlow_SubstitutionAndSameBlock(t *testing.T) { } } +func TestParseIOFile_TestdataDir(t *testing.T) { + dir, err := ioTestsDir() + if err != nil { + t.Fatalf("ioTestsDir: %v", err) + } + files, err := collectIOFiles(dir) + if err != nil { + t.Fatalf("collectIOFiles: %v", err) + } + if len(files) == 0 { + t.Fatal("no .io/.iox files under testdata") + } + for _, rel := range files { + content, err := os.ReadFile(filepath.Join(dir, rel)) + if err != nil { + t.Errorf("%s: read: %v", rel, err) + continue + } + pairs, err := parseIOFile(string(content)) + if err != nil { + t.Errorf("%s: parse: %v", rel, err) + continue + } + if len(pairs) == 0 { + t.Errorf("%s: parsed zero pairs", rel) + } + } +} + func TestCollectIOFiles_IncludesIOAndIox(t *testing.T) { dir := t.TempDir() for _, name := range []string{"a.io", "b.iox", "c.io", "skip.txt"} { diff --git a/integration_test/evm_module/rpc_io_test/testdata/README.md b/integration_test/evm_module/rpc_io_test/testdata/README.md index 3b036e84b1..bbcafb8e67 100644 --- a/integration_test/evm_module/rpc_io_test/testdata/README.md +++ b/integration_test/evm_module/rpc_io_test/testdata/README.md @@ -2,9 +2,9 @@ **What it is:** Request/response fixtures for Ethereum JSON-RPC methods. The `rpc_io_test` package runs them against a Sei EVM RPC node. -- `**.io` files** - Plain request (`>>`) / expected response (`<<`) pairs. Source: curated mix from [ethereum/execution-apis](https://github.com/ethereum/execution-apis) plus Sei-added tests. **97 files** (as of Mar 2026). Data-dependent .io that required Ethereum fixture hashes were removed; equivalent coverage lives in `.iox`. -- `**.iox` files** - Extended format with `@ bind` and optional `@ ref_pair N`; data comes from a first request. **62 files.** All are Sei-generated and live only in this repo. +- `**.io` files** - Plain request (`>>`) / expected response (`<<`) pairs. Source: curated mix from [ethereum/execution-apis](https://github.com/ethereum/execution-apis) plus Sei-added tests. **90 files.** Data-dependent .io that required Ethereum fixture hashes were removed; equivalent coverage lives in `.iox`. +- `**.iox` files** - Extended format with bindings, reference pairs, and response/error assertions. **64 files.** All are Sei-generated and live only in this repo. -**Total: 159 tests** (97 `.io` + 62 `.iox`). **69** top-level method folders under `testdata/`. See `../RPC_IO_README.md` for how to run and outcome meanings. +**Total: 154 tests** (90 `.io` + 64 `.iox`). **57** top-level method folders under `testdata/`. See `../RPC_IO_README.md` for how to run and outcome meanings. **Important:** This directory is **not** a direct copy of execution-apis. Do **not** replace it by copying from execution-apis (that would remove all .iox and restore removed .io). To add or update **individual** tests from execution-apis, copy only the specific files you need and avoid overwriting existing `.iox` or curated `.io`. The suite expects both .io and .iox under `testdata/` (and subdirs); if the directory is empty, the integration test skips with a clear message. \ No newline at end of file diff --git a/integration_test/evm_module/rpc_io_test/testdata/eth_sendRawTransaction/reject-blob-tx.iox b/integration_test/evm_module/rpc_io_test/testdata/eth_sendRawTransaction/reject-blob-tx.iox new file mode 100644 index 0000000000..99af01d94f --- /dev/null +++ b/integration_test/evm_module/rpc_io_test/testdata/eth_sendRawTransaction/reject-blob-tx.iox @@ -0,0 +1,11 @@ +// Rejects a type-3 BlobTx (EIP-4844). Sei does not support blob transactions. +// Signing key (throwaway): 0x4646464646464646464646464646464646464646464646464646464646464646 +// Sender: 0x9d8A62f656a8d1615C1294fd71e9CFb3E4855A4F +// Tx: type 3, chainId 713714, nonce 0, gas 21000, feeCap 2 gwei, tip 1 gwei, +// blobFeeCap 1 gwei, one BlobHash (32 bytes, version prefix 0x01), +// to 0x0000000000000000000000000000000000000001, value 0, no data, no sidecar. +// Signed with types.NewPragueSigner(713714); marshalled with MarshalBinary. +>> {"jsonrpc":"2.0","id":1,"method":"eth_sendRawTransaction","params":["0x03f894830ae3f280843b9aca0084773594008252089400000000000000000000000000000000000000018080c0843b9aca00e1a0010000000000000000000000000000000000000000000000000000000000000001a0ed3ffe5613e3e6760c69cb7098948dda754ec99bcc782eecb5207f3e905122d7a075c732d033ecad791c7f4ff39ef9eec96f22ed3bb3911ec1202ae783b7d5ba7e"]} +<< {"jsonrpc":"2.0","id":1,"error":{}} +@ expect_error_code -32000 +@ expect_error_message transaction type not supported diff --git a/integration_test/evm_module/rpc_io_test/testdata/eth_sendRawTransaction/reject-chain-id-mismatch.iox b/integration_test/evm_module/rpc_io_test/testdata/eth_sendRawTransaction/reject-chain-id-mismatch.iox new file mode 100644 index 0000000000..0d948d200a --- /dev/null +++ b/integration_test/evm_module/rpc_io_test/testdata/eth_sendRawTransaction/reject-chain-id-mismatch.iox @@ -0,0 +1,10 @@ +// Rejects an EIP-1559 tx signed for chainId 999999 against Sei's EVM chain id 713714. +// Signing key (throwaway): 0x4646464646464646464646464646464646464646464646464646464646464646 +// Sender: 0x9d8A62f656a8d1615C1294fd71e9CFb3E4855A4F +// Tx: type 2, chainId 999999, nonce 0, gas 21000, feeCap 2 gwei, tip 1 gwei, +// to 0x0000000000000000000000000000000000000001, value 0, no data. +// Signed with types.LatestSignerForChainID(999999). +>> {"jsonrpc":"2.0","id":1,"method":"eth_sendRawTransaction","params":["0x02f86d830f423f80843b9aca0084773594008252089400000000000000000000000000000000000000018080c080a089dc99079653bd09da42ec33a16a30b16bb45a0c329e5a94aa5c851afc851588a043586c25170098120c6cc09f0074196749ebb6885e6fbcc869240dbf1df40eb4"]} +<< {"jsonrpc":"2.0","id":1,"error":{}} +@ expect_error_code -32000 +@ expect_error_message invalid sender: invalid chain id for signer: have 999999 want 713714 diff --git a/integration_test/evm_module/rpc_io_test/testdata/eth_sendRawTransaction/reject-exceeds-block-gas-limit.iox b/integration_test/evm_module/rpc_io_test/testdata/eth_sendRawTransaction/reject-exceeds-block-gas-limit.iox new file mode 100644 index 0000000000..da36564a55 --- /dev/null +++ b/integration_test/evm_module/rpc_io_test/testdata/eth_sendRawTransaction/reject-exceeds-block-gas-limit.iox @@ -0,0 +1,10 @@ +// Rejects a legacy tx whose gas limit exceeds the localnet block gas limit by one. +// Signing key (throwaway): 0x4646464646464646464646464646464646464646464646464646464646464646 +// Sender: 0x9d8A62f656a8d1615C1294fd71e9CFb3E4855A4F +// Tx: type 0, chainId 713714, nonce 0, gasPrice 1 gwei, gas 35000001, +// to 0x0000000000000000000000000000000000000001, value 0, no data. +// Signed with types.LatestSignerForChainID(713714). +>> {"jsonrpc":"2.0","id":1,"method":"eth_sendRawTransaction","params":["0xf86880843b9aca008402160ec194000000000000000000000000000000000000000180808315c808a093c9c6b0b5a268ed1a8a63ec41d535864c7811abf12e24eaf305299636f339c4a043796c22794d3198ee2a6f9927eb0523bd13db79a784b4c7b0c15d304e09ea77"]} +<< {"jsonrpc":"2.0","id":1,"error":{}} +@ expect_error_code -32000 +@ expect_error_message exceeds block gas limit: tx gas limit 35000001 exceeds block max gas 35000000 diff --git a/integration_test/evm_module/rpc_io_test/testdata/eth_sendRawTransaction/reject-fee-cap-below-base-fee.iox b/integration_test/evm_module/rpc_io_test/testdata/eth_sendRawTransaction/reject-fee-cap-below-base-fee.iox new file mode 100644 index 0000000000..06c92bf2c5 --- /dev/null +++ b/integration_test/evm_module/rpc_io_test/testdata/eth_sendRawTransaction/reject-fee-cap-below-base-fee.iox @@ -0,0 +1,10 @@ +// Rejects an EIP-1559 tx whose fee cap (1 wei) is below the block base fee. +// Signing key (throwaway): 0x4646464646464646464646464646464646464646464646464646464646464646 +// Sender: 0x9d8A62f656a8d1615C1294fd71e9CFb3E4855A4F +// Tx: type 2, chainId 713714, nonce 0, gas 21000, feeCap 1 wei, tip 0, +// to 0x0000000000000000000000000000000000000001, value 0, no data. +// Signed with types.LatestSignerForChainID(713714). +>> {"jsonrpc":"2.0","id":1,"method":"eth_sendRawTransaction","params":["0x02f865830ae3f28080018252089400000000000000000000000000000000000000018080c080a025fe5f5c75fa1dab6009bfef0b0bdca562108f07532b610fd261cc632ea0af0ba07786ec009843c2c5aa3b74cfa386b42d08a4d6b1b4bf47d60a47c746e263bab3"]} +<< {"jsonrpc":"2.0","id":1,"error":{}} +@ expect_error_code -32000 +@ expect_body_contains max fee per gas less than block base fee: address 0x9d8A62f656a8d1615C1294fd71e9CFb3E4855A4F, maxFeePerGas: 1, baseFee: diff --git a/integration_test/evm_module/rpc_io_test/testdata/eth_sendRawTransaction/reject-floor-data-gas-too-low.iox b/integration_test/evm_module/rpc_io_test/testdata/eth_sendRawTransaction/reject-floor-data-gas-too-low.iox new file mode 100644 index 0000000000..7adf8ebddf --- /dev/null +++ b/integration_test/evm_module/rpc_io_test/testdata/eth_sendRawTransaction/reject-floor-data-gas-too-low.iox @@ -0,0 +1,10 @@ +// Rejects a legacy transfer whose gas covers intrinsic gas but not the EIP-7623 floor. +// Signing key (throwaway): 0x4646464646464646464646464646464646464646464646464646464646464646 +// Sender: 0x9d8A62f656a8d1615C1294fd71e9CFb3E4855A4F +// Tx: type 0, chainId 713714, nonce 0, gasPrice 1 gwei, gas 22000, +// to 0x0000000000000000000000000000000000000001, value 0, 50 non-zero data bytes. +// Intrinsic gas is 21800; EIP-7623 floor data gas is 23000. +>> {"jsonrpc":"2.0","id":1,"method":"eth_sendRawTransaction","params":["0xf89880843b9aca008255f094000000000000000000000000000000000000000180b201010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101018315c807a06015db3dbe2c4bfea5c1b7f89c81c7c545685b5bdd6a035af8b22c1aaa62fa01a06707f6d9f4b4af0ce00da0076635a898c76b929a44c45d004bcf9f62cc41245a"]} +<< {"jsonrpc":"2.0","id":1,"error":{}} +@ expect_error_code -32000 +@ expect_error_message insufficient gas for floor data gas cost: gas 22000, minimum needed 23000 diff --git a/integration_test/evm_module/rpc_io_test/testdata/eth_sendRawTransaction/reject-insufficient-funds.iox b/integration_test/evm_module/rpc_io_test/testdata/eth_sendRawTransaction/reject-insufficient-funds.iox new file mode 100644 index 0000000000..8bfa2e7527 --- /dev/null +++ b/integration_test/evm_module/rpc_io_test/testdata/eth_sendRawTransaction/reject-insufficient-funds.iox @@ -0,0 +1,10 @@ +// Rejects an EIP-1559 tx from an unfunded throwaway key (gas*feeCap exceeds balance 0). +// Signing key (throwaway): 0x4646464646464646464646464646464646464646464646464646464646464646 +// Sender: 0x9d8A62f656a8d1615C1294fd71e9CFb3E4855A4F +// Tx: type 2, chainId 713714, nonce 0, gas 21000, feeCap 100 gwei, tip 1 gwei, +// to 0x0000000000000000000000000000000000000001, value 0, no data. +// Signed with types.LatestSignerForChainID(713714). +>> {"jsonrpc":"2.0","id":1,"method":"eth_sendRawTransaction","params":["0x02f86e830ae3f280843b9aca0085174876e8008252089400000000000000000000000000000000000000018080c001a0b255a96aee0c6400af2e68f3f0add89982d8da99704b61e8398f6320864caa8ca069dba50fbac5f47355296a0dbfdb855dca9fa6c54987455ad0f83524de6a6d7e"]} +<< {"jsonrpc":"2.0","id":1,"error":{}} +@ expect_error_code -32000 +@ expect_error_message insufficient funds for gas * price + value: address 0x9d8A62f656a8d1615C1294fd71e9CFb3E4855A4F have 0 want 2100000000000000 diff --git a/integration_test/evm_module/rpc_io_test/testdata/eth_sendRawTransaction/reject-intrinsic-gas-too-low.iox b/integration_test/evm_module/rpc_io_test/testdata/eth_sendRawTransaction/reject-intrinsic-gas-too-low.iox new file mode 100644 index 0000000000..8d638d4aa4 --- /dev/null +++ b/integration_test/evm_module/rpc_io_test/testdata/eth_sendRawTransaction/reject-intrinsic-gas-too-low.iox @@ -0,0 +1,10 @@ +// Rejects a legacy transfer whose gas limit is below the 21000 intrinsic floor. +// Signing key (throwaway): 0x4646464646464646464646464646464646464646464646464646464646464646 +// Sender: 0x9d8A62f656a8d1615C1294fd71e9CFb3E4855A4F +// Tx: type 0, chainId 713714, nonce 0, gasPrice 1 gwei, gas 1000, +// to 0x0000000000000000000000000000000000000001, value 0, no data. +// Signed with types.LatestSignerForChainID(713714). +>> {"jsonrpc":"2.0","id":1,"method":"eth_sendRawTransaction","params":["0xf86680843b9aca008203e894000000000000000000000000000000000000000180808315c808a07739c1602014b4c4bf4acb866e8e7d3edb4dbf84af5bd091626b6ecd1b00019ba04f99fb655d407fd3f7ea2dbec609f377974af656b1cf8e1473b03ec1bc594711"]} +<< {"jsonrpc":"2.0","id":1,"error":{}} +@ expect_error_code -32000 +@ expect_error_message intrinsic gas too low: gas 1000, minimum needed 21000 diff --git a/integration_test/evm_module/rpc_io_test/testdata/eth_sendRawTransaction/reject-tip-above-fee-cap.iox b/integration_test/evm_module/rpc_io_test/testdata/eth_sendRawTransaction/reject-tip-above-fee-cap.iox new file mode 100644 index 0000000000..904041bcf9 --- /dev/null +++ b/integration_test/evm_module/rpc_io_test/testdata/eth_sendRawTransaction/reject-tip-above-fee-cap.iox @@ -0,0 +1,10 @@ +// Rejects an EIP-1559 tx whose priority fee exceeds its fee cap (4 gwei > 2 gwei). +// Signing key (throwaway): 0x4646464646464646464646464646464646464646464646464646464646464646 +// Sender: 0x9d8A62f656a8d1615C1294fd71e9CFb3E4855A4F +// Tx: type 2, chainId 713714, nonce 0, gas 21000, feeCap 2 gwei, tip 4 gwei, +// to 0x0000000000000000000000000000000000000001, value 0, no data. +// Signed with types.LatestSignerForChainID(713714). +>> {"jsonrpc":"2.0","id":1,"method":"eth_sendRawTransaction","params":["0x02f86d830ae3f28084ee6b280084773594008252089400000000000000000000000000000000000000018080c001a088887558142fc3589fd4a5a14a1bd7fe777ecdf3bdbf9809b7c63fffe065926fa010743968a1875cc305256b88e361ad5422c5c99d0d33633d77c8697a71b1df5e"]} +<< {"jsonrpc":"2.0","id":1,"error":{}} +@ expect_error_code -32000 +@ expect_error_message max priority fee per gas higher than max fee per gas (4000000000 > 2000000000) diff --git a/integration_test/evm_module/rpc_io_test/testdata/eth_sendRawTransaction/reject-unprotected-legacy-tx.iox b/integration_test/evm_module/rpc_io_test/testdata/eth_sendRawTransaction/reject-unprotected-legacy-tx.iox new file mode 100644 index 0000000000..8893f865d6 --- /dev/null +++ b/integration_test/evm_module/rpc_io_test/testdata/eth_sendRawTransaction/reject-unprotected-legacy-tx.iox @@ -0,0 +1,10 @@ +// Rejects a pre-EIP-155 (unprotected) legacy tx signed with HomesteadSigner (v = 27/28). +// Signing key (throwaway): 0x4646464646464646464646464646464646464646464646464646464646464646 +// Sender: 0x9d8A62f656a8d1615C1294fd71e9CFb3E4855A4F +// Tx: type 0, no chainId, nonce 0, gasPrice 1 gwei, gas 21000, +// to 0x0000000000000000000000000000000000000001, value 0, no data. +// Signed with types.HomesteadSigner{}. +>> {"jsonrpc":"2.0","id":1,"method":"eth_sendRawTransaction","params":["0xf86380843b9aca0082520894000000000000000000000000000000000000000180801ca0b33b9fe5277aefcdba690555cf2329af57c83d8584de4f5ae88854b488490032a01ce1a071dfafa591574358110ca46b6ca6ea55158f2d466219e1c6c7539d35a6"]} +<< {"jsonrpc":"2.0","id":1,"error":{}} +@ expect_error_code -32000 +@ expect_error_message only replay-protected (EIP-155) transactions allowed over RPC diff --git a/integration_test/rpc_tests/eth/eth_getBlockByHash.spec.ts b/integration_test/rpc_tests/eth/eth_getBlockByHash.spec.ts index 6c2b7d2fa9..2fd6bc30f9 100644 --- a/integration_test/rpc_tests/eth/eth_getBlockByHash.spec.ts +++ b/integration_test/rpc_tests/eth/eth_getBlockByHash.spec.ts @@ -470,7 +470,7 @@ describe('eth_getBlockByHash', function () { }); }); - describe('rejected transactions are refused (parity + documented divergence)', () => { + describe('rejected transactions are refused', () => { it('both reject a tx below the intrinsic gas floor and never mine it', async () => { const [seiTx, gethTx] = await Promise.all([ signBelowIntrinsicTx(sei, seiRejectSigner), @@ -480,12 +480,9 @@ describe('eth_getBlockByHash', function () { rawSei('eth_sendRawTransaction', [seiTx.raw]), rawGeth('eth_sendRawTransaction', [gethTx.raw]), ]); - expectJsonRpcError(g, -32000, /intrinsic gas too low/); - expect(s.error, 'Sei rejects the tx').to.not.equal(undefined); - expect(s.error!.code, 'both use -32000').to.equal(g.error!.code); - expect(s.error!.message, '[divergence] Sei does not surface the geth reason').to.not.equal( - g.error!.message, - ); + // Both nodes reject pre-execution with geth's exact intrinsic-gas string. + expectJsonRpcError(g, -32000, /^intrinsic gas too low: gas 1000, minimum needed 21000$/); + expectSameError(s, g); const [seiLookup, gethLookup] = await Promise.all([ rawSei('eth_getTransactionByHash', [seiTx.hash]), diff --git a/integration_test/rpc_tests/eth/eth_getBlockByNumber.spec.ts b/integration_test/rpc_tests/eth/eth_getBlockByNumber.spec.ts index 73d775ee9d..96254988c1 100644 --- a/integration_test/rpc_tests/eth/eth_getBlockByNumber.spec.ts +++ b/integration_test/rpc_tests/eth/eth_getBlockByNumber.spec.ts @@ -521,7 +521,7 @@ describe('eth_getBlockByNumber', function () { }); }); - describe('rejected transactions are refused (parity + documented divergence)', () => { + describe('rejected transactions are refused', () => { it('both reject a tx below the intrinsic gas floor and never mine it', async () => { const [seiTx, gethTx] = await Promise.all([ signBelowIntrinsicTx(sei, seiRejectSigner), @@ -531,14 +531,9 @@ describe('eth_getBlockByNumber', function () { rawSei('eth_sendRawTransaction', [seiTx.raw]), rawGeth('eth_sendRawTransaction', [gethTx.raw]), ]); - // Both reject with code -32000; geth is descriptive while Sei surfaces an - // opaque ": unknown" from its mempool — a documented message divergence. - expectJsonRpcError(g, -32000, /intrinsic gas too low/); - expect(s.error, 'Sei rejects the tx').to.not.equal(undefined); - expect(s.error!.code, 'both use -32000').to.equal(g.error!.code); - expect(s.error!.message, '[divergence] Sei does not surface the geth reason').to.not.equal( - g.error!.message, - ); + // Both nodes reject pre-execution with geth's exact intrinsic-gas string. + expectJsonRpcError(g, -32000, /^intrinsic gas too low: gas 1000, minimum needed 21000$/); + expectSameError(s, g); const [seiLookup, gethLookup] = await Promise.all([ rawSei('eth_getTransactionByHash', [seiTx.hash]), diff --git a/integration_test/rpc_tests/eth/eth_sendRawTransaction.spec.ts b/integration_test/rpc_tests/eth/eth_sendRawTransaction.spec.ts index 451595bf99..782d82b80f 100644 --- a/integration_test/rpc_tests/eth/eth_sendRawTransaction.spec.ts +++ b/integration_test/rpc_tests/eth/eth_sendRawTransaction.spec.ts @@ -43,7 +43,7 @@ describe('eth_sendRawTransaction', function () { }); describe('wrong params / error handling', () => { - it('[divergence] both reject a below-intrinsic-gas tx; geth is descriptive, Sei is generic', async () => { + it('rejects a below-intrinsic-gas tx identically to geth', async () => { const [seiTx, gethTx] = await Promise.all([ signBelowIntrinsicTx(sei, intrinsicTester), signBelowIntrinsicTx(geth, gethSender), @@ -52,14 +52,9 @@ describe('eth_sendRawTransaction', function () { rawSei('eth_sendRawTransaction', [seiTx.raw]), rawGeth('eth_sendRawTransaction', [gethTx.raw]), ]); - // geth rejects pre-execution with the exact reason; Sei rejects too (same -32000 code) - // but its ante surfaces a generic ABCI error rather than the descriptive message. - expectJsonRpcError(g, -32000, /intrinsic gas too low/); - expect(s.error, 'Sei rejects the below-intrinsic tx').to.not.equal(undefined); - expect(s.error!.code, 'both use -32000').to.equal(g.error!.code); - expect(s.error!.message, '[divergence] Sei does not surface the geth reason').to.not.equal( - g.error!.message, - ); + // Both nodes reject pre-execution with geth's exact intrinsic-gas string. + expectJsonRpcError(g, -32000, /^intrinsic gas too low: gas 1000, minimum needed 21000$/); + expectSameError(s, g); }); it('rejects malformed transaction bytes identically to geth', async () => { @@ -72,8 +67,8 @@ describe('eth_sendRawTransaction', function () { }); it('rejects a tx whose nonce is already used (stale nonce)', async () => { - // Consume nonce 0, then re-submit a freshly signed tx pinned to nonce 0. Sei's Cosmos - // ante reports "incorrect account sequence" where geth would say "nonce too low". + // Consume nonce 0, then submit different bytes pinned to nonce 0 so the request + // reaches the nonce check rather than the transaction-hash cache. const first = await signRawTransfer(sei, nonceTester, 2, { nonce: 0 }); await sendRaw(sei, first.raw); await sei.waitForTransaction(first.hash, 1, 60_000); @@ -81,8 +76,9 @@ describe('eth_sendRawTransaction', function () { const stale = await signRawTransfer(sei, nonceTester, 2, { nonce: 0 }); const res = await rawSei('eth_sendRawTransaction', [stale.raw]); expect(res.error, JSON.stringify(res)).to.not.equal(undefined); - expect(res.error!.message, 'stale-nonce signature').to.match( - /incorrect account sequence|nonce too low|already known/i, + expect(res.error!.code).to.equal(-32000); + expect(res.error!.message, 'stale-nonce signature').to.equal( + 'nonce too low: next nonce 1, tx nonce 0', ); }); @@ -99,10 +95,10 @@ describe('eth_sendRawTransaction', function () { const replayRes = await rawSei('eth_sendRawTransaction', [signed.raw]); expect(replayRes.error, 'replay must be rejected').to.not.equal(undefined); - // Sei dedups in the mempool cache ("tx already exists in cache") before the nonce - // check, so accept that alongside the canonical nonce/replay rejection reasons. + // Sei may still dedup in the mempool cache before the nonce check; that condition + // is now rendered as geth's "already known". expect(replayRes.error!.message, 'replay rejection reason').to.match( - /incorrect account sequence|nonce too low|already known|tx already exists in cache/i, + /^(nonce too low: next nonce \d+, tx nonce \d+|already known)$/, ); }); @@ -127,6 +123,11 @@ describe('eth_sendRawTransaction', function () { }); const res = await rawSei('eth_sendRawTransaction', [tx]); expect(res.error, 'wrong chain ID must be rejected').to.not.equal(undefined); + expectJsonRpcError( + res, + -32000, + /^invalid sender: invalid chain id for signer: have \d+ want \d+$/, + ); }); }); }); diff --git a/integration_test/rpc_tests/utils/txUtils.ts b/integration_test/rpc_tests/utils/txUtils.ts index 6a45d8ef9b..21d9409c19 100644 --- a/integration_test/rpc_tests/utils/txUtils.ts +++ b/integration_test/rpc_tests/utils/txUtils.ts @@ -668,9 +668,9 @@ export async function sendRevertingTx( /** * Sign (but do not broadcast) a well-formed legacy transaction whose gas limit is - * below the 21000 intrinsic floor. Submitting it must be *rejected* pre-execution by both - * nodes (same -32000 code): geth with a descriptive "intrinsic gas too low", Sei with a - * generic ABCI error from its ante (a documented divergence). Returns the raw payload + hash. + * below the 21000 intrinsic floor. Submitting it must be rejected pre-execution by both + * nodes with geth's "intrinsic gas too low: gas 1000, minimum needed 21000". + * Returns the raw payload + hash. */ export async function signBelowIntrinsicTx( provider: ethers.JsonRpcProvider, diff --git a/sei-tendermint/internal/proxy/proxy.go b/sei-tendermint/internal/proxy/proxy.go index 8f435fc26f..5a2c73434b 100644 --- a/sei-tendermint/internal/proxy/proxy.go +++ b/sei-tendermint/internal/proxy/proxy.go @@ -2,6 +2,7 @@ package proxy import ( "context" + "errors" "fmt" "runtime/debug" "time" @@ -12,8 +13,13 @@ import ( "github.com/sei-protocol/sei-chain/sei-tendermint/abci/types" tmproto "github.com/sei-protocol/sei-chain/sei-tendermint/proto/tendermint/types" + "github.com/sei-protocol/seilog" ) +var logger = seilog.NewLogger("tendermint", "internal", "proxy") + +var errCheckTxPanic = errors.New("panic recovered in CheckTxSafe") + // Proxy wraps an ABCI application and records ABCI method timings. type Proxy struct { app types.Application @@ -63,7 +69,9 @@ func (app *Proxy) CheckTxSafe(ctx context.Context, req *types.RequestCheckTxV2) defer addTimeSample(Global.MethodTimingAt("check_tx", "sync"))() defer func() { if r := recover(); r != nil { - err = fmt.Errorf("panic recovered in CheckTxSafe: %v\n%v", r, string(debug.Stack())) + logger.Error("panic recovered in CheckTxSafe", "panic", r, "stack", string(debug.Stack())) + res = nil + err = errCheckTxPanic } }() res = app.app.CheckTx(ctx, req) diff --git a/sei-tendermint/internal/proxy/proxy_test.go b/sei-tendermint/internal/proxy/proxy_test.go index c10e4a8698..5dc65e168d 100644 --- a/sei-tendermint/internal/proxy/proxy_test.go +++ b/sei-tendermint/internal/proxy/proxy_test.go @@ -8,7 +8,7 @@ import ( "github.com/holiman/uint256" sdk "github.com/sei-protocol/sei-chain/sei-cosmos/types" "github.com/sei-protocol/sei-chain/sei-tendermint/abci/types" - "github.com/stretchr/testify/require" + "github.com/sei-protocol/sei-chain/sei-tendermint/libs/utils/require" ) type testApp struct { @@ -27,7 +27,7 @@ func TestCheckTxSafeReturnsErrorOnPanic(t *testing.T) { }, }) _, err := proxyApp.CheckTxSafe(t.Context(), &types.RequestCheckTxV2{Tx: []byte("tx")}) - require.Error(t, err) + require.Equal(t, errCheckTxPanic, err) } func validEVMResponse() *types.ResponseCheckTxV2 {