diff --git a/sei-tendermint/internal/evmonlyapp/app.go b/sei-tendermint/internal/evmonlyapp/app.go index 7000bfa5cb..c86bba9161 100644 --- a/sei-tendermint/internal/evmonlyapp/app.go +++ b/sei-tendermint/internal/evmonlyapp/app.go @@ -12,9 +12,9 @@ import ( "runtime" "slices" "sync" + "sync/atomic" "github.com/ethereum/go-ethereum/common" - "go.opentelemetry.io/otel" ethcore "github.com/ethereum/go-ethereum/core" ethtypes "github.com/ethereum/go-ethereum/core/types" @@ -22,6 +22,8 @@ import ( "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/seilog" + "go.opentelemetry.io/otel" "github.com/sei-protocol/sei-chain/giga/evmonly" "github.com/sei-protocol/sei-chain/sei-db/bootstrap" @@ -34,6 +36,8 @@ import ( const evmOnlyMinGasPrice = 1_000_000_000 +var logger = seilog.NewLogger("tendermint", "internal", "evmonlyapp") + // evmOnlyBaseFee is the base fee this application executes every block at. // Admission and block validity both price against it, so they cannot diverge. func evmOnlyBaseFee() *big.Int { return new(big.Int) } @@ -56,7 +60,14 @@ type evmOnlyApplication struct { storage *bootstrap.GigaStorageManager changeSetEncoder evmonly.NamedChangeSetEncoder validators []abci.ValidatorUpdate - executor utils.Mutex[*utils.Option[*evmonly.Executor]] + // executor is held for the whole of a block's execution, so it serializes + // FinalizeBlock and InitChain against each other. EvmCall only takes it to + // read the executor out; the call itself runs unlocked. + executor utils.Mutex[*utils.Option[*evmonly.Executor]] + // settler publishes the same executor to readers of committed state that + // must not wait for a block to finish executing; they settle its + // background commit before opening a store view. + settler utils.AtomicSend[utils.Option[*evmonly.Executor]] // Lock order: executor before cursor. FinalizeBlock holds executor while // the block's cursor encoder takes cursor. cursor utils.Mutex[*evmOnlyCursorState] @@ -64,6 +75,9 @@ type evmOnlyApplication struct { // in CheckTx to the sender recovered there, so execution does not recover // it again. checkedSenders utils.Mutex[map[common.Hash]common.Address] + // settleFailureLogged is set once a failed commit has been logged by a + // committed-state reader; the failure is latched, so it is logged once. + settleFailureLogged atomic.Bool // finalizePhases breaks FinalizeBlock into its stages around the executor. // FinalizeBlock is serialized by executor, so one timer serves the app; it // is only touched with that lock held. @@ -110,6 +124,7 @@ func NewEVMOnlyApplication( validators: slices.Clone(validators), executor: utils.NewMutex(new(utils.Option[*evmonly.Executor])), finalizePhases: seidbmetrics.NewPhaseTimer(otel.Meter(finalizeMeterName), "evmonly_finalize"), + settler: utils.NewAtomicSend(utils.None[*evmonly.Executor]()), cursor: utils.NewMutex(&evmOnlyCursorState{}), checkedSenders: utils.NewMutex(map[common.Hash]common.Address{}), } @@ -119,7 +134,7 @@ func NewEVMOnlyApplication( } if cursor, ok := cursor.Get(); ok { for executor := range a.executor.Lock() { - *executor = utils.Some(a.newExecutor()) + a.installExecutor(executor) } for state := range a.cursor.Lock() { state.committed = cursor @@ -128,6 +143,26 @@ func NewEVMOnlyApplication( return a, nil } +// installExecutor creates the executor and publishes it to both the block +// serializer and the settler. Called with the executor lock held. +func (a *evmOnlyApplication) installExecutor(slot *utils.Option[*evmonly.Executor]) { + executor := a.newExecutor() + *slot = utils.Some(executor) + a.settler.Store(utils.Some(executor)) +} + +// AwaitCommits blocks until every block finalized so far is in the store and +// reports the first commit that failed. The store must be settled before it is +// closed, and before a reader opens a view that has to include the last +// finalized block. +func (a *evmOnlyApplication) AwaitCommits() error { + executor, ok := a.settler.Load().Get() + if !ok { + return nil + } + return executor.AwaitCommits() +} + func (a *evmOnlyApplication) newExecutor() *evmonly.Executor { return evmonly.NewExecutor(evmonly.Config{ ChainConfig: a.chainConfig, @@ -190,7 +225,7 @@ func (a *evmOnlyApplication) InitChain(req *abci.RequestInitChain) (*abci.Respon if err := a.seedInitialStateVersion(req.InitialHeight); err != nil { return nil, err } - *executor = utils.Some(a.newExecutor()) + a.installExecutor(executor) for state := range a.cursor.Lock() { state.committed = evmOnlyCursor{height: req.InitialHeight - 1, gasLimit: gasLimit} } @@ -396,14 +431,53 @@ func evmOnlyStoreAddress(address common.Address) gigatypes.Address { return storeAddress } +// openSettledView opens a store view that holds every block finalized so far. +// A failed commit is logged once rather than returned: the view is still a +// consistent version, and the failure halts the node through the next +// FinalizeBlock. +func (a *evmOnlyApplication) openSettledView() gigatypes.StateView { + if err := a.AwaitCommits(); err != nil && !a.settleFailureLogged.Swap(true) { + logger.Error("EVM-only committed state is behind a failed block commit", "err", err) + } + return a.storage.StateDB().OpenView() +} + +// callBlockContext returns the block context of the committed block, and +// refuses while a finalized block awaits Commit. +func (a *evmOnlyApplication) callBlockContext() (evmonly.BlockContext, error) { + 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 evmonly.BlockContext{}, fmt.Errorf("EVM-only call attempted before committing the finalized block") + } + number, ok := utils.SafeCast[uint64](state.committed.height) + if !ok { + return evmonly.BlockContext{}, fmt.Errorf("EVM-only committed height exceeds uint64: %d", state.committed.height) + } + // Coinbase and ParentHash are left zero: no coinbase is tracked outside + // FinalizeBlock, and only the current block's hash is tracked at all. + return evmonly.BlockContext{ + Number: number, + Time: state.lastBlockTime, + GasLimit: state.committed.gasLimit, + ChainID: new(big.Int).Set(a.chainID), + BaseFee: evmOnlyBaseFee(), + BlobBaseFee: new(big.Int), + BlockHash: state.committed.blockHash, + PrevRandao: state.committed.prevRandao, + }, nil + } + panic("unreachable") +} + func (a *evmOnlyApplication) EvmNonce(address common.Address) uint64 { - snapshot := a.storage.StateDB().OpenView() + snapshot := a.openSettledView() defer snapshot.Close() return snapshot.GetNonce(evmOnlyStoreAddress(address)) } func (a *evmOnlyApplication) EvmBalance(address common.Address, _ []byte) uint256.Int { - snapshot := a.storage.StateDB().OpenView() + snapshot := a.openSettledView() defer snapshot.Close() if !snapshot.AccountExists(evmOnlyStoreAddress(address)) { return *uint256.MustFromBig(evmOnlyBaseBalance) @@ -443,30 +517,29 @@ func (a *evmOnlyApplication) EvmCall(ctx context.Context, msg *ethcore.Message) } 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") + // The committed block's write may still be in flight, and a block may be + // finalized and committed while it is waited for. The context is taken + // before settling and confirmed unchanged after, so the store holds the + // advertised block and no later one has been committed to the cursor. + for { + if err := ctx.Err(); err != nil { + return nil, err } - number, ok := utils.SafeCast[uint64](state.committed.height) - if !ok { - return nil, fmt.Errorf("EVM-only committed height exceeds uint64: %d", state.committed.height) + blockCtx, err := a.callBlockContext() + if err != nil { + return nil, err } - // 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.committed.gasLimit, - ChainID: new(big.Int).Set(a.chainID), - BaseFee: evmOnlyBaseFee(), - BlobBaseFee: new(big.Int), - BlockHash: state.committed.blockHash, - PrevRandao: state.committed.prevRandao, + if err := executor.AwaitCommits(); err != nil { + return nil, err + } + settled, err := a.callBlockContext() + if err != nil { + return nil, err + } + if settled.Number == blockCtx.Number { + return executor.Call(ctx, blockCtx, msg) } } - return executor.Call(ctx, blockCtx, msg) } func (a *evmOnlyApplication) FinalizeBlock(ctx context.Context, req *abci.RequestFinalizeBlock) (*abci.ResponseFinalizeBlock, error) { @@ -511,9 +584,7 @@ func (a *evmOnlyApplication) finalizeBlockLocked( defer a.finalizePhases.Reset() a.finalizePhases.SetPhase("take_senders") senders := a.takeSenders(req.Txs) - // The executor's own timer breaks execution down further. - a.finalizePhases.SetPhase("execute") - result, err := executor.ExecuteBlock(ctx, evmonly.BlockRequest{ + result, err := executeBlockPipelined(ctx, executor, a.finalizePhases, evmonly.BlockRequest{ Context: evmonly.BlockContext{ Number: number, Time: timestamp, @@ -529,7 +600,7 @@ func (a *evmOnlyApplication) finalizeBlockLocked( Senders: senders, }) if err != nil { - return nil, errors.Join(err, a.abandonPending(height)) + return nil, errors.Join(err, a.abandonPending(executor, height)) } defer result.Release() pending, err := a.pendingCursor(height) @@ -543,6 +614,24 @@ func (a *evmOnlyApplication) finalizeBlockLocked( }, nil } +// executeBlockPipelined executes the block and returns once its state commit +// has been started, leaving the commit to run while the next block executes. +// The executor lands the previous block's commit before starting this one and +// reads its changes through an overlay in the meantime, so committed-state +// readers settle through AwaitCommits rather than this returning. +// +// Preparation (decoding and recovering the senders CheckTx did not) and execution are +// timed as separate phases; the executor's own timer breaks execution down further. +func executeBlockPipelined(ctx context.Context, executor *evmonly.Executor, phases *seidbmetrics.PhaseTimer, req evmonly.BlockRequest) (*evmonly.BlockResult, error) { + phases.SetPhase("prepare") + prepared, err := executor.PrepareBlock(ctx, req) + if err != nil { + return nil, err + } + phases.SetPhase("execute") + return executor.ExecutePreparedBlock(ctx, prepared) +} + // 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) { @@ -560,19 +649,21 @@ func (a *evmOnlyApplication) beginBlock(height int64) (evmOnlyCursor, error) { // 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 { +// stays pending for Commit. The in-flight commit is landed first so the store +// version is final; a commit that failed is reported alongside. +func (a *evmOnlyApplication) abandonPending(executor *evmonly.Executor, height int64) error { + commitErr := executor.AwaitCommits() latest, err := a.storage.SC().GetLatestVersion() if err != nil { - return fmt.Errorf("read EVM-only state version: %w", err) + return errors.Join(commitErr, fmt.Errorf("read EVM-only state version: %w", err)) } if latest >= height { - return nil + return commitErr } for state := range a.cursor.Lock() { state.pending = utils.None[evmOnlyCursor]() } - return nil + return commitErr } func (a *evmOnlyApplication) pendingCursor(height int64) (evmOnlyCursor, error) { @@ -586,6 +677,12 @@ func (a *evmOnlyApplication) pendingCursor(height int64) (evmOnlyCursor, error) panic("unreachable") } +// Commit acknowledges the finalized block as the one the chain builds on: the +// height it advances is what RPC serves as latest. Neither the block's state +// commit nor its queued receipt write is waited for here, since that would put +// the write back on the block loop, so the newest block's receipts can trail +// latest briefly. A commit that fails halts the node through the next +// FinalizeBlock, and a restart resumes from the store's own version. func (a *evmOnlyApplication) Commit(context.Context) (*abci.ResponseCommit, error) { for state := range a.cursor.Lock() { pending, ok := state.pending.Get() diff --git a/sei-tendermint/internal/evmonlyapp/app_test.go b/sei-tendermint/internal/evmonlyapp/app_test.go index ab230d6175..2f35d5895c 100644 --- a/sei-tendermint/internal/evmonlyapp/app_test.go +++ b/sei-tendermint/internal/evmonlyapp/app_test.go @@ -1,6 +1,7 @@ package evmonlyapp import ( + "context" "crypto/ecdsa" "encoding/binary" "errors" @@ -10,6 +11,7 @@ import ( "time" "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" sdkmetric "go.opentelemetry.io/otel/sdk/metric" @@ -18,7 +20,9 @@ import ( "github.com/sei-protocol/sei-chain/giga/evmonly" sdk "github.com/sei-protocol/sei-chain/sei-cosmos/types" "github.com/sei-protocol/sei-chain/sei-db/bootstrap" + "github.com/sei-protocol/sei-chain/sei-db/common/keys" seidbmetrics "github.com/sei-protocol/sei-chain/sei-db/common/metrics" + "github.com/sei-protocol/sei-chain/sei-db/proto" abci "github.com/sei-protocol/sei-chain/sei-tendermint/abci/types" "github.com/sei-protocol/sei-chain/sei-tendermint/libs/utils/require" tmproto "github.com/sei-protocol/sei-chain/sei-tendermint/proto/tendermint/types" @@ -77,30 +81,45 @@ func newInitializedEVMOnlyTestApp(t *testing.T) abci.Application { func newEVMOnlyTestApp(t *testing.T, validators []abci.ValidatorUpdate) abci.Application { t.Helper() 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) + t.Cleanup(func() { closeEVMOnlyTestApp(t, app, storage) }) return app } +// closeEVMOnlyTestApp closes storage the way the node does: after the +// application has landed every block commit it started. +func closeEVMOnlyTestApp(t *testing.T, app abci.Application, storage *bootstrap.GigaStorageManager) { + t.Helper() + settler, ok := app.(*evmOnlyApplication) + require.True(t, ok) + require.NoError(t, settler.AwaitCommits()) + require.NoError(t, storage.Close()) +} + func openEVMOnlyTestStorage(t *testing.T, home string) *bootstrap.GigaStorageManager { t.Helper() storageConfig, err := evmonly.NewValidatorStorageConfig(home, true) require.NoError(t, err) - storage, err := bootstrap.NewGigaStorageManager(t.Context(), storageConfig) + // The store outlives the test body: the last block's commit is still landing + // when it ends, and closeEVMOnlyTestApp settles it from a cleanup, which + // runs after t.Context() is cancelled. + ctx, cancel := context.WithCancel(context.Background()) + t.Cleanup(cancel) + storage, err := bootstrap.NewGigaStorageManager(ctx, storageConfig) require.NoError(t, err) 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) { +func reopenEVMOnlyTestApp(t *testing.T, app abci.Application, storage *bootstrap.GigaStorageManager, home string) (abci.Application, *bootstrap.GigaStorageManager) { t.Helper() - require.NoError(t, storage.Close()) + closeEVMOnlyTestApp(t, app, storage) reopened := openEVMOnlyTestStorage(t, home) - app, err := NewEVMOnlyApplication(evmOnlyTestChainID, nil, reopened, evmonly.NewFlatKVChangeSetEncoder(reopened.SC())) + reopenedApp, err := NewEVMOnlyApplication(evmOnlyTestChainID, nil, reopened, evmonly.NewFlatKVChangeSetEncoder(reopened.SC())) require.NoError(t, err) - return app, reopened + return reopenedApp, reopened } func evmOnlyTestBlock(height int64, txs ...[]byte) *abci.RequestFinalizeBlock { @@ -167,8 +186,8 @@ func TestEVMOnlyApplicationExecutesRawEthereumBlock(t *testing.T) { // 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()) }) + app, storage = reopenEVMOnlyTestApp(t, app, storage, home) + t.Cleanup(func() { closeEVMOnlyTestApp(t, app, storage) }) receiptCtx := sdk.NewContext(nil, tmproto.Header{Height: 1}, false).WithContext(t.Context()) receipt, err := storage.ReceiptDB().GetReceipt(receiptCtx, tx.Hash()) require.NoError(t, err) @@ -321,8 +340,8 @@ func TestEVMOnlyApplicationInitLastHeaderSeedsBlockTime(t *testing.T) { finalizeAndCommitEVMOnlyTestBlock(t, app, last) } - app, storage = reopenEVMOnlyTestApp(t, storage, home) - t.Cleanup(func() { require.NoError(t, storage.Close()) }) + app, storage = reopenEVMOnlyTestApp(t, app, storage, home) + t.Cleanup(func() { closeEVMOnlyTestApp(t, app, storage) }) resumed, ok := app.(*evmOnlyApplication) require.True(t, ok) @@ -360,8 +379,8 @@ func TestEVMOnlyApplicationResumesFromStorageAfterRestart(t *testing.T) { 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()) }) + app, storage = reopenEVMOnlyTestApp(t, app, storage, home) + t.Cleanup(func() { closeEVMOnlyTestApp(t, app, storage) }) info := app.Info() require.Equal(t, int64(blocks), info.LastBlockHeight) @@ -373,9 +392,10 @@ func TestEVMOnlyApplicationResumesFromStorageAfterRestart(t *testing.T) { 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. +// FinalizeBlock starts the block's state commit, and a shutdown settles it, so +// stopping after FinalizeBlock but 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) @@ -393,8 +413,8 @@ func TestEVMOnlyApplicationResumesFromBlockFinalizedButNotCommitted(t *testing.T 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()) }) + app, storage = reopenEVMOnlyTestApp(t, app, storage, home) + t.Cleanup(func() { closeEVMOnlyTestApp(t, app, storage) }) info := app.Info() require.Equal(t, int64(2), info.LastBlockHeight) @@ -415,8 +435,8 @@ func TestEVMOnlyApplicationRepeatsInitChainAfterSeedingOnly(t *testing.T) { _, err = app.InitChain(init) require.NoError(t, err) - app, storage = reopenEVMOnlyTestApp(t, storage, home) - t.Cleanup(func() { require.NoError(t, storage.Close()) }) + app, storage = reopenEVMOnlyTestApp(t, app, storage, home) + t.Cleanup(func() { closeEVMOnlyTestApp(t, app, storage) }) require.Equal(t, int64(0), app.Info().LastBlockHeight) _, err = app.InitChain(init) @@ -450,7 +470,7 @@ func TestEVMOnlyApplicationFeedsPrevRandaoThePriorAppHash(t *testing.T) { sender := crypto.PubkeyToAddress(key.PublicKey) contract := crypto.CreateAddress(sender, 0) - snapshot := app.(*evmOnlyApplication).storage.StateDB().OpenView() + snapshot := app.(*evmOnlyApplication).openSettledView() defer snapshot.Close() require.Equal(t, common.BytesToHash(prior), snapshot.GetStorage(evmOnlyStoreAddress(contract), common.Hash{})) } @@ -538,6 +558,83 @@ func TestEVMOnlyApplicationCommitsBlockWithStaleNonce(t *testing.T) { finalizeAndCommitEVMOnlyTestBlock(t, app, evmOnlyTestBlock(3)) } +// A block's state lands in the store behind FinalizeBlock. Readers of committed +// state see it before Commit, and the next block builds on it whether or not +// the store has caught up. +func TestEVMOnlyApplicationReadsSettleBehindFinalizeBlock(t *testing.T) { + app := newInitializedEVMOnlyTestApp(t) + key, err := crypto.GenerateKey() + require.NoError(t, err) + sender := crypto.PubkeyToAddress(key.PublicKey) + block := func(height int64) *abci.RequestFinalizeBlock { + return evmOnlyTestBlock(height, signedEVMOnlyTestTxFrom(t, key, evmOnlyTestChainID, uint64(height-1))) //nolint:gosec // G115: test heights are positive. + } + + for height := range int64(4) { + _, err := app.FinalizeBlock(t.Context(), block(height+1)) + require.NoError(t, err) + require.Equal(t, uint64(height+1), app.EvmNonce(sender)) //nolint:gosec // G115: test heights are positive. + require.Equal(t, height, app.LastBlockHeight()) + _, err = app.Commit(t.Context()) + require.NoError(t, err) + require.Equal(t, height+1, app.LastBlockHeight()) + } + + settler, ok := app.(*evmOnlyApplication) + require.True(t, ok) + require.NoError(t, settler.AwaitCommits()) + latest, err := settler.storage.SC().GetLatestVersion() + require.NoError(t, err) + require.Equal(t, int64(4), latest) +} + +// unwritableEVMChangeSetEncoder encodes every block with an EVM pair the store +// refuses to apply, so the block executes and encodes cleanly and its commit is +// the first thing that fails. +func unwritableEVMChangeSetEncoder(evmonly.StateChangeSet) ([]*proto.NamedChangeSet, error) { + return []*proto.NamedChangeSet{{ + Name: keys.EVMStoreKey, + Changeset: proto.ChangeSet{Pairs: []*proto.KVPair{{Key: nil, Value: []byte{1}}}}, + }}, nil +} + +// A block's commit lands behind FinalizeBlock: the block whose commit fails is +// still finalized and committed, and the failure surfaces from the next +// FinalizeBlock, from read-only calls, and from settling the store, while the +// store itself stays at the last version that landed. +func TestEVMOnlyApplicationSurfacesAFailedCommitFromTheNextBlock(t *testing.T) { + storage := openEVMOnlyTestStorage(t, t.TempDir()) + app, err := NewEVMOnlyApplication(evmOnlyTestChainID, nil, storage, unwritableEVMChangeSetEncoder) + require.NoError(t, err) + t.Cleanup(func() { require.NoError(t, storage.Close()) }) + _, err = app.InitChain(evmOnlyTestInitChain()) + require.NoError(t, err) + settler, ok := app.(*evmOnlyApplication) + require.True(t, ok) + + // Block 1 is unwritable, yet it finalizes and commits: the write has not + // been waited for. The synchronous path would fail here. + raw, _ := signedEVMOnlyTestTx(t, evmOnlyTestChainID, 0) + finalizeAndCommitEVMOnlyTestBlock(t, app, evmOnlyTestBlock(1, raw)) + require.Equal(t, int64(1), app.LastBlockHeight()) + + // The failed write is reported by the next block, and stays reported. + _, err = app.FinalizeBlock(t.Context(), evmOnlyTestBlock(2)) + require.Error(t, err) + require.Error(t, settler.AwaitCommits()) + _, err = settler.EvmCall(t.Context(), ðcore.Message{GasLimit: 21_000, GasPrice: new(big.Int), Value: new(big.Int)}) + require.Error(t, err) + + // The block that failed to finalize left nothing staged, and the store never + // moved past genesis. + _, err = app.Commit(t.Context()) + require.Error(t, err) + require.Equal(t, int64(1), app.LastBlockHeight()) + latest, err := storage.SC().GetLatestVersion() + require.NoError(t, err) + require.Equal(t, int64(0), latest) +} + // TestHashRawTxsMatchesKeccak256Hash pins hashRawTxs to crypto.Keccak256Hash, which keys the sender cache. func TestHashRawTxsMatchesKeccak256Hash(t *testing.T) { for _, count := range []int{0, 1, 2, 17, 64, 65, 200, 1848} { @@ -585,7 +682,7 @@ func TestEVMOnlyApplicationTimesEveryFinalizeBlockPhase(t *testing.T) { } } } - for _, want := range []string{"take_senders", "execute", "tx_results"} { + for _, want := range []string{"take_senders", "prepare", "execute", "tx_results"} { _, ok := phases[want] require.True(t, ok, "phase %q not recorded", want) } diff --git a/sei-tendermint/internal/proxy/proxy.go b/sei-tendermint/internal/proxy/proxy.go index 998733e63f..1fa1cb73bc 100644 --- a/sei-tendermint/internal/proxy/proxy.go +++ b/sei-tendermint/internal/proxy/proxy.go @@ -62,6 +62,24 @@ func (app *Proxy) EvmChainID() uint64 { return app.app.EvmChainID() } +// commitSettler is implemented by applications whose block state commits land +// behind FinalizeBlock. +type commitSettler interface { + AwaitCommits() error +} + +// AwaitCommits blocks until every block the wrapped application has finalized +// is in its store, and reports the first commit that failed. It returns nil for +// an application that commits synchronously. +func (app *Proxy) AwaitCommits() error { + defer addTimeSample(Global.MethodTimingAt("await_commits", "sync"))() + settler, ok := app.app.(commitSettler) + if !ok { + return nil + } + return settler.AwaitCommits() +} + // evmCaller is implemented by applications that can run a read-only EVM call // against their current state. type evmCaller interface { diff --git a/sei-tendermint/node/node.go b/sei-tendermint/node/node.go index 5dbfd64f20..f535f2faaa 100644 --- a/sei-tendermint/node/node.go +++ b/sei-tendermint/node/node.go @@ -769,11 +769,15 @@ func (n *nodeImpl) OnStop() { } // closeGigaStorage closes the manager-owned storage or standalone Autobahn -// block store at most once. +// block store at most once. The application's in-flight block commits are +// landed first, so the stores are not closed under a write. func (n *nodeImpl) closeGigaStorage() error { var err error n.gigaStorageCloseOnce.Do(func() { if manager, ok := n.gigaStorageManager.Get(); ok { + if settleErr := n.rpcEnv.App.AwaitCommits(); settleErr != nil { + logger.Error("failed to settle block commits before closing Giga storage", "err", settleErr) + } if err = manager.Close(); err != nil { logger.Error("failed to close Giga storage manager", "err", err) }