From f1e55e07551f5f55ca11f77413bda84f359fa6bc Mon Sep 17 00:00:00 2001 From: bdchatham Date: Thu, 17 Sep 2026 13:21:20 +0200 Subject: [PATCH 1/2] fix(evmonly): restore the durable execution cursor Reverts #4212, which reverted #4190. sei-chain#4169 is live again and it took down a 40-validator harbor chain today. giga-testnet-2 ran healthily at 64,500 tx/s on a build carrying #4190. The image automation bumped it to e104749 (the revert), that bump rolled all 40 pods, and every restarted validator panicked identically: execute: r.data.GlobalBlock(1): blockStore.ReadBlockByNumber(1): pruned: below retention watermark Without a durable cursor Info() reports height 0, the Giga router takes the fresh-genesis branch, and the node asks for block 1 -- which littdb retention had pruned 294,000 blocks earlier. Deterministic, fleet-wide, unrecoverable by restart. #4212 predicted it: "a restarted EVM-only node again reports height 0 from Info(), so #4169 is reopened by this change." #4210 is present and does not help: it retains a lane's LAST block across prune, not block 1. The revert of the revert applies cleanly and keeps the three commits that landed on top of #4190 -- #4183 checkedSenders, #4194 EvmChainConfig and EvmBaseFee, #4205 EvmCall and lastBlockTime are all intact. #4212's conflicts came from removing #4190 while keeping those three; returning to a state that had both is unambiguous. This is option A from #4169: height is already derivable from storage.SC().GetLatestVersion(), but the app hash is a chained SHA-256 (appHash(N) = H(appHash(N-1) || height || blockHash || gasUsed || changesets)), so it is not a function of state at version N and must be persisted. The cursor rides as an "evmonly" named changeset in the same CommitStateChanges call, outside keys.EVMStoreKey, so it shares the block's version without entering the EVM state hash. Verified: gofmt and go vet clean on the four changed packages; evmonlyapp, giga/evmonly, p2p and node tests pass; TestEVMOnlyApplicationResumesFromStorageAfterRestart and the cursor round-trip and malformed-decode tests pass -- these are #4169's acceptance criteria 1 and 2. Not run: staticcheck, golangci-lint, govulncheck, scripts/ramtest.sh, and the harbor cluster check in #4169 AC 4. Co-Authored-By: Claude Opus 5 (1M context) --- giga/evmonly/README.md | 12 + giga/evmonly/executor.go | 14 +- giga/evmonly/giga_store.go | 22 +- sei-tendermint/internal/evmonlyapp/app.go | 248 ++++++++++++------ .../internal/evmonlyapp/app_test.go | 147 +++++++++-- sei-tendermint/internal/evmonlyapp/cursor.go | 89 +++++++ .../internal/evmonlyapp/cursor_test.go | 47 ++++ .../internal/p2p/giga_router_common.go | 8 +- sei-tendermint/node/public.go | 5 +- 9 files changed, 485 insertions(+), 107 deletions(-) create mode 100644 sei-tendermint/internal/evmonlyapp/cursor.go create mode 100644 sei-tendermint/internal/evmonlyapp/cursor_test.go diff --git a/giga/evmonly/README.md b/giga/evmonly/README.md index f67296b1e8..77bf3ee23d 100644 --- a/giga/evmonly/README.md +++ b/giga/evmonly/README.md @@ -98,6 +98,18 @@ 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. +`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 tests use `WithMissingAccountState(...)` to supply the initial funded state for diff --git a/giga/evmonly/executor.go b/giga/evmonly/executor.go index ae9d7ddea4..06442d9b52 100644 --- a/giga/evmonly/executor.go +++ b/giga/evmonly/executor.go @@ -35,8 +35,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 // Breaks a store-backed block into its stages. That path is serialized by storeMu, so one timer // serves the executor. @@ -68,6 +70,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 3e53e4cc97..03e76ed8a1 100644 --- a/giga/evmonly/giga_store.go +++ b/giga/evmonly/giga_store.go @@ -37,6 +37,16 @@ var _ StateReader = gigaSnapshotStateReader{} // one exception, and the executor waits for that commit before encoding a block that has one. 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. +// +// As with NamedChangeSetEncoder, what it returns must not alias the result: the commit outlives +// the block. +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 { @@ -118,6 +128,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 } @@ -150,9 +167,10 @@ func (e *Executor) executePreparedBlockWithStore(ctx context.Context, req Prepar // the block's own changes, which decides whether encoding may overlap the previous block's commit. // // Expanding a storage clear iterates the live store to find the slots to delete, so a block that -// clears one must not be encoded against a store mid-commit. +// clears one must not be encoded against a store mid-commit. A block encoder is caller-supplied and +// free to read whatever it likes, so one being configured is treated the same way. func (e *Executor) encodingReadsTheStore(changes *StateChangeSet) bool { - return len(changes.StorageClears) > 0 + return len(changes.StorageClears) > 0 || e.blockChangeSetEncoder != nil } // AwaitCommits blocks until every block this executor has run is committed, and reports the first diff --git a/sei-tendermint/internal/evmonlyapp/app.go b/sei-tendermint/internal/evmonlyapp/app.go index d9d2b7e659..8e708f7a86 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" @@ -18,6 +19,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" @@ -44,21 +46,22 @@ 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] // checkedSenders maps the hash of every transaction this process admitted // in CheckTx to the sender recovered there, so execution does not recover // it again. checkedSenders utils.Mutex[map[common.Hash]common.Address] } -type evmOnlyState struct { - executor utils.Option[*evmonly.Executor] - gasLimit uint64 - nextHeight int64 - committedHeight int64 - appHash common.Hash - parentHash common.Hash - pending utils.Option[evmOnlyPending] +// 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] // lastBlockTime is the Time of the most recently committed block, used by // EvmCall to reproduce that block's execution context for a read-only call. lastBlockTime uint64 @@ -67,33 +70,86 @@ type evmOnlyState struct { pendingBlockTime uint64 } -type evmOnlyPending struct { - height int64 - appHash common.Hash - blockHash common.Hash -} - 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{}), checkedSenders: utils.NewMutex(map[common.Hash]common.Address{}), } + 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) + state.pendingBlockTime = block.Time + return []*proto.NamedChangeSet{next.changeSet()}, nil + } + panic("unreachable") } func (a *evmOnlyApplication) InitChain(req *abci.RequestInitChain) (*abci.ResponseInitChain, error) { @@ -104,41 +160,36 @@ 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 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 { @@ -159,27 +210,29 @@ 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") } -// EvmGasLimit returns the gas limit configured during InitChain. +// EvmGasLimit returns the gas limit of the most recently committed block. +// This application never changes it after InitChain, so it is also the gas +// limit of every earlier committed block. func (a *evmOnlyApplication) EvmGasLimit() uint64 { - for state := range a.state.Lock() { - return state.gasLimit + for state := range a.cursor.Lock() { + return state.committed.gasLimit } panic("unreachable") } @@ -320,28 +373,33 @@ func evmOnlyPrevRandao(timestamp uint64) common.Hash { // committed EVM state and returns the execution result. func (a *evmOnlyApplication) EvmCall(ctx context.Context, msg *ethcore.Message) (*ethcore.ExecutionResult, error) { var executor *evmonly.Executor - var blockCtx evmonly.BlockContext - for state := range a.state.Lock() { - got, ok := state.executor.Get() + for exec := range a.executor.Lock() { + got, ok := exec.Get() if !ok { return nil, fmt.Errorf("EVM-only call attempted before InitChain") } + executor = got + } + var blockCtx evmonly.BlockContext + for state := range a.cursor.Lock() { if state.pending.IsPresent() { + // The store already has this block's writes; NUMBER/TIMESTAMP/PrevRandao advance only on Commit. return nil, fmt.Errorf("EVM-only call attempted before committing the finalized block") } - number, ok := utils.SafeCast[uint64](state.committedHeight) + number, ok := utils.SafeCast[uint64](state.committed.height) if !ok { - return nil, fmt.Errorf("EVM-only committed height exceeds uint64: %d", state.committedHeight) + return nil, fmt.Errorf("EVM-only committed height exceeds uint64: %d", state.committed.height) } - executor = got + // Coinbase and ParentHash are left zero: no coinbase is tracked outside + // FinalizeBlock, and only the current block's hash is tracked at all. blockCtx = evmonly.BlockContext{ Number: number, Time: state.lastBlockTime, - GasLimit: state.gasLimit, + GasLimit: state.committed.gasLimit, ChainID: new(big.Int).Set(a.chainID), BaseFee: evmOnlyBaseFee(), BlobBaseFee: new(big.Int), - BlockHash: state.parentHash, + BlockHash: state.committed.blockHash, PrevRandao: evmOnlyPrevRandao(state.lastBlockTime), } } @@ -362,26 +420,24 @@ 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: evmOnlyPrevRandao(timestamp), }, @@ -389,35 +445,73 @@ func (a *evmOnlyApplication) FinalizeBlock(ctx context.Context, req *abci.Reques Senders: a.takeSenders(req.Txs), }) if err != nil { - return nil, err + return nil, errors.Join(err, a.abandonPending(height)) } 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}) - state.pendingBlockTime = timestamp 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 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) { + 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.committed = pending state.lastBlockTime = state.pendingBlockTime - state.pending = utils.None[evmOnlyPending]() + 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 96d39cd6c3..9b666b4636 100644 --- a/sei-tendermint/internal/evmonlyapp/app_test.go +++ b/sei-tendermint/internal/evmonlyapp/app_test.go @@ -1,6 +1,7 @@ package evmonlyapp import ( + "crypto/ecdsa" "encoding/binary" "errors" "math/big" @@ -32,6 +33,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, @@ -44,30 +50,33 @@ 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()) - require.NoError(t, err) - storage, err := bootstrap.NewGigaStorageManager(t.Context(), storageConfig) - require.NoError(t, err) + storage := openEVMOnlyTestStorage(t, t.TempDir()) t.Cleanup(func() { require.NoError(t, storage.Close()) }) - return NewEVMOnlyApplication(evmOnlyTestChainID, validators, storage, evmonly.NewFlatKVChangeSetEncoder(storage.SC())) + 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 { @@ -79,12 +88,14 @@ func openEVMOnlyTestStorage(t *testing.T, home string) *bootstrap.GigaStorageMan return storage } -func reopenEVMOnlyTestApp(t *testing.T, storage *bootstrap.GigaStorageManager, home string) (*evmOnlyApplication, *bootstrap.GigaStorageManager) { +// 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, ok := NewEVMOnlyApplication(evmOnlyTestChainID, nil, reopened, evmonly.NewFlatKVChangeSetEncoder(reopened.SC())).(*evmOnlyApplication) - require.True(t, ok) + app, err := NewEVMOnlyApplication(evmOnlyTestChainID, nil, reopened, evmonly.NewFlatKVChangeSetEncoder(reopened.SC())) + require.NoError(t, err) return app, reopened } @@ -111,14 +122,9 @@ func finalizeAndCommitEVMOnlyTestBlock(t *testing.T, app abci.Application, req * func TestEVMOnlyApplicationExecutesRawEthereumBlock(t *testing.T) { home := t.TempDir() storage := openEVMOnlyTestStorage(t, home) - app, ok := NewEVMOnlyApplication(evmOnlyTestChainID, nil, storage, evmonly.NewFlatKVChangeSetEncoder(storage.SC())).(*evmOnlyApplication) - require.True(t, ok) - _, err := app.InitChain(&abci.RequestInitChain{ - InitialHeight: 1, - ConsensusParams: &tmproto.ConsensusParams{ - Block: &tmproto.BlockParams{MaxGas: 30_000_000}, - }, - }) + 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) @@ -154,6 +160,9 @@ 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()) @@ -286,6 +295,100 @@ func TestEVMOnlyApplicationExecutesCheckedTxLikeUncheckedTx(t *testing.T) { require.Equal(t, uint64(1), checked.EvmNonce(sender)) } +// 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/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) + }) + } +} 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 2b30967ceff9755cef9fff04f5cd860cafe4b39c Mon Sep 17 00:00:00 2001 From: bdchatham Date: Thu, 17 Sep 2026 13:45:32 +0200 Subject: [PATCH 2/2] fix(evmonly): seed block time on resume, guard the EVM key, keep pipeline slack Three review suggestions on the restored cursor. InitLastHeader now seeds lastBlockTime. The cursor carries height, hashes and gas limit but not Time, so a resumed app answered EvmCall -- eth_call through giga/evmonly/rpc -- with TIMESTAMP 0 and PrevRandao keccak(0) while reporting the correct NUMBER, BlockHash and GasLimit. The router already passes the last header on its restart path; the app inherited BaseApplication's no-op. Block production was unaffected, since FinalizeBlock takes time from the header. TestEVMOnlyApplicationInitLastHeaderSeedsBlockTime covers the gap, which neither existing restart test reached. The keys.EVMStoreKey reservation is now enforced where every block encoder's output passes, rather than left as a doc comment the next encoder author has to remember. An EVM-keyed changeset would have been written into account, storage and code state as part of the block, diverging the app hash from the committed state. WithStoreIndependentBlockChangeSetEncoder lets an encoder declare that it reads only the block context and result. encodingReadsTheStore treated any configured encoder as store-reading, which made settleBeforeEncoding permanently true for this app and gave up the receipt-stage slack the pipeline exists for -- worst exactly when commits are slow. encodeCursorChangeSet reads only in-memory cursor state, so it takes the new variant; the store-reading variant keeps the conservative wait and is now opt-in rather than implied. Verified: gofmt and go vet clean; evmonlyapp, giga/evmonly, p2p and node tests pass. Not run: staticcheck, golangci-lint, govulncheck, ramtest.sh. Co-Authored-By: Claude Opus 5 (1M context) --- giga/evmonly/executor.go | 18 ++++++++-- giga/evmonly/giga_store.go | 15 +++++++-- sei-tendermint/internal/evmonlyapp/app.go | 16 ++++++++- .../internal/evmonlyapp/app_test.go | 33 +++++++++++++++++++ 4 files changed, 77 insertions(+), 5 deletions(-) diff --git a/giga/evmonly/executor.go b/giga/evmonly/executor.go index 06442d9b52..151d2d0cfe 100644 --- a/giga/evmonly/executor.go +++ b/giga/evmonly/executor.go @@ -37,8 +37,11 @@ type Executor struct { changeSetEncoder NamedChangeSetEncoder // Optional: nil commits only the state encoder's changesets. blockChangeSetEncoder BlockChangeSetEncoder - missingState StateReader - closed atomic.Bool + // blockEncoderReadsStore is false only for an encoder registered as + // store-independent, which lets encoding overlap the previous commit. + blockEncoderReadsStore bool + missingState StateReader + closed atomic.Bool // Breaks a store-backed block into its stages. That path is serialized by storeMu, so one timer // serves the executor. @@ -72,9 +75,20 @@ func WithMissingAccountState(state StateReader) Option { // WithBlockChangeSetEncoder commits the encoder's changesets alongside every // block's state changes. +// WithStoreIndependentBlockChangeSetEncoder registers an encoder that reads only +// the block context and result. Encoding then overlaps the previous block's +// commit. An encoder that touches the store must use WithBlockChangeSetEncoder. +func WithStoreIndependentBlockChangeSetEncoder(encoder BlockChangeSetEncoder) Option { + return func(e *Executor) { + e.blockChangeSetEncoder = encoder + e.blockEncoderReadsStore = false + } +} + func WithBlockChangeSetEncoder(encoder BlockChangeSetEncoder) Option { return func(e *Executor) { e.blockChangeSetEncoder = encoder + e.blockEncoderReadsStore = true } } diff --git a/giga/evmonly/giga_store.go b/giga/evmonly/giga_store.go index 03e76ed8a1..fabd63a6b5 100644 --- a/giga/evmonly/giga_store.go +++ b/giga/evmonly/giga_store.go @@ -9,6 +9,7 @@ import ( "github.com/ethereum/go-ethereum/common" gigametrics "github.com/sei-protocol/sei-chain/giga/metrics" + "github.com/sei-protocol/sei-chain/sei-db/common/keys" "github.com/sei-protocol/sei-chain/sei-db/proto" gigatypes "github.com/sei-protocol/sei-chain/sei-db/state_db/giga/types" ) @@ -19,6 +20,7 @@ var ( errMissingStateStore = errors.New("executor requires a state store") errMissingReceiptStore = errors.New("executor requires a receipt store") errMissingNamedChangeSetEncoder = errors.New("giga store requires a named changeset encoder") + errBlockEncoderUsedEVMStoreKey = errors.New("block changeset encoder may not write the EVM state changeset") ) var _ StateReader = gigaSnapshotStateReader{} @@ -133,6 +135,14 @@ func (e *Executor) executePreparedBlockWithStore(ctx context.Context, req Prepar if err != nil { return nil, fmt.Errorf("encode block changes for block %d: %w", req.Context.Number, err) } + // An EVM-keyed changeset here would be written into account, storage and code + // state as part of the block, diverging the app hash from the committed state. + for _, cs := range extra { + if cs != nil && cs.Name == keys.EVMStoreKey { + return nil, fmt.Errorf("block encoder returned a changeset named %q for block %d: %w", + cs.Name, req.Context.Number, errBlockEncoderUsedEVMStoreKey) + } + } changesets = append(changesets, extra...) } if err := ctx.Err(); err != nil { @@ -168,9 +178,10 @@ func (e *Executor) executePreparedBlockWithStore(ctx context.Context, req Prepar // // Expanding a storage clear iterates the live store to find the slots to delete, so a block that // clears one must not be encoded against a store mid-commit. A block encoder is caller-supplied and -// free to read whatever it likes, so one being configured is treated the same way. +// free to read whatever it likes, so one is assumed to read the store unless it was registered as +// store-independent: assuming otherwise would surrender the receipt-stage slack on every block. func (e *Executor) encodingReadsTheStore(changes *StateChangeSet) bool { - return len(changes.StorageClears) > 0 || e.blockChangeSetEncoder != nil + return len(changes.StorageClears) > 0 || (e.blockChangeSetEncoder != nil && e.blockEncoderReadsStore) } // AwaitCommits blocks until every block this executor has run is committed, and reports the first diff --git a/sei-tendermint/internal/evmonlyapp/app.go b/sei-tendermint/internal/evmonlyapp/app.go index 8e708f7a86..8bd2dfdc78 100644 --- a/sei-tendermint/internal/evmonlyapp/app.go +++ b/sei-tendermint/internal/evmonlyapp/app.go @@ -11,11 +11,13 @@ import ( "slices" "github.com/ethereum/go-ethereum/common" + ethcore "github.com/ethereum/go-ethereum/core" ethtypes "github.com/ethereum/go-ethereum/core/types" "github.com/ethereum/go-ethereum/crypto" "github.com/ethereum/go-ethereum/params" "github.com/holiman/uint256" + tmproto "github.com/sei-protocol/sei-chain/sei-tendermint/proto/tendermint/types" "github.com/sei-protocol/sei-chain/giga/evmonly" "github.com/sei-protocol/sei-chain/sei-db/bootstrap" @@ -119,7 +121,7 @@ func (a *evmOnlyApplication) newExecutor() *evmonly.Executor { }, evmonly.WithStorageManager(a.storage, a.changeSetEncoder), evmonly.WithMissingAccountState(evmOnlyFundedState{}), - evmonly.WithBlockChangeSetEncoder(a.encodeCursorChangeSet), + evmonly.WithStoreIndependentBlockChangeSetEncoder(a.encodeCursorChangeSet), ) } @@ -220,6 +222,18 @@ func (a *evmOnlyApplication) Info() *abci.ResponseInfo { panic("unreachable") } +// InitLastHeader seeds the committed block time on the router's restart path. +// The cursor carries height, hashes and gas limit but not Time, so without this +// EvmCall would answer with TIMESTAMP 0 until the next Commit. +func (a *evmOnlyApplication) InitLastHeader(lastHeader *tmproto.Header) { + if lastHeader == nil || lastHeader.Time.Unix() < 0 { + return + } + for state := range a.cursor.Lock() { + state.lastBlockTime = uint64(lastHeader.Time.Unix()) // nolint:gosec // guarded non-negative above + } +} + func (a *evmOnlyApplication) LastBlockHeight() int64 { for state := range a.cursor.Lock() { return state.committed.height diff --git a/sei-tendermint/internal/evmonlyapp/app_test.go b/sei-tendermint/internal/evmonlyapp/app_test.go index 9b666b4636..3f17675aa8 100644 --- a/sei-tendermint/internal/evmonlyapp/app_test.go +++ b/sei-tendermint/internal/evmonlyapp/app_test.go @@ -298,6 +298,39 @@ func TestEVMOnlyApplicationExecutesCheckedTxLikeUncheckedTx(t *testing.T) { // 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. +// The cursor does not carry the block time, so a resumed app would answer +// EvmCall with TIMESTAMP 0 until the next Commit. The router seeds it through +// InitLastHeader on its restart path. +func TestEVMOnlyApplicationInitLastHeaderSeedsBlockTime(t *testing.T) { + key, err := crypto.GenerateKey() + require.NoError(t, err) + const blocks = 3 + 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) + var last *abci.RequestFinalizeBlock + for height := range int64(blocks) { + last = evmOnlyTestBlock(height+1, signedEVMOnlyTestTxFrom(t, key, evmOnlyTestChainID, uint64(height))) //nolint:gosec // G115: test heights are positive. + finalizeAndCommitEVMOnlyTestBlock(t, app, last) + } + + app, storage = reopenEVMOnlyTestApp(t, storage, home) + t.Cleanup(func() { require.NoError(t, storage.Close()) }) + + resumed, ok := app.(*evmOnlyApplication) + require.True(t, ok) + for state := range resumed.cursor.Lock() { + require.Zero(t, state.lastBlockTime, "a resumed app has no block time before InitLastHeader") + } + resumed.InitLastHeader(last.Header) + for state := range resumed.cursor.Lock() { + require.Equal(t, uint64(last.Header.Time.Unix()), state.lastBlockTime) //nolint:gosec // G115: test times are positive. + } +} + func TestEVMOnlyApplicationResumesFromStorageAfterRestart(t *testing.T) { key, err := crypto.GenerateKey() require.NoError(t, err)