diff --git a/contracts/test/lib.js b/contracts/test/lib.js index 015e601357..6a46d66d1b 100644 --- a/contracts/test/lib.js +++ b/contracts/test/lib.js @@ -90,16 +90,15 @@ async function mineTransferBlock(sender) { } // Default 2 because the very next block after submit can be empty -// Like provider.getTransactionReceipt, but treats the Autobahn-specific -// "requested height N is not yet available; safe latest is N-1" -// transient as "no receipt yet" (null). That error fires in the -// narrow race between a tx being indexed in block N and block N -// becoming safe-latest; it should not propagate out of polling loops. +// Like provider.getTransactionReceipt, but treats "header not found" as +// "no receipt yet" (null). That error fires in the narrow race between a +// tx being indexed in block N and block N becoming safe-latest; it should +// not propagate out of polling loops. async function tryGetReceipt(provider, txHash) { try { return await provider.getTransactionReceipt(txHash) } catch (e) { - if (String(e?.message || e).includes("not yet available")) return null + if (String(e?.message || e).includes("header not found")) return null throw e } } diff --git a/evmrpc/AGENTS.md b/evmrpc/AGENTS.md index c2b537499b..13d4a71c7f 100644 --- a/evmrpc/AGENTS.md +++ b/evmrpc/AGENTS.md @@ -132,6 +132,49 @@ code. A producer that already emits go-ethereum text passes through verbatim. - 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. +## Error parity with go-ethereum (block resolution) + +A block, receipt set or state version the node cannot serve is one condition rendered several +ways: go-ethereum answers `null` from the endpoints that return a block or something inside one, +and an error from the state-backed ones, and its text differs by endpoint family. The producers +therefore return a typed condition, `*ethrpcerrors.BlockUnavailable`, and each family renders it +at its own entry point. Nothing between the two builds a message. + +**Producers.** `WatermarkManager.ResolveHeight`, `EnsureBlockHeightAvailable` and +`EnsureReceiptHeightAvailable`, `blockByNumberWithRetry` / `blockByHashWithRetry`, and +`CheckVersion`. Reasons, selectable with `errors.Is`: `ErrBlockAboveLatest`, `ErrBlockUnknownHash`, +`ErrBlockNotFound` (the block store has nothing at an in-window height), `ErrHistoryPruned` (block or +receipts below the earliest kept height), `ErrStatePruned` (state below the earliest kept height, or +a store with no version at the height). `Detail()` carries the heights for logs and tests; the wire +message does not, because go-ethereum's does not. + +**Renderers.** + +| Family | Entry point | Rendering | +|---|---|---| +| Block fetch: `eth_getBlockBy*`, `eth_getBlockReceipts`, `eth_getBlockTransactionCountBy*`, `eth_getTransactionByBlock*AndIndex`, `eth_getTransactionByHash`, `eth_getTransactionReceipt` | `blockByNumberOrNullForJSONRPC` / `blockByHashOrNullForJSONRPC` | `IsBlockMissing` → result `null`; pruned → `4444 pruned history unavailable` | +| State: `eth_call`, `eth_estimateGas`, `eth_createAccessList`, `eth_getBalance`, `eth_getCode`, `eth_getStorageAt`, `eth_getTransactionCount` | the condition's own `Error()`; `Backend.StateAndHeaderByNumberOrHash` applies `ForState` | above latest or not in store → `-32000 header not found`; unknown hash → `-32000 header for hash not found`; pruned → `-32000 missing trie node: state at height N is not available[; earliest available is M]` | +| Logs: `eth_getLogs`, `eth_getFilterLogs`, `eth_getFilterChanges` | `ForLogs` in `fetchBlocksByCrit`; `ComputeBlockBounds` and `GetLogs` for the range | missing → `-32000 unknown block`; range below earliest → `4444 pruned history unavailable` | +| Fee history: `eth_feeHistory` | `BeyondHead`, `HistoryPruned` in `FeeHistory` | `-32000 request beyond head block: requested N, head M`; below earliest → `4444 pruned history unavailable` | +| Tracers: `debug_traceBlockBy*`, `debug_traceCall` | `Backend.BlockByNumber` / `BlockByHash` return a nil block when `IsBlockMissing` | go-ethereum's own `block #N not found` / `block 0x… not found`; pruned → `4444 pruned history unavailable` | + +`4444 pruned history unavailable` is go-ethereum's `history.PrunedHistoryError` (v1.16, where +history expiry landed); `missing trie node` is the prefix of the trie error its state endpoints +return when the state at a kept header is gone, with Sei's height in place of the node and root +hashes it names. Both keep the sentinel as the prefix and any Sei detail after `: `. + +Rules that keep the table true: + +- A `*BlockUnavailable` is returned as is. `fmt.Errorf("…: %w", err)` keeps `errors.Is` working + but reverts the code to `-32000` and prefixes the text (see the send-path typing constraint). +- A new endpoint that takes a block identifier joins one of the families above and goes through + that family's entry point. A new reason gets a row in `block_test.go`'s golden table. +- `pending`, `safe` and `finalized` resolve to the safe latest height and never produce a + condition, so go-ethereum's `pending state is not available` and `safe/finalized block not found` + are never emitted (deliberate; see the distinctions list above). +- Ordering rules the range checks in `ComputeBlockBounds` apply (`fromBlock` above `toBlock`, a + range past the head) are not availability conditions and keep their own text. + ## 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/block.go b/evmrpc/ethrpcerrors/block.go new file mode 100644 index 0000000000..1d2eaf9575 --- /dev/null +++ b/evmrpc/ethrpcerrors/block.go @@ -0,0 +1,162 @@ +package ethrpcerrors + +import ( + "errors" + "fmt" + + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/rpc" +) + +// CodePrunedHistory is the code go-ethereum's history.PrunedHistoryError carries. +const CodePrunedHistory = 4444 + +// Message literals go-ethereum's backends define inline rather than as exported sentinels. +const ( + msgHeaderNotFound = "header not found" + msgHeaderForHashNotFound = "header for hash not found" + msgUnknownBlock = "unknown block" + msgPrunedHistory = "pruned history unavailable" + msgMissingTrieNode = "missing trie node" + msgBeyondHead = "request beyond head block" +) + +// Reasons a block, its receipts or its state cannot be served. None of them reaches a client; +// callers select on them with errors.Is to pick the rendering their endpoint family needs. +var ( + ErrBlockAboveLatest = errors.New("block above safe latest") + ErrBlockUnknownHash = errors.New("no block with this hash") + ErrBlockNotFound = errors.New("no block at this height") + ErrHistoryPruned = errors.New("history pruned") + ErrStatePruned = errors.New("state pruned") +) + +// BlockUnavailable reports a block, receipt set or state version this node cannot serve. Its +// message and code are the ones go-ethereum's state-backed endpoints return for the same +// condition; block-fetch endpoints answer null instead (see IsBlockMissing) and eth_getLogs has +// its own text (see ForLogs). It must be the top-level return value, or the code falls back to +// -32000 and the message gains the wrapper's prefix. +type BlockUnavailable struct { + reason error + height int64 + hash common.Hash + earliest int64 + latest int64 +} + +var ( + _ rpc.Error = (*BlockUnavailable)(nil) + _ rpc.DataError = (*BlockUnavailable)(nil) +) + +// BlockAboveLatest reports a height above the safe latest height. +func BlockAboveLatest(height, latest int64) *BlockUnavailable { + return &BlockUnavailable{reason: ErrBlockAboveLatest, height: height, latest: latest} +} + +// BlockUnknownHash reports a hash no block carries. +func BlockUnknownHash(hash common.Hash) *BlockUnavailable { + return &BlockUnavailable{reason: ErrBlockUnknownHash, hash: hash} +} + +// BlockNotFound reports a height inside the served window that the block store has no block for. +func BlockNotFound(height int64) *BlockUnavailable { + return &BlockUnavailable{reason: ErrBlockNotFound, height: height} +} + +// HistoryPruned reports a block or receipt set below the earliest height this node keeps. +func HistoryPruned(height, earliest int64) *BlockUnavailable { + return &BlockUnavailable{reason: ErrHistoryPruned, height: height, earliest: earliest} +} + +// StatePruned reports a height whose state this node no longer holds or never held. earliest is +// 0 when no earliest state height is known. +func StatePruned(height, earliest int64) *BlockUnavailable { + return &BlockUnavailable{reason: ErrStatePruned, height: height, earliest: earliest} +} + +func (e *BlockUnavailable) Error() string { + switch e.reason { + case ErrBlockUnknownHash: + return msgHeaderForHashNotFound + case ErrHistoryPruned: + return msgPrunedHistory + case ErrStatePruned: + // go-ethereum's message names the missing node and state root; the height is the + // closest identifier Sei has for the state that is gone. + msg := fmt.Sprintf("%s: state at height %d is not available", msgMissingTrieNode, e.height) + if e.earliest > 0 { + msg += fmt.Sprintf("; earliest available is %d", e.earliest) + } + return msg + default: + return msgHeaderNotFound + } +} + +// ErrorCode returns the JSON-RPC error code. +func (e *BlockUnavailable) ErrorCode() int { + if e.reason == ErrHistoryPruned { + return CodePrunedHistory + } + return CodeDefault +} + +// ErrorData returns nil; go-ethereum attaches no data to this class of error. +func (e *BlockUnavailable) ErrorData() interface{} { return nil } + +// Unwrap returns the reason, so errors.Is can select on it. +func (e *BlockUnavailable) Unwrap() error { return e.reason } + +// Detail returns the operator-facing description of the condition with the heights involved. +func (e *BlockUnavailable) Detail() string { + switch e.reason { + case ErrBlockAboveLatest: + return fmt.Sprintf("requested height %d is not yet available; safe latest is %d", e.height, e.latest) + case ErrBlockUnknownHash: + return fmt.Sprintf("no block with hash %s", e.hash.Hex()) + case ErrBlockNotFound: + return fmt.Sprintf("no block at height %d", e.height) + case ErrHistoryPruned: + return fmt.Sprintf("history at height %d has been pruned; earliest available is %d", e.height, e.earliest) + case ErrStatePruned: + if e.earliest > 0 { + return fmt.Sprintf("state at height %d has been pruned; earliest available is %d", e.height, e.earliest) + } + return fmt.Sprintf("state at height %d does not exist", e.height) + default: + return e.reason.Error() + } +} + +// IsBlockMissing reports whether err says the requested block does not exist from the caller's +// point of view: above the safe latest height, unknown by hash, or absent from the block store. +// Endpoints that return a block, or something inside one, answer such a request with null. +func IsBlockMissing(err error) bool { + return errors.Is(err, ErrBlockAboveLatest) || errors.Is(err, ErrBlockUnknownHash) || errors.Is(err, ErrBlockNotFound) +} + +// ForLogs renders err the way go-ethereum's eth_getLogs does: "unknown block" when the requested +// block does not exist. Any other error, including pruned history, is returned unchanged. +func ForLogs(err error) error { + if IsBlockMissing(err) { + return &Error{code: CodeDefault, message: msgUnknownBlock} + } + return err +} + +// ForState renders err for a state-backed endpoint such as eth_call or eth_getBalance. go-ethereum +// keeps headers when it prunes history, so those endpoints only ever report the state as missing; +// a pruned block here is reported the same way. Any other error is returned unchanged. +func ForState(err error) error { + var e *BlockUnavailable + if errors.As(err, &e) && e.reason == ErrHistoryPruned { + return StatePruned(e.height, e.earliest) + } + return err +} + +// BeyondHead is eth_feeHistory's rejection of a newest block above the head. +func BeyondHead(requested, head int64) error { + return &Error{code: CodeDefault, message: fmt.Sprintf("%s: requested %d, head %d", msgBeyondHead, requested, head)} +} diff --git a/evmrpc/ethrpcerrors/block_test.go b/evmrpc/ethrpcerrors/block_test.go new file mode 100644 index 0000000000..2502cf44ef --- /dev/null +++ b/evmrpc/ethrpcerrors/block_test.go @@ -0,0 +1,222 @@ +package ethrpcerrors_test + +import ( + "context" + "errors" + "fmt" + "strings" + "testing" + + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/rpc" + "github.com/stretchr/testify/require" + + "github.com/sei-protocol/sei-chain/evmrpc/ethrpcerrors" +) + +const ( + msgHeaderNotFound = "header not found" + msgHeaderForHashNotFound = "header for hash not found" + msgUnknownBlock = "unknown block" + msgPrunedHistory = "pruned history unavailable" + msgMissingTrieNode = "missing trie node" +) + +var someHash = common.HexToHash("0xabcdef") + +type blockGoldenCase struct { + name string + in *ethrpcerrors.BlockUnavailable + reason error + code int + message string + detail string + missing bool + forLogs string +} + +func blockGoldenCases() []blockGoldenCase { + return []blockGoldenCase{ + { + name: "above safe latest", + in: ethrpcerrors.BlockAboveLatest(12, 10), + reason: ethrpcerrors.ErrBlockAboveLatest, + code: -32000, + message: msgHeaderNotFound, + detail: "requested height 12 is not yet available; safe latest is 10", + missing: true, + forLogs: msgUnknownBlock, + }, + { + name: "unknown hash", + in: ethrpcerrors.BlockUnknownHash(someHash), + reason: ethrpcerrors.ErrBlockUnknownHash, + code: -32000, + message: msgHeaderForHashNotFound, + detail: "no block with hash " + someHash.Hex(), + missing: true, + forLogs: msgUnknownBlock, + }, + { + name: "not in block store", + in: ethrpcerrors.BlockNotFound(7), + reason: ethrpcerrors.ErrBlockNotFound, + code: -32000, + message: msgHeaderNotFound, + detail: "no block at height 7", + missing: true, + forLogs: msgUnknownBlock, + }, + { + name: "history pruned", + in: ethrpcerrors.HistoryPruned(3, 100), + reason: ethrpcerrors.ErrHistoryPruned, + code: ethrpcerrors.CodePrunedHistory, + message: msgPrunedHistory, + detail: "history at height 3 has been pruned; earliest available is 100", + forLogs: msgPrunedHistory, + }, + { + name: "state pruned", + in: ethrpcerrors.StatePruned(3, 100), + reason: ethrpcerrors.ErrStatePruned, + code: -32000, + message: msgMissingTrieNode + ": state at height 3 is not available; earliest available is 100", + detail: "state at height 3 has been pruned; earliest available is 100", + forLogs: msgMissingTrieNode + ": state at height 3 is not available; earliest available is 100", + }, + { + name: "state never written", + in: ethrpcerrors.StatePruned(3, 0), + reason: ethrpcerrors.ErrStatePruned, + code: -32000, + message: msgMissingTrieNode + ": state at height 3 is not available", + detail: "state at height 3 does not exist", + forLogs: msgMissingTrieNode + ": state at height 3 is not available", + }, + } +} + +func TestBlockUnavailableGolden(t *testing.T) { + for _, tc := range blockGoldenCases() { + t.Run(tc.name, func(t *testing.T) { + require.Equal(t, tc.code, tc.in.ErrorCode()) + require.Equal(t, tc.message, tc.in.Error()) + require.Equal(t, tc.detail, tc.in.Detail()) + require.Nil(t, tc.in.ErrorData()) + require.ErrorIs(t, tc.in, tc.reason) + require.Equal(t, tc.missing, ethrpcerrors.IsBlockMissing(tc.in)) + require.Equal(t, tc.forLogs, ethrpcerrors.ForLogs(tc.in).Error()) + }) + } +} + +// TestBlockUnavailableWrappedStillSelectable pins that the reason survives fmt.Errorf wrapping, +// so an internal caller may wrap for its own logging and still select with errors.Is. +func TestBlockUnavailableWrappedStillSelectable(t *testing.T) { + wrapped := fmt.Errorf("scan: %w", ethrpcerrors.BlockAboveLatest(12, 10)) + require.True(t, ethrpcerrors.IsBlockMissing(wrapped)) + require.ErrorIs(t, wrapped, ethrpcerrors.ErrBlockAboveLatest) + require.False(t, ethrpcerrors.IsBlockMissing(errors.New("header not found"))) + require.False(t, ethrpcerrors.IsBlockMissing(nil)) +} + +func TestForStateRelabelsPrunedHistory(t *testing.T) { + err := ethrpcerrors.ForState(ethrpcerrors.HistoryPruned(3, 100)) + require.ErrorIs(t, err, ethrpcerrors.ErrStatePruned) + require.Equal(t, msgMissingTrieNode+": state at height 3 is not available; earliest available is 100", err.Error()) + rpcErr, ok := err.(rpc.Error) + require.True(t, ok) + require.Equal(t, -32000, rpcErr.ErrorCode()) + + future := ethrpcerrors.BlockAboveLatest(12, 10) + require.Same(t, future, ethrpcerrors.ForState(future)) + other := errors.New("tendermint down") + require.Same(t, other, ethrpcerrors.ForState(other)) + require.Nil(t, ethrpcerrors.ForState(nil)) +} + +func TestForLogsPassesOtherErrorsThrough(t *testing.T) { + other := errors.New("tendermint down") + require.Same(t, other, ethrpcerrors.ForLogs(other)) + require.Nil(t, ethrpcerrors.ForLogs(nil)) + unknown := ethrpcerrors.ForLogs(ethrpcerrors.BlockUnknownHash(someHash)) + rpcErr, ok := unknown.(rpc.Error) + require.True(t, ok) + require.Equal(t, -32000, rpcErr.ErrorCode()) +} + +func TestBeyondHead(t *testing.T) { + err := ethrpcerrors.BeyondHead(4294967295, 812) + require.Equal(t, "request beyond head block: requested 4294967295, head 812", err.Error()) + rpcErr, ok := err.(rpc.Error) + require.True(t, ok) + require.Equal(t, -32000, rpcErr.ErrorCode()) +} + +// TestBlockUnavailableNoSeiVocabulary keeps the wire text free of the Sei-side words the detail +// uses, so a client never has to know how Sei stores blocks. +func TestBlockUnavailableNoSeiVocabulary(t *testing.T) { + banned := []string{"watermark", "safe latest", "tendermint", "module", "receipt store", "pruned;"} + for _, tc := range blockGoldenCases() { + t.Run(tc.name, func(t *testing.T) { + msg := tc.in.Error() + require.False(t, strings.HasPrefix(msg, ": ")) + for _, b := range banned { + require.NotContains(t, msg, b) + } + }) + } +} + +type blockRoundTripService struct{} + +func (*blockRoundTripService) Future(context.Context) (string, error) { + return "", ethrpcerrors.BlockAboveLatest(12, 10) +} + +func (*blockRoundTripService) Pruned(context.Context) (string, error) { + return "", ethrpcerrors.HistoryPruned(3, 100) +} + +func (*blockRoundTripService) Wrapped(context.Context) (string, error) { + return "", fmt.Errorf("outer: %w", ethrpcerrors.HistoryPruned(3, 100)) +} + +func TestBlockUnavailableJSONRPCRoundTrip(t *testing.T) { + srv := rpc.NewServer() + t.Cleanup(srv.Stop) + require.NoError(t, srv.RegisterName("test", &blockRoundTripService{})) + client := rpc.DialInProc(srv) + t.Cleanup(client.Close) + + t.Run("future", func(t *testing.T) { + var res string + err := client.CallContext(t.Context(), &res, "test_future") + rpcErr, ok := err.(rpc.Error) + require.True(t, ok) + require.Equal(t, -32000, rpcErr.ErrorCode()) + require.Equal(t, msgHeaderNotFound, err.Error()) + dataErr, ok := err.(rpc.DataError) + require.True(t, ok) + require.Nil(t, dataErr.ErrorData()) + }) + t.Run("pruned keeps go-ethereum's 4444", func(t *testing.T) { + var res string + err := client.CallContext(t.Context(), &res, "test_pruned") + rpcErr, ok := err.(rpc.Error) + require.True(t, ok) + require.Equal(t, ethrpcerrors.CodePrunedHistory, rpcErr.ErrorCode()) + require.Equal(t, msgPrunedHistory, err.Error()) + }) + t.Run("wrapped loses the code", func(t *testing.T) { + var res string + err := client.CallContext(t.Context(), &res, "test_wrapped") + rpcErr, ok := err.(rpc.Error) + require.True(t, ok) + // go-ethereum type-asserts the top-level value, so a wrapper reverts to -32000 and + // prefixes the text. BlockUnavailable must be returned as is. + require.Equal(t, -32000, rpcErr.ErrorCode()) + require.Equal(t, "outer: "+msgPrunedHistory, err.Error()) + }) +} diff --git a/evmrpc/ethrpcerrors/errors.go b/evmrpc/ethrpcerrors/errors.go index 29f49c1760..260163e9c4 100644 --- a/evmrpc/ethrpcerrors/errors.go +++ b/evmrpc/ethrpcerrors/errors.go @@ -1,5 +1,5 @@ -// Package ethrpcerrors maps EVM transaction submission errors onto the go-ethereum -// errors a JSON-RPC client expects. +// Package ethrpcerrors maps EVM RPC failures, transaction submission errors and +// unavailable blocks or state, onto the go-ethereum errors a JSON-RPC client expects. package ethrpcerrors import ( diff --git a/evmrpc/filter.go b/evmrpc/filter.go index 568d168038..d0986ecf5b 100644 --- a/evmrpc/filter.go +++ b/evmrpc/filter.go @@ -20,6 +20,7 @@ import ( "github.com/hashicorp/golang-lru/v2/expirable" evmrpcconfig "github.com/sei-protocol/sei-chain/evmrpc/config" "github.com/sei-protocol/sei-chain/evmrpc/ethbloom" + "github.com/sei-protocol/sei-chain/evmrpc/ethrpcerrors" "github.com/sei-protocol/sei-chain/sei-cosmos/client" sdk "github.com/sei-protocol/sei-chain/sei-cosmos/types" "github.com/sei-protocol/sei-chain/sei-db/ledger_db/receipt" @@ -697,7 +698,7 @@ func (a *FilterAPI) GetLogs(ctx context.Context, crit filters.FilterCriteria) (r // Early rejection for pruned blocks - avoid wasting resources on blocks that don't exist if earliest > 0 && begin < earliest { - return nil, fmt.Errorf("requested block range [%d, %d] includes pruned blocks, earliest available block is %d", begin, end, earliest) + return nil, ethrpcerrors.HistoryPruned(begin, earliest) } // Only apply rate limiting for large queries (> RPSLimitThreshold blocks) @@ -856,7 +857,7 @@ func ComputeBlockBounds(latest, earliest, lastToHeight int64, crit filters.Filte return 0, 0, fmt.Errorf("requested fromBlock %d is greater than toBlock %d", begin, end) } if begin < earliest { - return 0, 0, fmt.Errorf("requested fromBlock %d is before earliest available block %d", begin, earliest) + return 0, 0, ethrpcerrors.HistoryPruned(begin, earliest) } if end > latest { return 0, 0, fmt.Errorf("requested toBlock %d is after latest available block %d", end, latest) @@ -865,7 +866,7 @@ func ComputeBlockBounds(latest, earliest, lastToHeight int64, crit filters.Filte return 0, 0, fmt.Errorf("requested fromBlock %d is after latest available block %d", begin, latest) } if end < earliest { - return 0, 0, fmt.Errorf("requested toBlock %d is before earliest available block %d", end, earliest) + return 0, 0, ethrpcerrors.HistoryPruned(end, earliest) } if lastToHeight > begin { @@ -1414,21 +1415,9 @@ func MatchesCriteria(log *ethtypes.Log, crit filters.FilterCriteria) bool { // Optimized fetchBlocksByCrit with batch processing func (f *LogFetcher) fetchBlocksByCrit(ctx context.Context, crit filters.FilterCriteria, lastToHeight int64, bloomIndexes [][]BloomIndexes) (chan *coretypes.ResultBlock, int64, error) { if crit.BlockHash != nil { - // Check for invalid zero hash - zeroHash := common.Hash{} - if *crit.BlockHash == zeroHash { - // For invalid hash, return empty channel instead of error - res := make(chan *coretypes.ResultBlock) - close(res) - return res, 0, nil - } - block, err := blockByHashRespectingWatermarks(ctx, f.tmClient, f.watermarks, crit.BlockHash[:], 1) if err != nil { - // For non-existent blocks, return empty channel instead of error - res := make(chan *coretypes.ResultBlock) - close(res) - return res, 0, nil + return nil, 0, ethrpcerrors.ForLogs(err) } res := make(chan *coretypes.ResultBlock, 1) res <- block diff --git a/evmrpc/filter_bounds_test.go b/evmrpc/filter_bounds_test.go index cbf4248950..849db1997b 100644 --- a/evmrpc/filter_bounds_test.go +++ b/evmrpc/filter_bounds_test.go @@ -66,7 +66,7 @@ func TestComputeBlockBounds(t *testing.T) { latest: 100, earliest: 30, fromBlock: big.NewInt(5), - errContains: "before earliest available block 30", + errContains: "pruned history unavailable", }, { name: "to after latest fails", @@ -80,7 +80,7 @@ func TestComputeBlockBounds(t *testing.T) { latest: 100, earliest: 30, toBlock: big.NewInt(5), - errContains: "before earliest available block 30", + errContains: "pruned history unavailable", }, { name: "from greater than to fails", diff --git a/evmrpc/height_availability_test.go b/evmrpc/height_availability_test.go index 066cbe4b66..b51bc21beb 100644 --- a/evmrpc/height_availability_test.go +++ b/evmrpc/height_availability_test.go @@ -9,6 +9,7 @@ import ( "github.com/ethereum/go-ethereum/common/hexutil" "github.com/ethereum/go-ethereum/eth/filters" "github.com/ethereum/go-ethereum/rpc" + "github.com/sei-protocol/sei-chain/evmrpc/ethrpcerrors" "github.com/sei-protocol/sei-chain/sei-cosmos/client" sdk "github.com/sei-protocol/sei-chain/sei-cosmos/types" "github.com/sei-protocol/sei-chain/sei-tendermint/libs/bytes" @@ -360,7 +361,8 @@ func TestGetBlockTransactionCountByNumberReceiptsPruned(t *testing.T) { _, err := api.GetBlockTransactionCountByNumber(context.Background(), rpc.BlockNumber(100)) require.Error(t, err) - require.Contains(t, err.Error(), "receipts have been pruned") + require.ErrorIs(t, err, ethrpcerrors.ErrHistoryPruned) + require.Equal(t, "pruned history unavailable", err.Error()) } func TestGetBlockTransactionCountByHashReceiptsPruned(t *testing.T) { @@ -373,7 +375,8 @@ func TestGetBlockTransactionCountByHashReceiptsPruned(t *testing.T) { _, err := api.GetBlockTransactionCountByHash(context.Background(), common.HexToHash(highBlockHashHex)) require.Error(t, err) - require.Contains(t, err.Error(), "receipts have been pruned") + require.ErrorIs(t, err, ethrpcerrors.ErrHistoryPruned) + require.Equal(t, "pruned history unavailable", err.Error()) } func TestGetBlockByNumberReceiptsPruned(t *testing.T) { @@ -386,7 +389,8 @@ func TestGetBlockByNumberReceiptsPruned(t *testing.T) { _, err := api.GetBlockByNumber(context.Background(), rpc.BlockNumber(100), false) require.Error(t, err) - require.Contains(t, err.Error(), "receipts have been pruned") + require.ErrorIs(t, err, ethrpcerrors.ErrHistoryPruned) + require.Equal(t, "pruned history unavailable", err.Error()) } func TestGetBlockByHashReceiptsPruned(t *testing.T) { @@ -399,7 +403,8 @@ func TestGetBlockByHashReceiptsPruned(t *testing.T) { _, err := api.GetBlockByHash(context.Background(), common.HexToHash(highBlockHashHex), false) require.Error(t, err) - require.Contains(t, err.Error(), "receipts have been pruned") + require.ErrorIs(t, err, ethrpcerrors.ErrHistoryPruned) + require.Equal(t, "pruned history unavailable", err.Error()) } func TestGetBlockReceiptsReceiptsPruned(t *testing.T) { @@ -412,5 +417,6 @@ func TestGetBlockReceiptsReceiptsPruned(t *testing.T) { _, err := api.GetBlockReceipts(context.Background(), rpc.BlockNumberOrHashWithNumber(rpc.BlockNumber(100))) require.Error(t, err) - require.Contains(t, err.Error(), "receipts have been pruned") + require.ErrorIs(t, err, ethrpcerrors.ErrHistoryPruned) + require.Equal(t, "pruned history unavailable", err.Error()) } diff --git a/evmrpc/info.go b/evmrpc/info.go index 7e531e936e..82aa8c3617 100644 --- a/evmrpc/info.go +++ b/evmrpc/info.go @@ -12,6 +12,7 @@ import ( "github.com/ethereum/go-ethereum/common/hexutil" gmath "github.com/ethereum/go-ethereum/common/math" "github.com/ethereum/go-ethereum/rpc" + "github.com/sei-protocol/sei-chain/evmrpc/ethrpcerrors" "github.com/sei-protocol/sei-chain/sei-cosmos/client" sdk "github.com/sei-protocol/sei-chain/sei-cosmos/types" "github.com/sei-protocol/sei-chain/sei-db/ledger_db/receipt" @@ -185,12 +186,12 @@ func (i *InfoAPI) FeeHistory(ctx context.Context, blockCount gmath.HexOrDecimal6 lastBlockNumber = earliestHeight default: if lastBlockNumber > latestHeight { - return nil, fmt.Errorf("requested last block %d is not yet available; safe latest is %d", lastBlockNumber, latestHeight) + return nil, ethrpcerrors.BeyondHead(lastBlockNumber, latestHeight) } } if lastBlockNumber < earliestHeight { - return nil, errors.New("requested last block is before earliest available height") + return nil, ethrpcerrors.HistoryPruned(lastBlockNumber, earliestHeight) } if uint64(lastBlockNumber-earliestHeight) < uint64(blockCount) { //nolint:gosec diff --git a/evmrpc/info_test.go b/evmrpc/info_test.go index 992ac0069b..ba1d86b734 100644 --- a/evmrpc/info_test.go +++ b/evmrpc/info_test.go @@ -97,7 +97,7 @@ func TestFeeHistory(t *testing.T) { {name: "Valid request by latest", blockCount: 1, lastBlock: "latest", rewardPercentiles: []interface{}{0.5}, expectedOldest: latestHex}, {name: "Valid request by earliest", blockCount: 1, lastBlock: "earliest", rewardPercentiles: []interface{}{0.5}, expectedOldest: "0x1"}, {name: "Request on the same block", blockCount: 1, lastBlock: "0x1", rewardPercentiles: []interface{}{0.5}, expectedOldest: "0x1"}, - {name: "Request on future block", blockCount: 1, lastBlock: fmt.Sprintf("0x%x", MockHeight8+1), rewardPercentiles: []interface{}{0.5}, expectedError: fmt.Errorf("requested last block %d is not yet available; safe latest is %d", MockHeight8+1, MockHeight8)}, + {name: "Request on future block", blockCount: 1, lastBlock: fmt.Sprintf("0x%x", MockHeight8+1), rewardPercentiles: []interface{}{0.5}, expectedError: fmt.Errorf("request beyond head block: requested %d, head %d", MockHeight8+1, MockHeight8)}, {name: "Block count truncates", blockCount: 1025, lastBlock: "latest", rewardPercentiles: []interface{}{25}, expectedOldest: "0x1"}, {name: "Too many percentiles", blockCount: 10, lastBlock: "latest", rewardPercentiles: make([]interface{}, 101), expectedError: errors.New("rewardPercentiles length must be less than or equal to 100")}, {name: "Invalid percentiles order", blockCount: 10, lastBlock: "latest", rewardPercentiles: []interface{}{99, 1}, expectedError: errors.New("invalid reward percentiles: must be ascending and between 0 and 100")}, diff --git a/evmrpc/setup_test.go b/evmrpc/setup_test.go index 4d49c0ced1..50d08c27f6 100644 --- a/evmrpc/setup_test.go +++ b/evmrpc/setup_test.go @@ -429,8 +429,8 @@ func (c *MockClient) BlockByHash(_ context.Context, hash bytes.HexBytes) (*coret } if strings.ToLower(hash.String()) == "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" { // Match real Tendermint behavior for unknown hashes: ResultBlock with - // Block: nil + no error. blockByHashWithRetry wraps this as - // ErrBlockNotFoundByHash, which JSON-RPC endpoints convert to null. + // Block: nil + no error. blockByHashWithRetry reports this as + // ethrpcerrors.ErrBlockUnknownHash, which JSON-RPC endpoints convert to null. return &coretypes.ResultBlock{Block: nil}, nil } return c.mockBlock(MockHeight8), nil diff --git a/evmrpc/simulate.go b/evmrpc/simulate.go index 8e45343163..40f293e600 100644 --- a/evmrpc/simulate.go +++ b/evmrpc/simulate.go @@ -29,6 +29,7 @@ import ( "github.com/ethereum/go-ethereum/params" "github.com/ethereum/go-ethereum/rpc" "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" @@ -347,7 +348,7 @@ func (b *Backend) StateAndHeaderByNumberOrHash(ctx context.Context, blockNrOrHas if !isLatest || sdkCtx.BlockHeight() > 0 { tmBlock, isLatest, err := b.getBlockByNumberOrHash(ctx, blockNrOrHash) if err != nil { - return nil, nil, err + return nil, nil, ethrpcerrors.ForState(err) } header.Number = big.NewInt(tmBlock.Block.Height) header.Time = toUint64(tmBlock.Block.Time.Unix()) @@ -427,9 +428,14 @@ func (b Backend) ConvertBlockNumber(bn rpc.BlockNumber) int64 { return blockNum } +// BlockByNumber returns the block at bn, or a nil block when none exists from the caller's point +// of view, which go-ethereum's tracer API reports as "block #N not found". func (b Backend) BlockByNumber(ctx context.Context, bn rpc.BlockNumber) (*ethtypes.Block, []tracersutils.TraceBlockMetadata, error) { blockNum := b.ConvertBlockNumber(bn) tmBlock, err := blockByNumberRespectingWatermarks(ctx, b.tmClient, b.watermarks, &blockNum, 1) + if ethrpcerrors.IsBlockMissing(err) { + return nil, nil, nil + } if err != nil { return nil, nil, err } @@ -507,8 +513,13 @@ func (b Backend) BlockByNumber(ctx context.Context, bn rpc.BlockNumber) (*ethtyp return block, metadata, nil } +// BlockByHash returns the block with hash, or a nil block when none exists from the caller's +// point of view, which go-ethereum's tracer API reports as "block 0x… not found". func (b Backend) BlockByHash(ctx context.Context, hash common.Hash) (*ethtypes.Block, []tracersutils.TraceBlockMetadata, error) { tmBlock, err := blockByHashRespectingWatermarks(ctx, b.tmClient, b.watermarks, hash.Bytes(), 1) + if ethrpcerrors.IsBlockMissing(err) { + return nil, nil, nil + } if err != nil { return nil, nil, err } diff --git a/evmrpc/simulate_test.go b/evmrpc/simulate_test.go index 0213f72baa..57143a4c5a 100644 --- a/evmrpc/simulate_test.go +++ b/evmrpc/simulate_test.go @@ -1342,7 +1342,7 @@ func TestBlockByNumberNonTracedTxPassesTxBytes(t *testing.T) { // root over the sparse fields that doesn't match anything stored, // so debug_traceTransaction's blockByNumberAndHash check downstream // sends BlockByHash on a wild goose chase and fails with -// ErrBlockNotFoundByHash. +// ethrpcerrors.ErrBlockUnknownHash. func TestGetTransactionUsesBlockIDHash(t *testing.T) { const txHeight = int64(42) diff --git a/evmrpc/tests/state_test.go b/evmrpc/tests/state_test.go index e70a4a2437..425d0ff9bc 100644 --- a/evmrpc/tests/state_test.go +++ b/evmrpc/tests/state_test.go @@ -20,7 +20,7 @@ func TestGetBalance(t *testing.T) { res = sendRequestWithNamespace("eth", port, "getBalance", getAddrWithMnemonic(mnemonic1).Hex(), "0x3") fmt.Println(res) err := res["error"].(map[string]interface{}) - require.Contains(t, err["message"].(string), "not yet available", "expected gating message when querying future blocks") + require.Equal(t, "header not found", err["message"].(string), "future blocks answer with go-ethereum's header error") }, ) } diff --git a/evmrpc/utils.go b/evmrpc/utils.go index 4622c7ea05..c9748414fd 100644 --- a/evmrpc/utils.go +++ b/evmrpc/utils.go @@ -5,7 +5,6 @@ import ( "crypto/ecdsa" "crypto/sha256" "encoding/hex" - "errors" "fmt" "math/big" "runtime/debug" @@ -18,6 +17,7 @@ import ( "github.com/ethereum/go-ethereum/core" "github.com/ethereum/go-ethereum/crypto" "github.com/ethereum/go-ethereum/rpc" + "github.com/sei-protocol/sei-chain/evmrpc/ethrpcerrors" "github.com/sei-protocol/sei-chain/evmrpc/rpcutils" "github.com/sei-protocol/sei-chain/evmrpc/stats" "github.com/sei-protocol/sei-chain/sei-cosmos/client" @@ -41,10 +41,6 @@ const LatestCtxHeight int64 = -1 // EVM launch block heights for different chains const Pacific1EVMLaunchHeight int64 = 79123881 -// ErrBlockNotFoundByHash is returned when no block exists for the given hash (e.g. empty or unknown hash). -// Ethereum-compatible RPCs should return result: null for this case instead of an error. -var ErrBlockNotFoundByHash = errors.New("block not found by hash") - // GetBlockNumberByNrOrHash returns the height of the block with the given number or hash. func GetBlockNumberByNrOrHash(ctx context.Context, tmClient client.LocalClient, wm *WatermarkManager, blockNrOrHash rpc.BlockNumberOrHash) (*int64, error) { if blockNrOrHash.BlockHash != nil { @@ -153,7 +149,11 @@ func blockByNumberWithRetry(ctx context.Context, client client.LocalClient, heig return nil, err } if blockRes.Block == nil { - return nil, fmt.Errorf("could not find block for height %d", height) + var h int64 + if height != nil { + h = *height + } + return nil, ethrpcerrors.BlockNotFound(h) } TraceTendermintIfApplicable(ctx, "Block", []string{stringifyInt64Ptr(height)}, blockRes) return blockRes, err @@ -177,7 +177,7 @@ func blockByHashWithRetry(ctx context.Context, client client.LocalClient, hash b return nil, err } if blockRes.Block == nil { - return nil, ErrBlockNotFoundByHash + return nil, ethrpcerrors.BlockUnknownHash(common.BytesToHash(hash)) } TraceTendermintIfApplicable(ctx, "BlockByHash", []string{hash.String()}, blockRes) return blockRes, err @@ -298,12 +298,16 @@ func recordMetricsWithError(ctx context.Context, apiMethod string, connectionTyp } } +// CheckVersion verifies that the evm and bank stores hold a version at ctx's height, reporting +// an *ethrpcerrors.BlockUnavailable otherwise. func CheckVersion(ctx sdk.Context, k *keeper.Keeper) error { if !evmExists(ctx, k) { - return fmt.Errorf("evm module does not exist on height %d", ctx.BlockHeight()) + logger.Debug("evm store has no version at height", "height", ctx.BlockHeight()) + return ethrpcerrors.StatePruned(ctx.BlockHeight(), 0) } if !bankExists(ctx, k) { - return fmt.Errorf("bank module does not exist on height %d", ctx.BlockHeight()) + logger.Debug("bank store has no version at height", "height", ctx.BlockHeight()) + return ethrpcerrors.StatePruned(ctx.BlockHeight(), 0) } return nil } diff --git a/evmrpc/watermark_manager.go b/evmrpc/watermark_manager.go index 7367b2884d..9f33d8d79e 100644 --- a/evmrpc/watermark_manager.go +++ b/evmrpc/watermark_manager.go @@ -3,9 +3,9 @@ package evmrpc import ( "context" "errors" - "fmt" "github.com/ethereum/go-ethereum/rpc" + "github.com/sei-protocol/sei-chain/evmrpc/ethrpcerrors" "github.com/sei-protocol/sei-chain/sei-cosmos/client" sdk "github.com/sei-protocol/sei-chain/sei-cosmos/types" genesistypes "github.com/sei-protocol/sei-chain/sei-cosmos/types/genesis" @@ -16,10 +16,6 @@ import ( var errNoHeightSource = errors.New("unable to determine height information") -// ErrBlockHeightNotYetAvailable is returned when a concrete block height is above the -// node's safe latest watermark. eth_getBlockByNumber maps this to result null (Ethereum spec). -var ErrBlockHeightNotYetAvailable = errors.New("block height not yet available") - // WatermarkManager coordinates access to block, state, and receipt stores to // determine queryable block heights for RPC consumers. It ensures read-side // requests only target heights where all backing data sources are fully @@ -117,10 +113,9 @@ func (m *WatermarkManager) EarliestStateHeight(ctx context.Context) (int64, erro return stateEarliest, err } -// ResolveHeight normalizes a requested block identifier into a concrete height. -// If the resolved height sits outside the tracked watermarks, the method returns -// an error explaining whether it is too old (pruned) or too new (not yet -// available). +// ResolveHeight normalizes a requested block identifier into a concrete height whose +// state can be served. A height outside the state watermarks, or a hash no block carries, +// is reported as an *ethrpcerrors.BlockUnavailable. func (m *WatermarkManager) ResolveHeight(ctx context.Context, blockNrOrHash rpc.BlockNumberOrHash) (int64, error) { _, stateEarliest, latest, err := m.Watermarks(ctx) if err != nil { @@ -136,7 +131,7 @@ func (m *WatermarkManager) ResolveHeight(ctx context.Context, blockNrOrHash rpc. return 0, err } height := res.Block.Height - if err := ensureWithinWatermarks(height, stateEarliest, latest); err != nil { + if err := ensureWithinWatermarks(height, stateEarliest, latest, ethrpcerrors.StatePruned); err != nil { return 0, err } return height, nil @@ -161,43 +156,45 @@ func (m *WatermarkManager) ResolveHeight(ctx context.Context, blockNrOrHash rpc. if heightPtr == nil { return latest, nil } - if err := ensureWithinWatermarks(*heightPtr, stateEarliest, latest); err != nil { + if err := ensureWithinWatermarks(*heightPtr, stateEarliest, latest, ethrpcerrors.StatePruned); err != nil { return 0, err } return *heightPtr, nil } -// EnsureBlockHeightAvailable verifies that the provided block height falls within -// the computed watermarks. +// EnsureBlockHeightAvailable verifies that the block at height is within the block +// watermarks, reporting an *ethrpcerrors.BlockUnavailable otherwise. func (m *WatermarkManager) EnsureBlockHeightAvailable(ctx context.Context, height int64) error { blockEarliest, _, latest, err := m.Watermarks(ctx) if err != nil { return err } - return ensureWithinWatermarks(height, blockEarliest, latest) + return ensureWithinWatermarks(height, blockEarliest, latest, ethrpcerrors.HistoryPruned) } -// EnsureReceiptHeightAvailable verifies that receipts for the given block height -// have not been pruned from the receipt store. This is a separate check from -// EnsureBlockHeightAvailable because the receipt store can be configured with a -// smaller KeepRecent than the block or state stores. +// EnsureReceiptHeightAvailable verifies that the receipts at height are still in the receipt +// store, which may keep fewer heights than the block or state stores, reporting an +// *ethrpcerrors.BlockUnavailable otherwise. func (m *WatermarkManager) EnsureReceiptHeightAvailable(height int64) error { if m.receiptStore == nil { return receipt.ErrNotConfigured } earliest := m.receiptStore.EarliestVersion() if height < earliest { - return fmt.Errorf("requested height %d receipts have been pruned; earliest available is %d", height, earliest) + return ethrpcerrors.HistoryPruned(height, earliest) } return nil } -func ensureWithinWatermarks(height, earliest, latest int64) error { +// ensureWithinWatermarks reports a height outside [earliest, latest]. pruned names the store the +// height fell out of, since state and block history are pruned independently and go-ethereum +// renders the two differently. +func ensureWithinWatermarks(height, earliest, latest int64, pruned func(height, earliest int64) *ethrpcerrors.BlockUnavailable) error { if height > latest { - return fmt.Errorf("requested height %d is not yet available; safe latest is %d: %w", height, latest, ErrBlockHeightNotYetAvailable) + return ethrpcerrors.BlockAboveLatest(height, latest) } if height < earliest { - return fmt.Errorf("requested height %d has been pruned; earliest available is %d", height, earliest) + return pruned(height, earliest) } return nil } @@ -243,17 +240,11 @@ func blockByHashRespectingWatermarks( return block, nil } -// blockByNumberOrNullForJSONRPC wraps blockByNumberRespectingWatermarks for -// Ethereum JSON-RPC endpoints that must return null (not an error) when the -// requested block sits above the safe-latest watermark — i.e. the block does -// not yet exist from the caller's perspective. This is the spec contract for -// endpoints that take a block identifier and return null for non-existent -// blocks (eth_getBlockByNumber, eth_getBlockByHash, eth_getBlockReceipts, -// eth_getTransactionByHash, eth_getTransactionByBlock*AndIndex, etc.). -// -// Internal call sites that genuinely need the error (state queries that must -// reject invalid heights, simulation paths bound to a specific block) keep -// using blockByNumberRespectingWatermarks directly. +// blockByNumberOrNullForJSONRPC is blockByNumberRespectingWatermarks for the endpoints that +// return a block or something inside one (eth_getBlockByNumber, eth_getBlockReceipts, +// eth_getTransactionByBlockNumberAndIndex, ...). A block that does not exist from the caller's +// point of view is (nil, nil), which the endpoint answers with null; pruned history stays an +// error, as in go-ethereum. func blockByNumberOrNullForJSONRPC( ctx context.Context, c client.LocalClient, @@ -262,17 +253,13 @@ func blockByNumberOrNullForJSONRPC( maxRetries int, ) (*coretypes.ResultBlock, error) { block, err := blockByNumberRespectingWatermarks(ctx, c, wm, heightPtr, maxRetries) - if errors.Is(err, ErrBlockHeightNotYetAvailable) { + if ethrpcerrors.IsBlockMissing(err) { return nil, nil } return block, err } -// blockByHashOrNullForJSONRPC is the by-hash counterpart of -// blockByNumberOrNullForJSONRPC. In addition to the above-watermark case it -// also converts ErrBlockNotFoundByHash to (nil, nil) — both are forms of -// "block doesn't exist from the caller's perspective" and the Ethereum -// JSON-RPC spec maps both to null. +// blockByHashOrNullForJSONRPC is the by-hash counterpart of blockByNumberOrNullForJSONRPC. func blockByHashOrNullForJSONRPC( ctx context.Context, c client.LocalClient, @@ -281,7 +268,7 @@ func blockByHashOrNullForJSONRPC( maxRetries int, ) (*coretypes.ResultBlock, error) { block, err := blockByHashRespectingWatermarks(ctx, c, wm, hash, maxRetries) - if errors.Is(err, ErrBlockHeightNotYetAvailable) || errors.Is(err, ErrBlockNotFoundByHash) { + if ethrpcerrors.IsBlockMissing(err) { return nil, nil } return block, err diff --git a/evmrpc/watermark_manager_test.go b/evmrpc/watermark_manager_test.go index 681b9761cc..b0e2927ad6 100644 --- a/evmrpc/watermark_manager_test.go +++ b/evmrpc/watermark_manager_test.go @@ -12,6 +12,7 @@ import ( "github.com/ethereum/go-ethereum/rpc" "github.com/stretchr/testify/require" + "github.com/sei-protocol/sei-chain/evmrpc/ethrpcerrors" "github.com/sei-protocol/sei-chain/sei-cosmos/client" storetypes "github.com/sei-protocol/sei-chain/sei-cosmos/store/types" sdk "github.com/sei-protocol/sei-chain/sei-cosmos/types" @@ -73,8 +74,9 @@ func TestResolveHeightGating(t *testing.T) { tooHigh := rpc.BlockNumber(6) _, err := wm.ResolveHeight(t.Context(), rpc.BlockNumberOrHash{BlockNumber: &tooHigh}) - require.Error(t, err) - require.Contains(t, err.Error(), "not yet available") + require.ErrorIs(t, err, ethrpcerrors.ErrBlockAboveLatest) + // State queries speak go-ethereum's header vocabulary on the wire. + require.Equal(t, "header not found", err.Error()) within := rpc.BlockNumber(4) height, err := wm.ResolveHeight(t.Context(), rpc.BlockNumberOrHash{BlockNumber: &within}) @@ -103,8 +105,8 @@ func TestEnsureBlockHeightAvailableBounds(t *testing.T) { require.NoError(t, wm.EnsureBlockHeightAvailable(t.Context(), 5)) - require.ErrorContains(t, wm.EnsureBlockHeightAvailable(t.Context(), 7), "not yet available") - require.ErrorContains(t, wm.EnsureBlockHeightAvailable(t.Context(), 2), "has been pruned") + require.ErrorIs(t, wm.EnsureBlockHeightAvailable(t.Context(), 7), ethrpcerrors.ErrBlockAboveLatest) + require.ErrorIs(t, wm.EnsureBlockHeightAvailable(t.Context(), 2), ethrpcerrors.ErrHistoryPruned) } func TestEnsureReceiptHeightAvailable(t *testing.T) { @@ -121,8 +123,14 @@ func TestEnsureReceiptHeightAvailable(t *testing.T) { t.Run("pruned receipt height returns error", func(t *testing.T) { rs := &fakeReceiptStore{latest: 200, earliest: 150} wm := NewWatermarkManager(tmClient, watermarkTestCtxProvider(200), nil, rs) - require.ErrorContains(t, wm.EnsureReceiptHeightAvailable(100), "receipts have been pruned") - require.ErrorContains(t, wm.EnsureReceiptHeightAvailable(149), "receipts have been pruned") + err := wm.EnsureReceiptHeightAvailable(100) + require.ErrorIs(t, err, ethrpcerrors.ErrHistoryPruned) + // Pruned history keeps go-ethereum's code and text all the way to the wire. + rpcErr, ok := err.(rpc.Error) + require.True(t, ok) + require.Equal(t, ethrpcerrors.CodePrunedHistory, rpcErr.ErrorCode()) + require.Equal(t, "pruned history unavailable", err.Error()) + require.ErrorIs(t, wm.EnsureReceiptHeightAvailable(149), ethrpcerrors.ErrHistoryPruned) }) t.Run("height within receipt retention succeeds", func(t *testing.T) { @@ -159,8 +167,8 @@ func TestResolveHeightUsesStateEarliest(t *testing.T) { belowState := rpc.BlockNumber(9) _, err := wm.ResolveHeight(t.Context(), rpc.BlockNumberOrHash{BlockNumber: &belowState}) - require.Error(t, err) - require.Contains(t, err.Error(), "has been pruned") + require.ErrorIs(t, err, ethrpcerrors.ErrStatePruned) + require.Equal(t, "missing trie node: state at height 9 is not available; earliest available is 10", err.Error()) within := rpc.BlockNumber(12) resolved, err := wm.ResolveHeight(t.Context(), rpc.BlockNumberOrHash{BlockNumber: &within}) @@ -295,8 +303,7 @@ func TestExplicitReadBelowGenesisFloorRejected(t *testing.T) { below := rpc.BlockNumber(50) _, err := wm.ResolveHeight(t.Context(), rpc.BlockNumberOrHash{BlockNumber: &below}) - require.Error(t, err) - require.Contains(t, err.Error(), "has been pruned") + require.ErrorIs(t, err, ethrpcerrors.ErrStatePruned) } func newTestWatermarkManager(tmClient client.LocalClient, ctxHeight int64, stateStore types.StateStore, receiptLatest int64) *WatermarkManager { @@ -537,7 +544,7 @@ func TestBlockByNumberOrNullForJSONRPC(t *testing.T) { h := int64(50) _, err := blockByNumberOrNullForJSONRPC(t.Context(), c, wm, &h, 0) require.Error(t, err) - require.False(t, errors.Is(err, ErrBlockHeightNotYetAvailable)) + require.False(t, ethrpcerrors.IsBlockMissing(err)) }) } @@ -557,8 +564,8 @@ func TestBlockByHashOrNullForJSONRPC(t *testing.T) { }) t.Run("unknown hash (Block: nil) returns (nil, nil)", func(t *testing.T) { - // blockByHashWithRetry wraps Block:nil as ErrBlockNotFoundByHash; - // the helper must catch that sentinel too. + // blockByHashWithRetry reports Block:nil as ethrpcerrors.ErrBlockUnknownHash; + // the helper must treat that as a missing block too. c := &fakeTMClient{status: stat, blockByHash: &coretypes.ResultBlock{Block: nil}} wm := newTestWatermarkManager(c, 100, nil, 100) block, err := blockByHashOrNullForJSONRPC(t.Context(), c, wm, []byte{0xbb}, 0) diff --git a/integration_test/rpc_tests/eth/eth_call.spec.ts b/integration_test/rpc_tests/eth/eth_call.spec.ts index c92020bc5f..279ccc3081 100644 --- a/integration_test/rpc_tests/eth/eth_call.spec.ts +++ b/integration_test/rpc_tests/eth/eth_call.spec.ts @@ -401,34 +401,26 @@ describe('eth_call Tests', function () { }); - it('[divergence] far-future block: both -32000 but different messages', async () => { + it('a far-future block fails identically (-32000 header not found)', async () => { const latest = await sei.getBlockNumber(); const data = erc20.balanceOf(seiAdmin); const [s, g] = await Promise.all([ rawSei('eth_call', [{ to: erc20Sei, data }, ethers.toQuantity(latest + 1_000_000)]), rawGeth('eth_call', [{ to: erc20Geth, data }, '0xffffffff']), ]); - expect(s.error?.code, 'sei code').to.equal(-32000); - expect(g.error?.code, 'geth code').to.equal(-32000); - expect(s.error?.code, 'codes still agree').to.equal(g.error?.code); - expect(s.error?.message).to.match(/not yet available/i); - expect(g.error?.message).to.match(/header not found/i); - expect(s.error?.message, 'documented divergence in message').to.not.equal(g.error?.message); + expectJsonRpcError(s, -32000, /^header not found$/); + expectSameError(s, g); }); - it('[divergence] unknown block hash: both -32000 but different messages', async () => { + it('an unknown block hash fails identically (-32000 header for hash not found)', async () => { const zeroHash = '0x' + '00'.repeat(32); const data = erc20.balanceOf(seiAdmin); const [s, g] = await Promise.all([ rawSei('eth_call', [{ to: erc20Sei, data }, { blockHash: zeroHash }]), rawGeth('eth_call', [{ to: erc20Geth, data }, { blockHash: zeroHash }]), ]); - expect(s.error?.code, 'sei code').to.equal(-32000); - expect(g.error?.code, 'geth code').to.equal(-32000); - expect(s.error?.code, 'codes still agree').to.equal(g.error?.code); - expect(s.error?.message).to.match(/block not found by hash/i); - expect(g.error?.message).to.match(/header for hash not found/i); - expect(s.error?.message, 'documented divergence in message').to.not.equal(g.error?.message); + expectJsonRpcError(s, -32000, /^header for hash not found$/); + expectSameError(s, g); }); it('the earliest tag either errors (-32000) or reads genesis state (0x)', async () => { diff --git a/integration_test/rpc_tests/eth/eth_estimateGas.spec.ts b/integration_test/rpc_tests/eth/eth_estimateGas.spec.ts index 32d42bca77..f1f49956c7 100644 --- a/integration_test/rpc_tests/eth/eth_estimateGas.spec.ts +++ b/integration_test/rpc_tests/eth/eth_estimateGas.spec.ts @@ -386,16 +386,13 @@ describe('eth_estimateGas Tests', function () { expect(s.error?.message).to.not.equal(g.error?.message); }); - it('[divergence] far-future block: both -32000 but different messages', async () => { + it('a far-future block fails identically (-32000 header not found)', async () => { const [s, g] = await Promise.all([ rawSei('eth_estimateGas', [{ from: seiAdmin, to: BOB, value: '0x1' }, '0xffffffff']), rawGeth('eth_estimateGas', [{ from: gethAdmin, to: BOB, value: '0x1' }, '0xffffffff']), ]); - expect(s.error?.code, 'sei code').to.equal(-32000); - expect(g.error?.code, 'geth code').to.equal(-32000); - expect(s.error?.message).to.match(/not yet available/i); - expect(g.error?.message).to.match(/header not found/i); - expect(s.error?.message).to.not.equal(g.error?.message); + expectJsonRpcError(s, -32000, /^header not found$/); + expectSameError(s, g); }); }); diff --git a/integration_test/rpc_tests/eth/eth_feeHistory.spec.ts b/integration_test/rpc_tests/eth/eth_feeHistory.spec.ts index d7ac59bd33..7abd7301b9 100644 --- a/integration_test/rpc_tests/eth/eth_feeHistory.spec.ts +++ b/integration_test/rpc_tests/eth/eth_feeHistory.spec.ts @@ -279,16 +279,16 @@ describe('eth_feeHistory Tests', function () { expect(g.error?.message).to.match(/percentile/i); }); - it('[divergence] a far-future newest block is rejected by both (-32000)', async () => { + it('a far-future newest block is rejected identically (-32000 request beyond head block)', async () => { const [s, g] = await Promise.all([ rawSei('eth_feeHistory', ['0x2', '0xffffffff', [50]]), rawGeth('eth_feeHistory', ['0x2', '0xffffffff', [50]]), ]); - expect(s.error?.code, 'sei code').to.equal(-32000); - expect(g.error?.code, 'geth code').to.equal(-32000); - expect(s.error?.message).to.match(/not yet available|beyond/i); - expect(g.error?.message).to.match(/beyond head block/i); - expect(s.error?.message).to.not.equal(g.error?.message); + // The message carries each node's own head, so it is compared up to that number. + const beyondHead = /^request beyond head block: requested 4294967295, head \d+$/; + expectJsonRpcError(s, -32000, beyondHead); + expectJsonRpcError(g, -32000, beyondHead); + expect(s.error!.code, 'error.code parity').to.equal(g.error!.code); }); // TODO: Sei returns oldestBlock null (not 0x0) for blockCount 0 — revisit. Skipped for now. diff --git a/integration_test/rpc_tests/eth/eth_getTransactionCount.spec.ts b/integration_test/rpc_tests/eth/eth_getTransactionCount.spec.ts index 837b981150..da70b3c5b2 100644 --- a/integration_test/rpc_tests/eth/eth_getTransactionCount.spec.ts +++ b/integration_test/rpc_tests/eth/eth_getTransactionCount.spec.ts @@ -269,10 +269,14 @@ describe('eth_getTransactionCount', function () { expectSameError(s, g); }); - it('an unknown future block returns undefined (does not panic)', async () => { + it('a far-future block fails identically (-32000 header not found)', async () => { const future = ethers.toQuantity((await sei.getBlockNumber()) + 10_000_000); - const res = await rawSei('eth_getTransactionCount', [seiAdmin, future]); - expect(res.error!.message).to.contain('is not yet available'); + const [s, g] = await Promise.all([ + rawSei('eth_getTransactionCount', [seiAdmin, future]), + rawGeth('eth_getTransactionCount', [gethAdmin, future]), + ]); + expectJsonRpcError(s, -32000, /^header not found$/); + expectSameError(s, g); }); }); }); diff --git a/integration_test/rpc_tests/utils/format.ts b/integration_test/rpc_tests/utils/format.ts index c702ea29da..f65cce9edd 100644 --- a/integration_test/rpc_tests/utils/format.ts +++ b/integration_test/rpc_tests/utils/format.ts @@ -28,7 +28,8 @@ export const NONCE8 = /^0x[0-9a-fA-F]{16}$/; /** Opaque, lower-case hex handle (filter id, subscription id) — random, so not minimally encoded, only "0x + lower hex". */ export const OPAQUE_HEX_ID = /^0x[0-9a-f]+$/; -export const EARLY_STATE_ERROR = /pruned|evm module does not exist/i; +/** State-backed query at a height whose state is gone: geth's trie-node error, with Sei's height detail after the colon. */ +export const EARLY_STATE_ERROR = /^missing trie node: state at height \d+ is not available/; /** A uint256 as its canonical left-padded 32-byte word (ABI word / storage slot value). */ export const uint256Word = (value: bigint): string => ethers.toBeHex(value, 32);