Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion giga/evmonly/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -76,7 +76,10 @@ The executor is always store-backed. `WithStorageManager(...)` selects the
`bootstrap.GigaStorageManager` that provides both `giga.StateDB` and the ledger
receipt store, plus the `NamedChangeSetEncoder` for its state implementation.
Unit tests can supply those dependencies independently. Execution fails closed
if either store or the encoder is missing.
if the state store or the encoder is missing. The receipt store is optional: a
node configured without one (`enable_receipt_store = false`, meant for
validators that serve no receipt reads) skips receipt persistence entirely and
`AwaitReceipts` returns at once.
For each block the executor opens a current `giga.StateView`, executes against
its EVM-native read methods, converts the resulting `StateChangeSet`, and calls
`CommitStateChanges`. Execution and commit on an executor are serialized so
Expand Down
2 changes: 1 addition & 1 deletion giga/evmonly/cmd/evmonly-loadtest/pipeline.go
Original file line number Diff line number Diff line change
Expand Up @@ -147,7 +147,7 @@ func runPrebuilt(ctx context.Context, cfg config, state *generatedState, workloa
err = errors.Join(err, cleanupStorage())
}()
fmt.Printf("storage directory: %s\n", storageDirectory)
storageConfig, err := evmonly.NewValidatorStorageConfig(storageDirectory)
storageConfig, err := evmonly.NewValidatorStorageConfig(storageDirectory, true)
if err != nil {
return fmt.Errorf("configure storage manager: %w", err)
}
Expand Down
13 changes: 7 additions & 6 deletions giga/evmonly/giga_store.go
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,6 @@ const maxGigaStoreBlockNumber = uint64(1<<63 - 1)

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")
)
Expand Down Expand Up @@ -50,9 +49,6 @@ func (e *Executor) executePreparedBlockWithStore(ctx context.Context, req Prepar
if stateStore == nil {
return nil, errMissingStateStore
}
if e.receiptStore == nil {
return nil, errMissingReceiptStore
}
if e.changeSetEncoder == nil {
return nil, errMissingNamedChangeSetEncoder
}
Expand Down Expand Up @@ -168,7 +164,8 @@ type receiptWrite struct {
}

// AwaitReceipts blocks until the receipts of the last block this executor ran are in the receipt
// store, and reports the write's failure if it had one.
// store, and reports the write's failure if it had one. It returns at once on an executor without
// a receipt store, which keeps none.
//
// Receipts are written in the background behind ExecutePreparedBlock. A caller that publishes a
// block to readers who expect its receipts has to wait here first. Unlike AwaitCommits it does not
Expand Down Expand Up @@ -316,12 +313,16 @@ func (e *Executor) awaitPipelineCommit() error {

// startReceiptWrite persists the block's receipts in the background, after the previous block's
// have landed, and returns the write to wait on. The block result is held until the write has
// landed.
// landed. An executor without a receipt store keeps none, so its write is done on return.
//
// The write is recorded as the executor's newest, so AwaitReceipts finds it whether or not the
// block's commit is started afterwards.
func (e *Executor) startReceiptWrite(ctx context.Context, blockNumber int64, result *BlockResult) *receiptWrite {
receipts := &receiptWrite{done: make(chan struct{})}
if e.receiptStore == nil {
close(receipts.done)
return receipts
}
e.pipelineMu.Lock()
previous := e.pipelineReceipts
e.pipelineReceipts = receipts
Expand Down
10 changes: 0 additions & 10 deletions giga/evmonly/giga_store_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -259,16 +259,6 @@ func TestExecutorGigaStoreFailuresDoNotCommitPartialState(t *testing.T) {
require.Nil(t, result)
})

t.Run("missing receipt store", func(t *testing.T) {
store := NewMemoryStore(NewMemoryState())
executor := NewExecutor(Config{}, WithStore(store, store.EncodeChangeSet))

result, err := executor.ExecuteBlock(t.Context(), BlockRequest{Context: blockContext(big.NewInt(testChainID))})

require.ErrorIs(t, err, errMissingReceiptStore)
require.Nil(t, result)
})

t.Run("missing encoder", func(t *testing.T) {
snapshot := newMemoryGigaSnapshot(0)
store := &recordingGigaStore{snapshot: snapshot}
Expand Down
29 changes: 29 additions & 0 deletions giga/evmonly/pipeline_store_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -390,6 +390,35 @@ func TestAwaitReceiptsReturnsBeforeTheStateCommitLands(t *testing.T) {
require.Equal(t, []int64{41}, store.commitBlock)
}

// An executor without a receipt store commits state only: AwaitReceipts has nothing to wait on
// and the result goes back to the pool as soon as the block returns.
func TestNoReceiptStoreCommitsStateWithoutWaitingOnReceipts(t *testing.T) {
chainID := big.NewInt(testChainID)
key, err := crypto.GenerateKey()
require.NoError(t, err)
sender := crypto.PubkeyToAddress(key.PublicKey)
recipient := testAddress(0xa9)

snapshot := newMemoryGigaSnapshot(40)
snapshot.setBalance(sender, big.NewInt(testFundedBalanceWei))
store := &gatedCommitStore{recordingGigaStore: &recordingGigaStore{snapshot: snapshot}, release: make(chan struct{})}
executor := NewExecutor(Config{BlockResultPoolSize: 1}, withTestStores(store, nil, noopChangeSetEncoder))
defer executor.Close()

rawTx := signLegacyTx(t, key, chainID, 0, &recipient, big.NewInt(7), nil)
result := executePipelinedBlock(t, executor, chainID, 41, rawTx)
require.Equal(t, uint64(1), result.Receipts[0].Status, "receipts are still produced for the block result")
result.Release()

require.NoError(t, executor.AwaitReceipts())
require.Empty(t, store.commits, "the state commit is still held")
require.Equal(t, BlockResultPoolStats{Capacity: 1, Available: 1}, executor.ResultPoolStats())

close(store.release)
require.NoError(t, executor.AwaitCommits())
require.Equal(t, []int64{41}, store.commitBlock)
}

// signallingReceiptStore reports each block whose receipts it was handed on written.
type signallingReceiptStore struct {
*MemoryReceiptStore
Expand Down
8 changes: 7 additions & 1 deletion giga/evmonly/rpc/block.go
Original file line number Diff line number Diff line change
Expand Up @@ -188,9 +188,15 @@ func headerFields(block *coretypes.ResultBlock, blockUnix, gasLimit uint64, base
}
}

// ErrNoReceiptStore is returned by receipt lookups on a node that keeps no receipts.
var ErrNoReceiptStore = errors.New("this node does not store receipts")

// receiptFor returns hash's stored receipt, or nil with a nil error when no
// receipt is stored for it.
// receipt is stored for it, including on a node without a receipt store.
func receiptFor(ctx context.Context, store receiptpkg.ReceiptStore, hash common.Hash) (*evmtypes.Receipt, error) {
if store == nil {
return nil, nil
}
stored, err := store.GetReceipt(receiptContext(ctx), hash)
if errors.Is(err, receiptpkg.ErrNotFound) {
return nil, nil
Expand Down
6 changes: 3 additions & 3 deletions giga/evmonly/rpc/server.go
Original file line number Diff line number Diff line change
Expand Up @@ -113,10 +113,10 @@ func websocketHandler(rpcServer *ethrpc.Server) http.Handler {
return rpcServer.WebsocketHandler(wsAllowedOrigins)
}

// newHandler registers the eth namespace. receiptStore may be nil on a node that keeps no
// receipts: transactions and blocks are then served without receipt-derived fields, and
// receipt lookups report ErrNoReceiptStore.
func newHandler(backend Backend, receiptStore receipt.ReceiptStore) (*ethrpc.Server, error) {
if receiptStore == nil {
return nil, errors.New("EVM-only RPC requires a receipt store")
}
rpcServer := ethrpc.NewServer()
if err := rpcServer.RegisterName("eth", &sendAPI{backend: backend}); err != nil {
return nil, fmt.Errorf("register EVM-only send RPC: %w", err)
Expand Down
3 changes: 3 additions & 0 deletions giga/evmonly/rpc/tx.go
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,9 @@ func (api *txAPI) GetTransactionByHash(ctx context.Context, hash common.Hash) (*
// returns a nil receipt with a nil error when hash is unknown or its block is
// not yet finalized.
func (api *txAPI) lookupFinalizedTx(ctx context.Context, hash common.Hash) (*evmtypes.Receipt, *coretypes.ResultBlock, error) {
if api.store == nil {
return nil, nil, ErrNoReceiptStore
}
stored, err := api.store.GetReceipt(receiptContext(ctx), hash)
if errors.Is(err, receiptpkg.ErrNotFound) {
return nil, nil, nil
Expand Down
35 changes: 32 additions & 3 deletions giga/evmonly/rpc/tx_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -420,7 +420,36 @@ func TestGetTransactionByHashEndToEnd(t *testing.T) {
require.Nil(t, missing)
}

func TestHandlerRequiresReceiptStore(t *testing.T) {
_, err := newHandler(&testBackend{}, nil)
require.EqualError(t, err, "EVM-only RPC requires a receipt store")
// A node without a receipt store still serves blocks; receipt-backed lookups say so
// rather than answering null, which a client would read as "not mined".
func TestHandlerWithoutReceiptStore(t *testing.T) {
blockHash := common.HexToHash("0xabcd")
block, tx1, _, _ := multiTxBlock(t, 9, blockHash, time.Unix(1_700_000_000, 0))
backend := fixedGasLimitBackend(t, 35_000_000, func(context.Context, *coretypes.RequestBlockInfo) (*coretypes.ResultBlock, error) {
return block, nil
})
backend.proxy = utils.None[*ethrpc.Client]()
handler, err := newHandler(backend, nil)
require.NoError(t, err)
t.Cleanup(handler.Stop)
server := httptest.NewServer(handler)
t.Cleanup(server.Close)
client, err := ethrpc.DialHTTP(server.URL)
require.NoError(t, err)
t.Cleanup(client.Close)

var byNumber map[string]any
require.NoError(t, client.CallContext(t.Context(), &byNumber, "eth_getBlockByNumber", "latest", true))
require.Equal(t, "0x9", byNumber["number"])
require.Equal(t, "0x0", byNumber["gasUsed"])
txs, ok := byNumber["transactions"].([]any)
require.True(t, ok)
require.Len(t, txs, 2)

var receipt map[string]any
err = client.CallContext(t.Context(), &receipt, "eth_getTransactionReceipt", tx1.Hash())
require.EqualError(t, err, ErrNoReceiptStore.Error())
var byHash map[string]any
err = client.CallContext(t.Context(), &byHash, "eth_getTransactionByHash", tx1.Hash())
require.EqualError(t, err, ErrNoReceiptStore.Error())
}
7 changes: 4 additions & 3 deletions giga/evmonly/storage_manager.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,14 +8,15 @@ import (
)

// NewValidatorStorageConfig returns the disk-backed storage configuration for
// an EVM-only validator. It enables FlatKV, receipt, and block persistence.
func NewValidatorStorageConfig(homePath string) (*seidbconfig.GigaStorageConfig, error) {
// an EVM-only validator. It enables FlatKV and block persistence, and receipt
// persistence when receipts is set.
func NewValidatorStorageConfig(homePath string, receipts bool) (*seidbconfig.GigaStorageConfig, error) {
storageConfig, err := seidbconfig.DefaultGigaStorageConfig(homePath)
if err != nil {
return nil, err
}
storageConfig.WithValidatorMode()
storageConfig.ReceiptDBConfig.Enable = true
storageConfig.ReceiptDBConfig.Enable = receipts
return storageConfig, nil
}

Expand Down
14 changes: 8 additions & 6 deletions giga/evmonly/storage_manager_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,10 +7,12 @@ import (
)

func TestNewValidatorStorageConfig(t *testing.T) {
storageConfig, err := NewValidatorStorageConfig(t.TempDir())
require.NoError(t, err)
require.NotNil(t, storageConfig.FlatKVConfig)
require.False(t, storageConfig.SSConfig.Enable)
require.True(t, storageConfig.ReceiptDBConfig.Enable)
require.NotNil(t, storageConfig.BlockDBConfig)
for _, receipts := range []bool{true, false} {
storageConfig, err := NewValidatorStorageConfig(t.TempDir(), receipts)
require.NoError(t, err)
require.NotNil(t, storageConfig.FlatKVConfig)
require.False(t, storageConfig.SSConfig.Enable)
require.Equal(t, receipts, storageConfig.ReceiptDBConfig.Enable)
require.NotNil(t, storageConfig.BlockDBConfig)
}
}
27 changes: 27 additions & 0 deletions sei-db/bootstrap/recovery.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package bootstrap

import (
"context"
"errors"
"fmt"

"github.com/sei-protocol/sei-chain/sei-db/ledger_db/block/littblock"
Expand All @@ -28,6 +29,9 @@ func (m *GigaStorageManager) OpenDBWithRecovery(ctx context.Context) error {
if err := m.openBlockStore(); err != nil {
return err
}
if err := m.requireNoReceiptsWhenDisabled(); err != nil {
return err
}
targetHeight, err := m.findTargetRecoveryHeight()
if err != nil {
return err
Expand Down Expand Up @@ -94,6 +98,29 @@ func (m *GigaStorageManager) openReceiptStore() error {
return nil
}

// ErrDisabledReceiptStoreHoldsBlocks is returned when receipts are disabled on a node whose receipt
// directory still holds blocks.
var ErrDisabledReceiptStoreHoldsBlocks = errors.New("receipt store is disabled but holds blocks")

// requireNoReceiptsWhenDisabled refuses to run with receipts disabled while the receipt directory holds
// blocks. A disabled store is left out of recovery and falls behind the others; re-enabling it later
// would then read its stale head as a store to converge on and roll every other store back to it. An
// operator clears the directory to disable receipts, so a later re-enable starts from an empty store.
func (m *GigaStorageManager) requireNoReceiptsWhenDisabled() error {
if m.cfg.ReceiptDBConfig.Enable {
return nil
}
head, err := receipt.GetLatestBlock(m.cfg.ReceiptDBConfig)
if err != nil {
return fmt.Errorf("read disabled receipt store head: %w", err)
}
if head == 0 {
return nil
}
return fmt.Errorf("%w: %s is at block %d; remove that directory to run without receipts, or enable them",
ErrDisabledReceiptStoreHoldsBlocks, m.cfg.ReceiptDBConfig.DBDirectory, head)
}

// findTargetRecoveryHeight returns the height every store is recovered to, read from the heads of the
// block store, the state WAL and the receipt store. A disabled receipt store reads as 0, which is the
// same as an empty one: no opinion on the height.
Expand Down
35 changes: 35 additions & 0 deletions sei-db/bootstrap/recovery_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -746,3 +746,38 @@ func TestOpenDBWithoutRecoveryOnAFreshHome(t *testing.T) {
require.Zero(t, manager.SS().GetLatestVersion())
require.Zero(t, manager.ReceiptDB().LatestVersion())
}

// Receipts disabled on a node whose receipt directory holds blocks is refused rather than run: left out
// of recovery, that store would fall behind, and enabling receipts again later would converge every
// other store back onto its stale head. Clearing the directory is what makes the disable stick, and a
// re-enable after that is the empty-store case.
func TestDisablingReceiptsOverAStoreHoldingBlocksIsRefused(t *testing.T) {
cfg, err := config.DefaultGigaStorageConfig(t.TempDir())
require.NoError(t, err)
manager, err := NewGigaStorageManager(t.Context(), cfg)
require.NoError(t, err)
commitBlocks(t, manager, 3)
writeReceipts(t, manager, 3)
require.NoError(t, manager.Close())

cfg.ReceiptDBConfig.Enable = false
_, err = NewGigaStorageManager(t.Context(), cfg)
require.ErrorIs(t, err, ErrDisabledReceiptStoreHoldsBlocks)
require.ErrorContains(t, err, cfg.ReceiptDBConfig.DBDirectory)

require.NoError(t, os.RemoveAll(cfg.ReceiptDBConfig.DBDirectory))
manager, err = NewGigaStorageManager(t.Context(), cfg)
require.NoError(t, err)
require.Nil(t, manager.ReceiptDB())
require.NoError(t, manager.StateDB().CommitStateChanges(4, evmBlock(4, 4)))
require.NoError(t, manager.Close())

cfg.ReceiptDBConfig.Enable = true
manager, err = NewGigaStorageManager(t.Context(), cfg)
require.NoError(t, err)
defer func() { require.NoError(t, manager.Close()) }()
version, err := manager.SC().GetLatestVersion()
require.NoError(t, err)
require.Equal(t, int64(4), version, "re-enabling receipts over an empty store must not roll state back")
require.Zero(t, manager.ReceiptDB().LatestVersion())
}
16 changes: 16 additions & 0 deletions sei-tendermint/config/autobahn.go
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,18 @@ type AutobahnFileConfig struct {
// Useful for loadtesting (to compare enabled/disabled performance).
// Defaults to true.
EnableEvmProxy utils.Option[bool] `json:"enable_evm_proxy,omitzero"`
// Whether an EVM-only node keeps a receipt store. A node without one
// serves blocks over EVM RPC but no receipts or logs: receipt and
// transaction lookups fail with "this node does not store receipts",
// and receipt-derived block fields (a block's gasUsed, eth_feeHistory's
// gasUsedRatio) read as zero. Its Commit does not wait for receipts to
// land; RPC-serving nodes keep it on.
// Turning it off on a node whose receipt directory already holds blocks
// is refused at startup until that directory is removed, so that
// turning it back on later starts from an empty store rather than
// rolling state back to the stale receipt head.
// Ignored when PersistentStateDir is absent. Defaults to true.
EnableReceiptStore utils.Option[bool] `json:"enable_receipt_store,omitzero"`
// BlockDB optionally overlays AutobahnBlockDBConfig onto littblock.DefaultConfig
// when PersistentStateDir is set. Zero value ⇒ littblock.DefaultConfig unchanged
// (see AutobahnBlockDBConfig for field semantics). Ignored when
Expand All @@ -100,6 +112,10 @@ func (c *AutobahnFileConfig) GetEnableEvmProxy() bool {
return c.EnableEvmProxy.Or(true)
}

func (c *AutobahnFileConfig) GetEnableReceiptStore() bool {
return c.EnableReceiptStore.Or(true)
}

// DefaultMaxInboundFullnodePeers is the built-in cap used when
// AutobahnFileConfig.MaxInboundFullnodePeers is absent.
//
Expand Down
15 changes: 8 additions & 7 deletions sei-tendermint/internal/evmonlyapp/app.go
Original file line number Diff line number Diff line change
Expand Up @@ -866,13 +866,14 @@ func (a *evmOnlyApplication) pendingCursor(height int64) (evmOnlyCursor, error)
panic("unreachable")
}

// Commit acknowledges the finalized block as the one the chain builds on, once
// its receipts are readable: the height this advances is what RPC serves as
// latest, and a block it serves has its receipts. The block's state commit may
// still be landing in the store: it is not waited for here, since that would
// put the write back on the block loop. A commit that fails halts the node here
// or through the next FinalizeBlock, and a restart resumes from the store's own
// version.
// Commit acknowledges the finalized block as the one the chain builds on: the
// height it advances is what RPC serves as latest. On a node with a receipt
// store it first waits for the block's receipts to land, so a block RPC serves
// has them; a node without one has nothing to wait for. The block's state
// commit may still be landing in the store: it is not waited for here, since
// that would put the write back on the block loop. A commit that fails halts
// the node here or through the next FinalizeBlock, and a restart resumes from
// the store's own version.
func (a *evmOnlyApplication) Commit(context.Context) (*abci.ResponseCommit, error) {
if executor, ok := a.settler.Load().Get(); ok {
if err := executor.AwaitReceipts(); err != nil {
Expand Down
2 changes: 1 addition & 1 deletion sei-tendermint/internal/evmonlyapp/app_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -100,7 +100,7 @@ func closeEVMOnlyTestApp(t *testing.T, app abci.Application, storage *bootstrap.

func openEVMOnlyTestStorage(t *testing.T, home string) *bootstrap.GigaStorageManager {
t.Helper()
storageConfig, err := evmonly.NewValidatorStorageConfig(home)
storageConfig, err := evmonly.NewValidatorStorageConfig(home, true)
require.NoError(t, err)
// 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
Expand Down
Loading
Loading