From 97ed20250af9266cecccaaae7cf8bd62ea8050eb Mon Sep 17 00:00:00 2001 From: masih Date: Tue, 15 Sep 2026 15:00:52 +0000 Subject: [PATCH 1/4] Persist the EVM-only execution cursor with each block's FlatKV state FlatKV state became durable in FinalizeBlock while the app's height, app hash and parent hash lived only in memory, so a restarted evmonly node reported height 0, re-ran InitChain and block 1, and crash-looped on existing nonces. The cursor is now written as an evmonly named changeset in the same CommitStateChanges call as the block's state, restored on construction, and a non-empty store refuses InitChain at any initial height. --- docs/rfc/rfc-002-evmonly-durable-cursor.md | 104 +++++++++ giga/evmonly/README.md | 9 + giga/evmonly/executor.go | 14 +- giga/evmonly/giga_store.go | 14 ++ sei-tendermint/internal/evmonlyapp/app.go | 202 ++++++++++++------ .../internal/evmonlyapp/app_test.go | 162 +++++++++++++- sei-tendermint/internal/evmonlyapp/cursor.go | 89 ++++++++ .../internal/p2p/giga_router_common.go | 8 +- sei-tendermint/node/public.go | 5 +- 9 files changed, 531 insertions(+), 76 deletions(-) create mode 100644 docs/rfc/rfc-002-evmonly-durable-cursor.md create mode 100644 sei-tendermint/internal/evmonlyapp/cursor.go diff --git a/docs/rfc/rfc-002-evmonly-durable-cursor.md b/docs/rfc/rfc-002-evmonly-durable-cursor.md new file mode 100644 index 0000000000..eaa60bf76c --- /dev/null +++ b/docs/rfc/rfc-002-evmonly-durable-cursor.md @@ -0,0 +1,104 @@ +# RFC 002: Durable Execution Cursor for the EVM-only Application + +## Changelog + +- 2026-09-15: Initial draft + +## Abstract + +The EVM-only Autobahn application (`sei-tendermint/internal/evmonlyapp`) kept +its execution cursor, the last committed height, app hash and parent block +hash, only in memory, while the state it executed against was made durable by +FlatKV on every block. After a crash the two disagreed: the application +reported height 0, the Giga router ran `InitChain` and block 1 again, and the +node crash-looped on the nonces already in storage. This RFC records the +decision to persist the cursor alongside each block's state, in the same FlatKV +version, and to derive `Info()` from storage on construction. + +## Background + +In Giga mode the router (`internal/p2p/giga_router_common.go`) does not run the +CometBFT handshake. On start it calls `app.Info()`; a `LastBlockHeight` of zero +takes the fresh-genesis branch (`InitChain`, then block 1), anything else takes +the restart branch (`InitLastHeader` and pushing the last app hash). The +application therefore is the only source of truth for where execution stands. + +The EVM-only executor (`giga/evmonly`) is store-backed: `ExecuteBlock` writes +receipts and calls `StateDB.CommitStateChanges(height, changesets)` before it +returns, so FlatKV state is durable once `FinalizeBlock` completes. ABCI +`Commit` then only advanced the in-memory cursor. Nothing rebuilt that cursor on +the next process start, and the `initialHeight == 1` early return in the +InitChain guard meant a non-empty store at the default initial height was never +refused. + +## Discussion + +### What is stored + +Height needs no new storage: it is `SC().GetLatestVersion()` of the reopened +FlatKV store, which `OpenDBWithRecovery` has already converged with the block +store, state WAL and receipt store. + +The app hash, parent block hash and block gas limit are stored as one record, +key `cursor` under a named changeset `evmonly`, which FlatKV routes to its misc +store under a module prefix, outside `keys.EVMStoreKey`. The record is +`height ‖ appHash ‖ parentHash ‖ gasLimit` (8 + 32 + 32 + 8 bytes). The height is +redundant with the storage version and is checked against it on load, so a +cursor that somehow belongs to a different version is refused rather than +trusted. + +### How it is written + +The executor gains an optional `BlockChangeSetEncoder`, called after execution +with the block context and result. Its changesets are appended to the state +encoder's output and passed to the same `CommitStateChanges` call, so the cursor +and the state it describes share a FlatKV version, a WAL entry and a recovery +outcome. The application supplies an encoder that chains the block into the app +hash (the existing SHA-256 chain over the memory-store encoding of the result, +unchanged), stages the resulting cursor as pending and returns it as the +`evmonly` changeset. `FinalizeBlock` reports the pending app hash; `Commit` +promotes pending to committed in memory, as before. + +The EVM state store filters to `keys.EVMStoreKey` changesets, so the cursor +never reaches the state-sync store or the LtHash. + +### How it is restored + +`NewEVMOnlyApplication` reads the cursor of the store's latest version. If one +is present it builds the executor and seeds the committed cursor, so `Info()` +reports the durable height and app hash and the router takes its restart +branch. The gas limit lives in the record because `InitChain`, which used to +supply it from consensus params, is not called on restart. + +`InitChain` is refused once an executor exists, and `seedInitialStateVersion` +no longer skips the check at initial height 1: a store at any version other +than zero or `initialHeight - 1` is refused. The `initialHeight - 1` case is a +store seeded by an earlier `InitChain` with no block yet, which a crash between +`InitChain` and the first block leaves behind; re-running `InitChain` there is +the fresh start the router expects. + +### Crash window + +A node that crashes after `FinalizeBlock(N)` returned and before `Commit(N)` +has N durable in FlatKV and restarts reporting height N, not N-1. This is the +correct answer for a store-backed executor: replaying N would fail on its own +nonces. The router's restart branch handles a durable app tip ahead of the +block store's by syncing the missing suffix. + +### Alternatives considered + +- Deriving the app hash from FlatKV's LtHash instead of persisting it. This + changes what the app hash means and therefore consensus behaviour; it is left + as a separate decision. +- Passing `RequestInitChain` to the constructor so the gas limit is available on + restart without persisting it. Storing it with the cursor keeps the record + self-describing and keeps the constructor's signature, which the node and + tests already share. +- Writing the cursor in ABCI `Commit`. That reintroduces the window in which + state is durable and the cursor is not; the cursor has to travel with the + state. + +### References + +- https://github.com/sei-protocol/sei-chain/issues/4169 +- `giga/evmonly/README.md`, store-backed execution diff --git a/giga/evmonly/README.md b/giga/evmonly/README.md index f67296b1e8..b723ff1b97 100644 --- a/giga/evmonly/README.md +++ b/giga/evmonly/README.md @@ -98,6 +98,15 @@ including for empty blocks. A receipt failure leaves state unchanged so the block can be retried. A state failure can leave receipts behind, but retrying the block overwrites them. `ResultSink` runs only after both stores succeed. +Because state becomes durable inside `ExecuteBlock`, before any ABCI `Commit`, +an ABCI application built on the executor must derive `Info()` from storage +rather than from memory: after a restart, the Giga router calls `InitChain` and +replays block 1 whenever `Info().LastBlockHeight` is zero, which fails against +state that already exists. `WithBlockChangeSetEncoder(...)` lets the +application commit its own named changesets (for example an execution cursor +holding the app hash and parent hash) in the same `CommitStateChanges` call as +the block's EVM state, so the two can never disagree on disk. + The FlatKV encoder persists balance, nonce, code, and storage changes, and the executor reads them through the current Giga state view. EVM-only Autobahn load tests use `WithMissingAccountState(...)` to supply the initial funded state for diff --git a/giga/evmonly/executor.go b/giga/evmonly/executor.go index 8d46de2500..1bb049d493 100644 --- a/giga/evmonly/executor.go +++ b/giga/evmonly/executor.go @@ -30,8 +30,10 @@ type Executor struct { stateStore gigatypes.StateDB receiptStore receipt.ReceiptStore changeSetEncoder NamedChangeSetEncoder - missingState StateReader - closed atomic.Bool + // Optional: nil commits only the state encoder's changesets. + blockChangeSetEncoder BlockChangeSetEncoder + missingState StateReader + closed atomic.Bool } type Option func(*Executor) @@ -50,6 +52,14 @@ func WithMissingAccountState(state StateReader) Option { } } +// WithBlockChangeSetEncoder commits the encoder's changesets alongside every +// block's state changes. +func WithBlockChangeSetEncoder(encoder BlockChangeSetEncoder) Option { + return func(e *Executor) { + e.blockChangeSetEncoder = encoder + } +} + // NewExecutor constructs an EVM-only executor. Call Close to disable future OCC // execution on this executor. func NewExecutor(cfg Config, opts ...Option) *Executor { diff --git a/giga/evmonly/giga_store.go b/giga/evmonly/giga_store.go index 7c7190eec4..1e820cbd81 100644 --- a/giga/evmonly/giga_store.go +++ b/giga/evmonly/giga_store.go @@ -29,6 +29,13 @@ var _ StateReader = gigaSnapshotStateReader{} // immutable and must not retain references to it after returning. type NamedChangeSetEncoder func(StateChangeSet) ([]*proto.NamedChangeSet, error) +// BlockChangeSetEncoder contributes named changesets that are committed in the +// same CommitStateChanges call as the block's EVM state changes, so they are +// durable, rolled back and replayed together with that state. It is called +// after execution with the block's context and result, which it must treat as +// immutable. Changesets under keys.EVMStoreKey are reserved for the state encoder. +type BlockChangeSetEncoder func(BlockContext, *BlockResult) ([]*proto.NamedChangeSet, error) + func (e *Executor) executePreparedBlockWithStore(ctx context.Context, req PreparedBlock) (*BlockResult, error) { stateStore := e.stateStore if stateStore == nil { @@ -84,6 +91,13 @@ func (e *Executor) executePreparedBlockWithStore(ctx context.Context, req Prepar if err != nil { return nil, fmt.Errorf("encode state changes for block %d: %w", req.Context.Number, err) } + if e.blockChangeSetEncoder != nil { + extra, err := e.blockChangeSetEncoder(req.Context, result) + if err != nil { + return nil, fmt.Errorf("encode block changes for block %d: %w", req.Context.Number, err) + } + changesets = append(changesets, extra...) + } if err := ctx.Err(); err != nil { return nil, err } diff --git a/sei-tendermint/internal/evmonlyapp/app.go b/sei-tendermint/internal/evmonlyapp/app.go index 20c6d466fc..4184487d8d 100644 --- a/sei-tendermint/internal/evmonlyapp/app.go +++ b/sei-tendermint/internal/evmonlyapp/app.go @@ -17,6 +17,7 @@ import ( "github.com/sei-protocol/sei-chain/giga/evmonly" "github.com/sei-protocol/sei-chain/sei-db/bootstrap" + "github.com/sei-protocol/sei-chain/sei-db/proto" gigatypes "github.com/sei-protocol/sei-chain/sei-db/state_db/giga/types" abci "github.com/sei-protocol/sei-chain/sei-tendermint/abci/types" "github.com/sei-protocol/sei-chain/sei-tendermint/libs/utils" @@ -38,45 +39,98 @@ type evmOnlyApplication struct { storage *bootstrap.GigaStorageManager changeSetEncoder evmonly.NamedChangeSetEncoder validators []abci.ValidatorUpdate - state utils.Mutex[*evmOnlyState] + executor utils.Mutex[*utils.Option[*evmonly.Executor]] + // Lock order: executor before cursor. FinalizeBlock holds executor while + // the block's cursor encoder takes cursor. + cursor utils.Mutex[*evmOnlyCursorState] } -type evmOnlyState struct { - executor utils.Option[*evmonly.Executor] - gasLimit uint64 - nextHeight int64 - committedHeight int64 - appHash common.Hash - parentHash common.Hash - pending utils.Option[evmOnlyPending] -} - -type evmOnlyPending struct { - height int64 - appHash common.Hash - blockHash common.Hash +// evmOnlyCursorState is the execution position: the block whose state is +// committed to storage and the block finalized but not yet acknowledged by +// Commit. +type evmOnlyCursorState struct { + committed evmOnlyCursor + pending utils.Option[evmOnlyCursor] } var _ abci.Application = (*evmOnlyApplication)(nil) // NewEVMOnlyApplication returns the raw-Ethereum application used by Autobahn -// load tests. State, receipts, and blocks are owned by storage. +// load tests. State, receipts, and blocks are owned by storage. A storage that +// already holds committed blocks resumes from its durable cursor, so Info +// reports the stored height and InitChain is refused. func NewEVMOnlyApplication( chainID uint64, validators []abci.ValidatorUpdate, storage *bootstrap.GigaStorageManager, changeSetEncoder evmonly.NamedChangeSetEncoder, -) abci.Application { +) (abci.Application, error) { chainConfig := *params.AllDevChainProtocolChanges chainConfig.ChainID = new(big.Int).SetUint64(chainID) - return &evmOnlyApplication{ + a := &evmOnlyApplication{ chainID: new(big.Int).SetUint64(chainID), chainConfig: &chainConfig, storage: storage, changeSetEncoder: changeSetEncoder, validators: slices.Clone(validators), - state: utils.NewMutex(&evmOnlyState{}), + executor: utils.NewMutex(new(utils.Option[*evmonly.Executor])), + cursor: utils.NewMutex(&evmOnlyCursorState{}), + } + cursor, err := loadEVMOnlyCursor(storage.SC()) + if err != nil { + return nil, err + } + if cursor, ok := cursor.Get(); ok { + for executor := range a.executor.Lock() { + *executor = utils.Some(a.newExecutor()) + } + for state := range a.cursor.Lock() { + state.committed = cursor + } + } + return a, nil +} + +func (a *evmOnlyApplication) newExecutor() *evmonly.Executor { + return evmonly.NewExecutor(evmonly.Config{ + ChainConfig: a.chainConfig, + MinGasPrice: big.NewInt(evmOnlyMinGasPrice), + OCCWorkers: runtime.GOMAXPROCS(0), + ParseWorkers: runtime.GOMAXPROCS(0), + BlockResultPoolSize: 1, + }, + evmonly.WithStorageManager(a.storage, a.changeSetEncoder), + evmonly.WithMissingAccountState(evmOnlyFundedState{}), + evmonly.WithBlockChangeSetEncoder(a.encodeCursorChangeSet), + ) +} + +// encodeCursorChangeSet chains the block into the app hash and stages the +// resulting cursor as pending, returning it as the changeset committed with +// the block's state. +func (a *evmOnlyApplication) encodeCursorChangeSet(block evmonly.BlockContext, result *evmonly.BlockResult) ([]*proto.NamedChangeSet, error) { + height, ok := utils.SafeCast[int64](block.Number) + if !ok { + return nil, fmt.Errorf("EVM-only block number exceeds int64: %d", block.Number) + } + for state := range a.cursor.Lock() { + if height != state.committed.height+1 { + return nil, fmt.Errorf("EVM-only block height %d does not follow committed height %d", height, state.committed.height) + } + appHash, err := hashEVMOnlyResult(state.committed.appHash, block.Number, block.BlockHash, result) + if err != nil { + return nil, err + } + next := evmOnlyCursor{ + height: height, + appHash: appHash, + blockHash: block.BlockHash, + gasLimit: block.GasLimit, + } + state.pending = utils.Some(next) + return []*proto.NamedChangeSet{next.changeSet()}, nil } + panic("unreachable") } func (a *evmOnlyApplication) InitChain(req *abci.RequestInitChain) (*abci.ResponseInitChain, error) { @@ -90,38 +144,36 @@ func (a *evmOnlyApplication) InitChain(req *abci.RequestInitChain) (*abci.Respon if err := a.seedInitialStateVersion(req.InitialHeight); err != nil { return nil, err } - for state := range a.state.Lock() { - if state.executor.IsPresent() { + for executor := range a.executor.Lock() { + if executor.IsPresent() { return nil, fmt.Errorf("EVM-only application already initialized") } - state.executor = utils.Some(evmonly.NewExecutor(evmonly.Config{ - ChainConfig: a.chainConfig, - MinGasPrice: big.NewInt(evmOnlyMinGasPrice), - OCCWorkers: runtime.GOMAXPROCS(0), - ParseWorkers: runtime.GOMAXPROCS(0), - BlockResultPoolSize: 1, - }, - evmonly.WithStorageManager(a.storage, a.changeSetEncoder), - evmonly.WithMissingAccountState(evmOnlyFundedState{}), - )) - state.gasLimit = gasLimit - state.nextHeight = req.InitialHeight - state.committedHeight = req.InitialHeight - 1 + if err := a.seedInitialStateVersion(req.InitialHeight); err != nil { + return nil, err + } + *executor = utils.Some(a.newExecutor()) + for state := range a.cursor.Lock() { + state.committed = evmOnlyCursor{height: req.InitialHeight - 1, gasLimit: gasLimit} + } return &abci.ResponseInitChain{}, nil } panic("unreachable") } +// seedInitialStateVersion moves an empty state store to the version preceding +// initialHeight. A store already seeded there is accepted, since a crash +// between InitChain and the first block leaves it that way; a store holding +// any block is refused. func (a *evmOnlyApplication) seedInitialStateVersion(initialHeight int64) error { stateStore := a.storage.SC() - if stateStore == nil || initialHeight == 1 { - return nil - } latest, err := stateStore.GetLatestVersion() if err != nil { return fmt.Errorf("read EVM-only state version: %w", err) } - if latest != 0 { + switch { + case latest == initialHeight-1: + return nil + case latest != 0: return fmt.Errorf("EVM-only state is already at height %d before InitChain", latest) } if err := stateStore.SetInitialVersion(initialHeight); err != nil { @@ -142,19 +194,19 @@ func evmOnlyGasLimit(req *abci.RequestInitChain) (uint64, error) { } func (a *evmOnlyApplication) Info() *abci.ResponseInfo { - for state := range a.state.Lock() { + for state := range a.cursor.Lock() { return &abci.ResponseInfo{ Data: "evmonly", - LastBlockHeight: state.committedHeight, - LastBlockAppHash: append([]byte(nil), state.appHash[:]...), + LastBlockHeight: state.committed.height, + LastBlockAppHash: append([]byte(nil), state.committed.appHash[:]...), } } panic("unreachable") } func (a *evmOnlyApplication) LastBlockHeight() int64 { - for state := range a.state.Lock() { - return state.committedHeight + for state := range a.cursor.Lock() { + return state.committed.height } panic("unreachable") } @@ -252,59 +304,87 @@ func (a *evmOnlyApplication) FinalizeBlock(ctx context.Context, req *abci.Reques return nil, fmt.Errorf("EVM-only block timestamp is negative: %s", req.Header.Time) } blockHash := common.BytesToHash(req.Hash) - for state := range a.state.Lock() { - executor, ok := state.executor.Get() + for executor := range a.executor.Lock() { + executor, ok := executor.Get() if !ok { return nil, fmt.Errorf("EVM-only block finalized before InitChain") } - if state.pending.IsPresent() { - return nil, fmt.Errorf("EVM-only block %d finalized before committing the previous block", height) - } - if height != state.nextHeight { - return nil, fmt.Errorf("EVM-only block height %d does not match next height %d", height, state.nextHeight) + parent, err := a.beginBlock(height) + if err != nil { + return nil, err } result, err := executor.ExecuteBlock(ctx, evmonly.BlockRequest{ Context: evmonly.BlockContext{ Number: number, Time: timestamp, - GasLimit: state.gasLimit, + GasLimit: parent.gasLimit, ChainID: new(big.Int).Set(a.chainID), BaseFee: evmOnlyBaseFee(), BlobBaseFee: new(big.Int), - ParentHash: state.parentHash, + ParentHash: parent.blockHash, BlockHash: blockHash, PrevRandao: crypto.Keccak256Hash(binary.BigEndian.AppendUint64(nil, timestamp)), }, Txs: req.Txs, }) if err != nil { + a.abandonPending() return nil, err } defer result.Release() - appHash, err := hashEVMOnlyResult(state.appHash, number, blockHash, result) + pending, err := a.pendingCursor(height) if err != nil { return nil, err } - state.pending = utils.Some(evmOnlyPending{height: height, appHash: appHash, blockHash: blockHash}) return &abci.ResponseFinalizeBlock{ - AppHash: append([]byte(nil), appHash[:]...), + AppHash: append([]byte(nil), pending.appHash[:]...), TxResults: evmOnlyABCIResults(result), }, nil } panic("unreachable") } +// beginBlock checks height is the next block to finalize and returns the +// committed cursor it builds on. +func (a *evmOnlyApplication) beginBlock(height int64) (evmOnlyCursor, error) { + for state := range a.cursor.Lock() { + if state.pending.IsPresent() { + return evmOnlyCursor{}, fmt.Errorf("EVM-only block %d finalized before committing the previous block", height) + } + if next := state.committed.height + 1; height != next { + return evmOnlyCursor{}, fmt.Errorf("EVM-only block height %d does not match next height %d", height, next) + } + return state.committed, nil + } + panic("unreachable") +} + +// abandonPending drops a cursor staged by a block whose commit failed. +func (a *evmOnlyApplication) abandonPending() { + for state := range a.cursor.Lock() { + state.pending = utils.None[evmOnlyCursor]() + } +} + +func (a *evmOnlyApplication) pendingCursor(height int64) (evmOnlyCursor, error) { + for state := range a.cursor.Lock() { + pending, ok := state.pending.Get() + if !ok || pending.height != height { + return evmOnlyCursor{}, fmt.Errorf("EVM-only block %d committed without staging its cursor", height) + } + return pending, nil + } + panic("unreachable") +} + func (a *evmOnlyApplication) Commit(context.Context) (*abci.ResponseCommit, error) { - for state := range a.state.Lock() { + for state := range a.cursor.Lock() { pending, ok := state.pending.Get() if !ok { return nil, fmt.Errorf("EVM-only Commit called without a finalized block") } - state.committedHeight = pending.height - state.nextHeight = pending.height + 1 - state.appHash = pending.appHash - state.parentHash = pending.blockHash - state.pending = utils.None[evmOnlyPending]() + state.committed = pending + state.pending = utils.None[evmOnlyCursor]() return &abci.ResponseCommit{}, nil } panic("unreachable") diff --git a/sei-tendermint/internal/evmonlyapp/app_test.go b/sei-tendermint/internal/evmonlyapp/app_test.go index 1c2cf21ab9..8901f39841 100644 --- a/sei-tendermint/internal/evmonlyapp/app_test.go +++ b/sei-tendermint/internal/evmonlyapp/app_test.go @@ -1,6 +1,8 @@ package evmonlyapp import ( + "crypto/ecdsa" + "encoding/binary" "math/big" "testing" "time" @@ -23,6 +25,11 @@ func signedEVMOnlyTestTx(t *testing.T, chainID uint64, nonce uint64) ([]byte, co t.Helper() key, err := crypto.GenerateKey() require.NoError(t, err) + return signedEVMOnlyTestTxFrom(t, key, chainID, nonce), crypto.PubkeyToAddress(key.PublicKey) +} + +func signedEVMOnlyTestTxFrom(t *testing.T, key *ecdsa.PrivateKey, chainID uint64, nonce uint64) []byte { + t.Helper() recipient := common.HexToAddress("0x1000000000000000000000000000000000000001") tx := ethtypes.NewTx(ðtypes.LegacyTx{ Nonce: nonce, @@ -35,30 +42,73 @@ func signedEVMOnlyTestTx(t *testing.T, chainID uint64, nonce uint64) ([]byte, co require.NoError(t, err) raw, err := signed.MarshalBinary() require.NoError(t, err) - return raw, crypto.PubkeyToAddress(key.PublicKey) + return raw } -func newInitializedEVMOnlyTestApp(t *testing.T) abci.Application { - t.Helper() - app := newEVMOnlyTestApp(t, nil) - _, err := app.InitChain(&abci.RequestInitChain{ +func evmOnlyTestInitChain() *abci.RequestInitChain { + return &abci.RequestInitChain{ InitialHeight: 1, ConsensusParams: &tmproto.ConsensusParams{ Block: &tmproto.BlockParams{MaxGas: 30_000_000}, }, - }) + } +} + +func newInitializedEVMOnlyTestApp(t *testing.T) abci.Application { + t.Helper() + app := newEVMOnlyTestApp(t, nil) + _, err := app.InitChain(evmOnlyTestInitChain()) require.NoError(t, err) return app } func newEVMOnlyTestApp(t *testing.T, validators []abci.ValidatorUpdate) abci.Application { t.Helper() - storageConfig, err := evmonly.NewValidatorStorageConfig(t.TempDir()) + storage := openEVMOnlyTestStorage(t, t.TempDir()) + t.Cleanup(func() { require.NoError(t, storage.Close()) }) + app, err := NewEVMOnlyApplication(evmOnlyTestChainID, validators, storage, evmonly.NewFlatKVChangeSetEncoder(storage.SC())) + require.NoError(t, err) + return app +} + +func openEVMOnlyTestStorage(t *testing.T, home string) *bootstrap.GigaStorageManager { + t.Helper() + storageConfig, err := evmonly.NewValidatorStorageConfig(home) require.NoError(t, err) storage, err := bootstrap.NewGigaStorageManager(t.Context(), storageConfig) require.NoError(t, err) - t.Cleanup(func() { require.NoError(t, storage.Close()) }) - return NewEVMOnlyApplication(evmOnlyTestChainID, validators, storage, evmonly.NewFlatKVChangeSetEncoder(storage.SC())) + return storage +} + +// reopenEVMOnlyTestApp closes storage and constructs a fresh application over +// the same home, the way a restarted process does. +func reopenEVMOnlyTestApp(t *testing.T, storage *bootstrap.GigaStorageManager, home string) (abci.Application, *bootstrap.GigaStorageManager) { + t.Helper() + require.NoError(t, storage.Close()) + reopened := openEVMOnlyTestStorage(t, home) + app, err := NewEVMOnlyApplication(evmOnlyTestChainID, nil, reopened, evmonly.NewFlatKVChangeSetEncoder(reopened.SC())) + require.NoError(t, err) + return app, reopened +} + +func evmOnlyTestBlock(height int64, txs ...[]byte) *abci.RequestFinalizeBlock { + return &abci.RequestFinalizeBlock{ + Txs: txs, + Hash: crypto.Keccak256(binary.BigEndian.AppendUint64([]byte("block-"), uint64(height))), //nolint:gosec // G115: test heights are positive. + Header: &tmproto.Header{ + Height: height, + Time: time.Unix(1_700_000_000+height, 0), + }, + } +} + +func finalizeAndCommitEVMOnlyTestBlock(t *testing.T, app abci.Application, req *abci.RequestFinalizeBlock) []byte { + t.Helper() + response, err := app.FinalizeBlock(t.Context(), req) + require.NoError(t, err) + _, err = app.Commit(t.Context()) + require.NoError(t, err) + return response.AppHash } func TestEVMOnlyApplicationExecutesRawEthereumBlock(t *testing.T) { @@ -192,6 +242,100 @@ func TestEVMOnlyApplicationProducesDeterministicRoot(t *testing.T) { require.Equal(t, firstResponse.AppHash, secondResponse.AppHash) } +// A restarted node must resume from the height and app hash its storage holds, +// and continue executing without an InitChain. The reference app runs the same +// blocks without restarting, so the resumed chain has to match it hash for hash. +func TestEVMOnlyApplicationResumesFromStorageAfterRestart(t *testing.T) { + key, err := crypto.GenerateKey() + require.NoError(t, err) + sender := crypto.PubkeyToAddress(key.PublicKey) + const blocks = 3 + block := func(height int64) *abci.RequestFinalizeBlock { + return evmOnlyTestBlock(height, signedEVMOnlyTestTxFrom(t, key, evmOnlyTestChainID, uint64(height-1))) //nolint:gosec // G115: test heights are positive. + } + + reference := newInitializedEVMOnlyTestApp(t) + wantHashes := make([][]byte, 0, blocks+1) + for height := range int64(blocks + 1) { + wantHashes = append(wantHashes, finalizeAndCommitEVMOnlyTestBlock(t, reference, block(height+1))) + } + + home := t.TempDir() + storage := openEVMOnlyTestStorage(t, home) + app, err := NewEVMOnlyApplication(evmOnlyTestChainID, nil, storage, evmonly.NewFlatKVChangeSetEncoder(storage.SC())) + require.NoError(t, err) + _, err = app.InitChain(evmOnlyTestInitChain()) + require.NoError(t, err) + for height := range int64(blocks) { + require.Equal(t, wantHashes[height], finalizeAndCommitEVMOnlyTestBlock(t, app, block(height+1))) + } + + app, storage = reopenEVMOnlyTestApp(t, storage, home) + t.Cleanup(func() { require.NoError(t, storage.Close()) }) + + info := app.Info() + require.Equal(t, int64(blocks), info.LastBlockHeight) + require.Equal(t, wantHashes[blocks-1], info.LastBlockAppHash) + require.Equal(t, uint64(blocks), app.EvmNonce(sender)) + _, err = app.InitChain(evmOnlyTestInitChain()) + require.Error(t, err) + require.Equal(t, wantHashes[blocks], finalizeAndCommitEVMOnlyTestBlock(t, app, block(blocks+1))) + require.Equal(t, int64(blocks+1), app.LastBlockHeight()) +} + +// State is committed by FinalizeBlock, so a crash before Commit leaves the +// finalized block durable. The restarted node must report it rather than +// execute it a second time. +func TestEVMOnlyApplicationResumesFromBlockFinalizedButNotCommitted(t *testing.T) { + key, err := crypto.GenerateKey() + require.NoError(t, err) + block := func(height int64) *abci.RequestFinalizeBlock { + return evmOnlyTestBlock(height, signedEVMOnlyTestTxFrom(t, key, evmOnlyTestChainID, uint64(height-1))) //nolint:gosec // G115: test heights are positive. + } + + home := t.TempDir() + storage := openEVMOnlyTestStorage(t, home) + app, err := NewEVMOnlyApplication(evmOnlyTestChainID, nil, storage, evmonly.NewFlatKVChangeSetEncoder(storage.SC())) + require.NoError(t, err) + _, err = app.InitChain(evmOnlyTestInitChain()) + require.NoError(t, err) + finalizeAndCommitEVMOnlyTestBlock(t, app, block(1)) + finalized, err := app.FinalizeBlock(t.Context(), block(2)) + require.NoError(t, err) + + app, storage = reopenEVMOnlyTestApp(t, storage, home) + t.Cleanup(func() { require.NoError(t, storage.Close()) }) + + info := app.Info() + require.Equal(t, int64(2), info.LastBlockHeight) + require.Equal(t, finalized.AppHash, info.LastBlockAppHash) + finalizeAndCommitEVMOnlyTestBlock(t, app, block(3)) + require.Equal(t, int64(3), app.LastBlockHeight()) +} + +// InitChain with an initial height above one seeds the store before any block +// exists. Crashing there must still allow InitChain to run again. +func TestEVMOnlyApplicationRepeatsInitChainAfterSeedingOnly(t *testing.T) { + home := t.TempDir() + storage := openEVMOnlyTestStorage(t, home) + app, err := NewEVMOnlyApplication(evmOnlyTestChainID, nil, storage, evmonly.NewFlatKVChangeSetEncoder(storage.SC())) + require.NoError(t, err) + init := evmOnlyTestInitChain() + init.InitialHeight = 5 + _, err = app.InitChain(init) + require.NoError(t, err) + + app, storage = reopenEVMOnlyTestApp(t, storage, home) + t.Cleanup(func() { require.NoError(t, storage.Close()) }) + + require.Equal(t, int64(0), app.Info().LastBlockHeight) + _, err = app.InitChain(init) + require.NoError(t, err) + require.Equal(t, int64(4), app.Info().LastBlockHeight) + finalizeAndCommitEVMOnlyTestBlock(t, app, evmOnlyTestBlock(5)) + require.Equal(t, int64(5), app.LastBlockHeight()) +} + func TestEVMOnlyApplicationRequiresInitChain(t *testing.T) { app := newEVMOnlyTestApp(t, nil) diff --git a/sei-tendermint/internal/evmonlyapp/cursor.go b/sei-tendermint/internal/evmonlyapp/cursor.go new file mode 100644 index 0000000000..78c0ec9ea2 --- /dev/null +++ b/sei-tendermint/internal/evmonlyapp/cursor.go @@ -0,0 +1,89 @@ +package evmonlyapp + +import ( + "encoding/binary" + "fmt" + + "github.com/ethereum/go-ethereum/common" + + "github.com/sei-protocol/sei-chain/sei-db/proto" + "github.com/sei-protocol/sei-chain/sei-db/state_db/sc/flatkv" + "github.com/sei-protocol/sei-chain/sei-tendermint/libs/utils" +) + +// The cursor is stored as a named changeset outside keys.EVMStoreKey, so it +// shares the block's storage version without entering the EVM state hash. +const ( + evmOnlyCursorModule = "evmonly" + evmOnlyCursorKey = "cursor" + evmOnlyCursorSize = 8 + common.HashLength + common.HashLength + 8 +) + +// evmOnlyCursor identifies a block whose state is committed to storage and +// what the next block executes against. +type evmOnlyCursor struct { + height int64 + appHash common.Hash + blockHash common.Hash + gasLimit uint64 +} + +func (c evmOnlyCursor) changeSet() *proto.NamedChangeSet { + return &proto.NamedChangeSet{ + Name: evmOnlyCursorModule, + Changeset: proto.ChangeSet{Pairs: []*proto.KVPair{{ + Key: []byte(evmOnlyCursorKey), + Value: c.encode(), + }}}, + } +} + +func (c evmOnlyCursor) encode() []byte { + buf := make([]byte, 0, evmOnlyCursorSize) + buf = binary.BigEndian.AppendUint64(buf, uint64(c.height)) //nolint:gosec // G115: height is non-negative. + buf = append(buf, c.appHash[:]...) + buf = append(buf, c.blockHash[:]...) + return binary.BigEndian.AppendUint64(buf, c.gasLimit) +} + +func decodeEVMOnlyCursor(raw []byte) (evmOnlyCursor, error) { + if len(raw) != evmOnlyCursorSize { + return evmOnlyCursor{}, fmt.Errorf("EVM-only cursor is %d bytes, want %d", len(raw), evmOnlyCursorSize) + } + height, ok := utils.SafeCast[int64](binary.BigEndian.Uint64(raw)) + if !ok { + return evmOnlyCursor{}, fmt.Errorf("EVM-only cursor height exceeds int64") + } + raw = raw[8:] + return evmOnlyCursor{ + height: height, + appHash: common.BytesToHash(raw[:common.HashLength]), + blockHash: common.BytesToHash(raw[common.HashLength : 2*common.HashLength]), + gasLimit: binary.BigEndian.Uint64(raw[2*common.HashLength:]), + }, nil +} + +// loadEVMOnlyCursor reads the cursor of the store's latest version. It is None +// for an empty store and for one seeded by InitChain with no block committed +// yet; both are resumed through InitChain. +func loadEVMOnlyCursor(store *flatkv.CommitStore) (utils.Option[evmOnlyCursor], error) { + latest, err := store.GetLatestVersion() + if err != nil { + return utils.None[evmOnlyCursor](), fmt.Errorf("read EVM-only state version: %w", err) + } + if latest == 0 { + return utils.None[evmOnlyCursor](), nil + } + raw, found := store.Get(evmOnlyCursorModule, []byte(evmOnlyCursorKey)) + if !found { + return utils.None[evmOnlyCursor](), nil + } + cursor, err := decodeEVMOnlyCursor(raw) + if err != nil { + return utils.None[evmOnlyCursor](), err + } + if cursor.height != latest { + return utils.None[evmOnlyCursor](), fmt.Errorf("EVM-only cursor is at height %d but state is at %d", cursor.height, latest) + } + return utils.Some(cursor), nil +} diff --git a/sei-tendermint/internal/p2p/giga_router_common.go b/sei-tendermint/internal/p2p/giga_router_common.go index 57b8a746f2..0ae1cdfa1a 100644 --- a/sei-tendermint/internal/p2p/giga_router_common.go +++ b/sei-tendermint/internal/p2p/giga_router_common.go @@ -407,9 +407,11 @@ func (r *gigaRouterCommon) runExecute(ctx context.Context) error { // call InitChain ourselves. It sets up the app's deliverState // against which the first FinalizeBlock below runs. // - // Re-entering on restart (crashed after InitChain, before first - // Commit) is safe — nothing was committed, so it behaves as a - // fresh init. + // Re-entering on restart (crashed after InitChain, before the first + // block became durable) is safe — nothing was committed, so it + // behaves as a fresh init. An app whose storage already holds + // blocks must report them here and refuse InitChain, since blocks + // may be durable before Commit acknowledged them. if _, err := app.InitChain(r.cfg.GenDoc.ToRequestInitChain()); err != nil { return fmt.Errorf("App.InitChain(): %w", err) } diff --git a/sei-tendermint/node/public.go b/sei-tendermint/node/public.go index b25b07a52b..4a61230509 100644 --- a/sei-tendermint/node/public.go +++ b/sei-tendermint/node/public.go @@ -172,12 +172,15 @@ func prepareApplication( return nil, noStorage, fmt.Errorf("open EVM-only storage: %w", err) } logger.Info("Autobahn EVM-only execution enabled with disk-backed Giga storage") - prepared := evmonlyapp.NewEVMOnlyApplication( + prepared, err := evmonlyapp.NewEVMOnlyApplication( config.AutobahnEVMOnlyChainID, validators, manager, evmonly.NewFlatKVChangeSetEncoder(manager.SC()), ) + if err != nil { + return nil, noStorage, errors.Join(fmt.Errorf("restore EVM-only application: %w", err), manager.Close()) + } return prepared, utils.Some(manager), nil } if conf.MockApp { From 273ea8b3492ed78610610bf40ad8921b345a7097 Mon Sep 17 00:00:00 2001 From: masih Date: Tue, 15 Sep 2026 15:16:02 +0000 Subject: [PATCH 2/4] Read the receipt across a storage reopen so the async writer has drained --- sei-tendermint/internal/evmonlyapp/app_test.go | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/sei-tendermint/internal/evmonlyapp/app_test.go b/sei-tendermint/internal/evmonlyapp/app_test.go index 8901f39841..a9b685d120 100644 --- a/sei-tendermint/internal/evmonlyapp/app_test.go +++ b/sei-tendermint/internal/evmonlyapp/app_test.go @@ -112,7 +112,12 @@ func finalizeAndCommitEVMOnlyTestBlock(t *testing.T, app abci.Application, req * } func TestEVMOnlyApplicationExecutesRawEthereumBlock(t *testing.T) { - app := newInitializedEVMOnlyTestApp(t) + home := t.TempDir() + storage := openEVMOnlyTestStorage(t, home) + app, err := NewEVMOnlyApplication(evmOnlyTestChainID, nil, storage, evmonly.NewFlatKVChangeSetEncoder(storage.SC())) + require.NoError(t, err) + _, err = app.InitChain(evmOnlyTestInitChain()) + require.NoError(t, err) raw, sender := signedEVMOnlyTestTx(t, evmOnlyTestChainID, 0) tx := new(ethtypes.Transaction) require.NoError(t, tx.UnmarshalBinary(raw)) @@ -147,8 +152,13 @@ func TestEVMOnlyApplicationExecutesRawEthereumBlock(t *testing.T) { gotBalance = app.EvmBalance(sender, nil) require.Equal(t, wantBalance, gotBalance.ToBig()) require.Equal(t, response.AppHash, app.Info().LastBlockAppHash) + + // Receipt writes are queued behind the block; closing the storage drains + // them, so the reopened store is where the receipt is guaranteed to be. + _, storage = reopenEVMOnlyTestApp(t, storage, home) + t.Cleanup(func() { require.NoError(t, storage.Close()) }) receiptCtx := sdk.NewContext(nil, tmproto.Header{Height: 1}, false).WithContext(t.Context()) - receipt, err := app.(*evmOnlyApplication).storage.ReceiptDB().GetReceipt(receiptCtx, tx.Hash()) + receipt, err := storage.ReceiptDB().GetReceipt(receiptCtx, tx.Hash()) require.NoError(t, err) require.Equal(t, tx.Hash().Hex(), receipt.TxHashHex) require.Equal(t, uint64(1), receipt.BlockNumber) From 99740ae046ed236dd8d4b7b40bd371176cbcace7 Mon Sep 17 00:00:00 2001 From: masih Date: Wed, 16 Sep 2026 07:57:52 +0000 Subject: [PATCH 3/4] Drop the RFC for the durable EVM-only cursor --- docs/rfc/rfc-002-evmonly-durable-cursor.md | 104 --------------------- 1 file changed, 104 deletions(-) delete mode 100644 docs/rfc/rfc-002-evmonly-durable-cursor.md diff --git a/docs/rfc/rfc-002-evmonly-durable-cursor.md b/docs/rfc/rfc-002-evmonly-durable-cursor.md deleted file mode 100644 index eaa60bf76c..0000000000 --- a/docs/rfc/rfc-002-evmonly-durable-cursor.md +++ /dev/null @@ -1,104 +0,0 @@ -# RFC 002: Durable Execution Cursor for the EVM-only Application - -## Changelog - -- 2026-09-15: Initial draft - -## Abstract - -The EVM-only Autobahn application (`sei-tendermint/internal/evmonlyapp`) kept -its execution cursor, the last committed height, app hash and parent block -hash, only in memory, while the state it executed against was made durable by -FlatKV on every block. After a crash the two disagreed: the application -reported height 0, the Giga router ran `InitChain` and block 1 again, and the -node crash-looped on the nonces already in storage. This RFC records the -decision to persist the cursor alongside each block's state, in the same FlatKV -version, and to derive `Info()` from storage on construction. - -## Background - -In Giga mode the router (`internal/p2p/giga_router_common.go`) does not run the -CometBFT handshake. On start it calls `app.Info()`; a `LastBlockHeight` of zero -takes the fresh-genesis branch (`InitChain`, then block 1), anything else takes -the restart branch (`InitLastHeader` and pushing the last app hash). The -application therefore is the only source of truth for where execution stands. - -The EVM-only executor (`giga/evmonly`) is store-backed: `ExecuteBlock` writes -receipts and calls `StateDB.CommitStateChanges(height, changesets)` before it -returns, so FlatKV state is durable once `FinalizeBlock` completes. ABCI -`Commit` then only advanced the in-memory cursor. Nothing rebuilt that cursor on -the next process start, and the `initialHeight == 1` early return in the -InitChain guard meant a non-empty store at the default initial height was never -refused. - -## Discussion - -### What is stored - -Height needs no new storage: it is `SC().GetLatestVersion()` of the reopened -FlatKV store, which `OpenDBWithRecovery` has already converged with the block -store, state WAL and receipt store. - -The app hash, parent block hash and block gas limit are stored as one record, -key `cursor` under a named changeset `evmonly`, which FlatKV routes to its misc -store under a module prefix, outside `keys.EVMStoreKey`. The record is -`height ‖ appHash ‖ parentHash ‖ gasLimit` (8 + 32 + 32 + 8 bytes). The height is -redundant with the storage version and is checked against it on load, so a -cursor that somehow belongs to a different version is refused rather than -trusted. - -### How it is written - -The executor gains an optional `BlockChangeSetEncoder`, called after execution -with the block context and result. Its changesets are appended to the state -encoder's output and passed to the same `CommitStateChanges` call, so the cursor -and the state it describes share a FlatKV version, a WAL entry and a recovery -outcome. The application supplies an encoder that chains the block into the app -hash (the existing SHA-256 chain over the memory-store encoding of the result, -unchanged), stages the resulting cursor as pending and returns it as the -`evmonly` changeset. `FinalizeBlock` reports the pending app hash; `Commit` -promotes pending to committed in memory, as before. - -The EVM state store filters to `keys.EVMStoreKey` changesets, so the cursor -never reaches the state-sync store or the LtHash. - -### How it is restored - -`NewEVMOnlyApplication` reads the cursor of the store's latest version. If one -is present it builds the executor and seeds the committed cursor, so `Info()` -reports the durable height and app hash and the router takes its restart -branch. The gas limit lives in the record because `InitChain`, which used to -supply it from consensus params, is not called on restart. - -`InitChain` is refused once an executor exists, and `seedInitialStateVersion` -no longer skips the check at initial height 1: a store at any version other -than zero or `initialHeight - 1` is refused. The `initialHeight - 1` case is a -store seeded by an earlier `InitChain` with no block yet, which a crash between -`InitChain` and the first block leaves behind; re-running `InitChain` there is -the fresh start the router expects. - -### Crash window - -A node that crashes after `FinalizeBlock(N)` returned and before `Commit(N)` -has N durable in FlatKV and restarts reporting height N, not N-1. This is the -correct answer for a store-backed executor: replaying N would fail on its own -nonces. The router's restart branch handles a durable app tip ahead of the -block store's by syncing the missing suffix. - -### Alternatives considered - -- Deriving the app hash from FlatKV's LtHash instead of persisting it. This - changes what the app hash means and therefore consensus behaviour; it is left - as a separate decision. -- Passing `RequestInitChain` to the constructor so the gas limit is available on - restart without persisting it. Storing it with the cursor keeps the record - self-describing and keeps the constructor's signature, which the node and - tests already share. -- Writing the cursor in ABCI `Commit`. That reintroduces the window in which - state is durable and the cursor is not; the cursor has to travel with the - state. - -### References - -- https://github.com/sei-protocol/sei-chain/issues/4169 -- `giga/evmonly/README.md`, store-backed execution From 50c4f0fe10e8120fcb1ae5df763be887e5f798ba Mon Sep 17 00:00:00 2001 From: masih Date: Wed, 16 Sep 2026 08:18:31 +0000 Subject: [PATCH 4/4] Address review: single InitChain seed, durable-aware abandonPending, cursor tests, README wording --- giga/evmonly/README.md | 19 ++++---- sei-tendermint/internal/evmonlyapp/app.go | 21 ++++++--- .../internal/evmonlyapp/cursor_test.go | 47 +++++++++++++++++++ 3 files changed, 72 insertions(+), 15 deletions(-) create mode 100644 sei-tendermint/internal/evmonlyapp/cursor_test.go diff --git a/giga/evmonly/README.md b/giga/evmonly/README.md index b723ff1b97..77bf3ee23d 100644 --- a/giga/evmonly/README.md +++ b/giga/evmonly/README.md @@ -98,14 +98,17 @@ including for empty blocks. A receipt failure leaves state unchanged so the block can be retried. A state failure can leave receipts behind, but retrying the block overwrites them. `ResultSink` runs only after both stores succeed. -Because state becomes durable inside `ExecuteBlock`, before any ABCI `Commit`, -an ABCI application built on the executor must derive `Info()` from storage -rather than from memory: after a restart, the Giga router calls `InitChain` and -replays block 1 whenever `Info().LastBlockHeight` is zero, which fails against -state that already exists. `WithBlockChangeSetEncoder(...)` lets the -application commit its own named changesets (for example an execution cursor -holding the app hash and parent hash) in the same `CommitStateChanges` call as -the block's EVM state, so the two can never disagree on disk. +`ExecuteBlock` advances the state store's version itself, independently of any +ABCI `Commit`, so what the store holds after a restart is decided by the +storage layer (which flushes asynchronously and re-executes blocks from +BlockDB), not by which `Commit` calls the application saw. An ABCI application +built on the executor must therefore derive `Info()` from storage rather than +from memory: after a restart, the Giga router calls `InitChain` and replays +block 1 whenever `Info().LastBlockHeight` is zero, which fails against state +that already exists. `WithBlockChangeSetEncoder(...)` lets the application +commit its own named changesets (for example an execution cursor holding the +app hash and parent hash) in the same `CommitStateChanges` call as the block's +EVM state, so the two can never disagree on disk. The FlatKV encoder persists balance, nonce, code, and storage changes, and the executor reads them through the current Giga state view. EVM-only Autobahn load diff --git a/sei-tendermint/internal/evmonlyapp/app.go b/sei-tendermint/internal/evmonlyapp/app.go index 2fd93c6d8f..c48b51edf6 100644 --- a/sei-tendermint/internal/evmonlyapp/app.go +++ b/sei-tendermint/internal/evmonlyapp/app.go @@ -4,6 +4,7 @@ import ( "context" "crypto/sha256" "encoding/binary" + "errors" "fmt" "math/big" "runtime" @@ -141,9 +142,6 @@ func (a *evmOnlyApplication) InitChain(req *abci.RequestInitChain) (*abci.Respon if err != nil { return nil, err } - if err := a.seedInitialStateVersion(req.InitialHeight); err != nil { - return nil, err - } for executor := range a.executor.Lock() { if executor.IsPresent() { return nil, fmt.Errorf("EVM-only application already initialized") @@ -332,8 +330,7 @@ func (a *evmOnlyApplication) FinalizeBlock(ctx context.Context, req *abci.Reques Txs: req.Txs, }) if err != nil { - a.abandonPending() - return nil, err + return nil, errors.Join(err, a.abandonPending(height)) } defer result.Release() pending, err := a.pendingCursor(height) @@ -363,11 +360,21 @@ func (a *evmOnlyApplication) beginBlock(height int64) (evmOnlyCursor, error) { panic("unreachable") } -// abandonPending drops a cursor staged by a block whose commit failed. -func (a *evmOnlyApplication) abandonPending() { +// abandonPending drops the cursor staged by a failed block unless the store +// already holds that block's version, in which case the cursor is durable and +// stays pending for Commit. +func (a *evmOnlyApplication) abandonPending(height int64) error { + latest, err := a.storage.SC().GetLatestVersion() + if err != nil { + return fmt.Errorf("read EVM-only state version: %w", err) + } + if latest >= height { + return nil + } for state := range a.cursor.Lock() { state.pending = utils.None[evmOnlyCursor]() } + return nil } func (a *evmOnlyApplication) pendingCursor(height int64) (evmOnlyCursor, error) { diff --git a/sei-tendermint/internal/evmonlyapp/cursor_test.go b/sei-tendermint/internal/evmonlyapp/cursor_test.go new file mode 100644 index 0000000000..8da43142e5 --- /dev/null +++ b/sei-tendermint/internal/evmonlyapp/cursor_test.go @@ -0,0 +1,47 @@ +package evmonlyapp + +import ( + "encoding/binary" + "math" + "testing" + + "github.com/ethereum/go-ethereum/common" + + "github.com/sei-protocol/sei-chain/sei-tendermint/libs/utils/require" +) + +func TestEVMOnlyCursorRoundTrip(t *testing.T) { + cursor := evmOnlyCursor{ + height: 0x0102030405060708, + appHash: common.HexToHash("0xaa"), + blockHash: common.HexToHash("0xbb"), + gasLimit: 0x1112131415161718, + } + raw := cursor.encode() + require.Equal(t, evmOnlyCursorSize, len(raw)) + require.Equal(t, uint64(cursor.height), binary.BigEndian.Uint64(raw[:8])) //nolint:gosec // G115: fixture height is non-negative. + require.Equal(t, cursor.appHash, common.BytesToHash(raw[8:40])) + require.Equal(t, cursor.blockHash, common.BytesToHash(raw[40:72])) + require.Equal(t, cursor.gasLimit, binary.BigEndian.Uint64(raw[72:])) + + decoded, err := decodeEVMOnlyCursor(raw) + require.NoError(t, err) + require.Equal(t, cursor, decoded) +} + +func TestEVMOnlyCursorDecodeRejectsMalformed(t *testing.T) { + valid := evmOnlyCursor{height: 1}.encode() + negativeHeight := make([]byte, evmOnlyCursorSize) + binary.BigEndian.PutUint64(negativeHeight, math.MaxUint64) + for name, raw := range map[string][]byte{ + "empty": nil, + "truncated": valid[:evmOnlyCursorSize-1], + "oversized": append(append([]byte(nil), valid...), 0), + "height exceeds int64": negativeHeight, + } { + t.Run(name, func(t *testing.T) { + _, err := decodeEVMOnlyCursor(raw) + require.Error(t, err) + }) + } +}