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..151d2d0cfe 100644 --- a/giga/evmonly/executor.go +++ b/giga/evmonly/executor.go @@ -35,8 +35,13 @@ 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 + // 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. @@ -68,6 +73,25 @@ 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 + } +} + // 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..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{} @@ -37,6 +39,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 +130,21 @@ 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) + } + // 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 { return nil, err } @@ -150,9 +177,11 @@ 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 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 + 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 d9d2b7e659..8bd2dfdc78 100644 --- a/sei-tendermint/internal/evmonlyapp/app.go +++ b/sei-tendermint/internal/evmonlyapp/app.go @@ -4,20 +4,24 @@ import ( "context" "crypto/sha256" "encoding/binary" + "errors" "fmt" "math/big" "runtime" "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" + "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 +48,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 +72,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.WithStoreIndependentBlockChangeSetEncoder(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 +162,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 +212,41 @@ 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") } +// 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.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 +387,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 +434,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 +459,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..3f17675aa8 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,133 @@ 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. +// 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) + 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 {