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
11 changes: 5 additions & 6 deletions contracts/test/lib.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
}
Expand Down
43 changes: 43 additions & 0 deletions evmrpc/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
162 changes: 162 additions & 0 deletions evmrpc/ethrpcerrors/block.go
Original file line number Diff line number Diff line change
@@ -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)}
}
Loading
Loading