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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions .golangci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ linters:
default: none
enable:
- bodyclose
- depguard
- dogsled
- errcheck
- goconst
Expand All @@ -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$"
Expand Down
49 changes: 45 additions & 4 deletions app/ante/evm_checktx.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand All @@ -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")
}
Expand All @@ -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
}
}
Expand All @@ -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())

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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))
Expand Down Expand Up @@ -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)

Expand Down
50 changes: 50 additions & 0 deletions app/ante/evm_checktx_test.go
Original file line number Diff line number Diff line change
@@ -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"
Expand Down Expand Up @@ -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 := &ethtx.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)
}
6 changes: 3 additions & 3 deletions contracts/test/EVMCompatabilityTest.js
Original file line number Diff line number Diff line change
Expand Up @@ -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() {
Expand Down Expand Up @@ -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() {
Expand All @@ -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() {
Expand Down
6 changes: 3 additions & 3 deletions contracts/test/lib.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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))
}
}
Expand Down
80 changes: 80 additions & 0 deletions evmrpc/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 "<shard owner url>": …`), 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: <reason>` | 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: <Sei detail>` | 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.

Expand Down
Loading
Loading