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 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 6f66c99cab..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" @@ -17,6 +18,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 +40,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) { @@ -87,41 +142,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 { @@ -142,19 +192,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") } @@ -256,59 +306,96 @@ 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 { - 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}) 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.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..a9b685d120 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,34 +42,82 @@ 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) { - 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)) @@ -97,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) @@ -192,6 +252,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/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 {