From 4a6fdfec2cdd3e60736edef775e0434c44e4bc45 Mon Sep 17 00:00:00 2001 From: alrevuelta Date: Wed, 7 Oct 2026 09:07:56 +0200 Subject: [PATCH 1/3] Integrate undo log --- giga/config/config.go | 21 ++ giga/config/config_test.go | 4 + giga/config/register_test.go | 1 + giga/evmonly/giga_store.go | 44 ++- giga/evmonly/giga_store_test.go | 4 + sei-db/config/giga_config.go | 8 + sei-db/db_engine/pebbledb/undo/db.go | 196 +++++++++----- sei-db/db_engine/pebbledb/undo/db_test.go | 165 +++++------- sei-db/state_db/giga/historical_view.go | 117 ++++++++ sei-db/state_db/giga/historical_view_test.go | 51 ++++ sei-db/state_db/giga/state_db.go | 101 ++++++- sei-db/state_db/giga/state_db_replay.go | 12 +- sei-db/state_db/giga/state_db_test.go | 12 +- .../state_db/giga/types/state_change_set.go | 40 +++ sei-db/state_db/giga/undo_state_db_test.go | 255 ++++++++++++++++++ 15 files changed, 847 insertions(+), 184 deletions(-) create mode 100644 sei-db/state_db/giga/historical_view.go create mode 100644 sei-db/state_db/giga/historical_view_test.go create mode 100644 sei-db/state_db/giga/types/state_change_set.go create mode 100644 sei-db/state_db/giga/undo_state_db_test.go diff --git a/giga/config/config.go b/giga/config/config.go index 49deee4992..fcff6eb2c1 100644 --- a/giga/config/config.go +++ b/giga/config/config.go @@ -42,6 +42,9 @@ type StorageConfig struct { RollbackWindow uint64 `mapstructure:"rollback_window"` // LookbackWindow is how many queryable blocks are kept below the rollback window; -1 keeps all. LookbackWindow int64 `mapstructure:"lookback_window"` + // StateStoreBackend is how the state store keeps its history: "pebbledb" stores every version of + // every key, "undolog" the value each block replaced, over the current state. + StateStoreBackend string `mapstructure:"state_store_backend"` // PruneInterval is how often the storage garbage collector runs. PruneInterval time.Duration `mapstructure:"prune_interval"` // CheckpointTimeInterval is the wall-clock gap between state-commit checkpoints. @@ -90,6 +93,7 @@ func defaultStorageConfig() StorageConfig { Receipts: true, RollbackWindow: gc.RollbackWindow, LookbackWindow: gc.LookbackWindow, + StateStoreBackend: seidbconfig.DefaultSSBackend, PruneInterval: gc.PruneInterval, CheckpointTimeInterval: cp.TimeInterval, CheckpointBlockInterval: cp.BlockInterval, @@ -102,6 +106,7 @@ const ( FlagStorageReceipts = "giga.storage.receipts" FlagStorageRollbackWindow = "giga.storage.rollback_window" FlagStorageLookbackWindow = "giga.storage.lookback_window" + FlagStorageStateStoreBackend = "giga.storage.state_store_backend" FlagStoragePruneInterval = "giga.storage.prune_interval" FlagStorageCheckpointTimeInterval = "giga.storage.checkpoint_time_interval" FlagStorageCheckpointBlockInterval = "giga.storage.checkpoint_block_interval" @@ -137,6 +142,11 @@ func ReadConfig(opts AppOptions) (Config, error) { return cfg, fmt.Errorf("%s: %w", FlagStorageLookbackWindow, err) } } + if v := opts.Get(FlagStorageStateStoreBackend); v != nil { + if cfg.Storage.StateStoreBackend, err = cast.ToStringE(v); err != nil { + return cfg, fmt.Errorf("%s: %w", FlagStorageStateStoreBackend, err) + } + } if v := opts.Get(FlagStoragePruneInterval); v != nil { if cfg.Storage.PruneInterval, err = cast.ToDurationE(v); err != nil { return cfg, fmt.Errorf("%s: %w", FlagStoragePruneInterval, err) @@ -197,6 +207,12 @@ func (c Config) Validate() error { return fmt.Errorf("%s: must be >= 0, or -1 to keep all history, got %d", FlagStorageLookbackWindow, c.Storage.LookbackWindow) } + switch c.Storage.StateStoreBackend { + case seidbconfig.PebbleDBBackend, seidbconfig.PebbleDBUndoBackend: + default: + return fmt.Errorf("%s: %q is not %q or %q", FlagStorageStateStoreBackend, c.Storage.StateStoreBackend, + seidbconfig.PebbleDBBackend, seidbconfig.PebbleDBUndoBackend) + } if c.Storage.PruneInterval <= 0 { return fmt.Errorf("%s: must be positive, got %s", FlagStoragePruneInterval, c.Storage.PruneInterval) } @@ -257,6 +273,11 @@ rollback_window = {{ .Giga.Storage.RollbackWindow }} # -1 keeps all history. lookback_window = {{ .Giga.Storage.LookbackWindow }} +# state_store_backend is how the state store keeps its history. "pebbledb" stores every version +# of every key; "undolog" stores the value each block replaced, over the current state, and +# drops expired history a bucket at a time. +state_store_backend = "{{ .Giga.Storage.StateStoreBackend }}" + # prune_interval is how often the storage garbage collector runs. prune_interval = "{{ .Giga.Storage.PruneInterval }}" diff --git a/giga/config/config_test.go b/giga/config/config_test.go index 9d37dc8d87..84f3ce4872 100644 --- a/giga/config/config_test.go +++ b/giga/config/config_test.go @@ -27,6 +27,7 @@ func TestDefaultsMatchTheStorageDefaults(t *testing.T) { require.True(t, s.Receipts) require.Equal(t, gc.RollbackWindow, s.RollbackWindow) require.Equal(t, gc.LookbackWindow, s.LookbackWindow) + require.Equal(t, seidbconfig.DefaultStateStoreConfig().Backend, s.StateStoreBackend) require.Equal(t, gc.PruneInterval, s.PruneInterval) require.Equal(t, cp.TimeInterval, s.CheckpointTimeInterval) require.Equal(t, cp.BlockInterval, s.CheckpointBlockInterval) @@ -38,6 +39,7 @@ func TestReadConfigReadsEveryKey(t *testing.T) { "giga.storage.receipts": "false", "giga.storage.rollback_window": "250", "giga.storage.lookback_window": "-1", + "giga.storage.state_store_backend": "undolog", "giga.storage.prune_interval": "90s", "giga.storage.checkpoint_time_interval": "1h", "giga.storage.checkpoint_block_interval": "5000", @@ -55,6 +57,7 @@ func TestReadConfigReadsEveryKey(t *testing.T) { Receipts: false, RollbackWindow: 250, LookbackWindow: -1, + StateStoreBackend: seidbconfig.PebbleDBUndoBackend, PruneInterval: 90 * time.Second, CheckpointTimeInterval: time.Hour, CheckpointBlockInterval: 5000, @@ -76,6 +79,7 @@ func TestReadConfigRejectsUnusableValues(t *testing.T) { "unknown mode": {"giga.storage.mode": "archive"}, "non-boolean receipts": {"giga.storage.receipts": "maybe"}, "lookback below -1": {"giga.storage.lookback_window": "-2"}, + "unknown backend": {"giga.storage.state_store_backend": "rocksdb"}, "zero prune interval": {"giga.storage.prune_interval": "0s"}, "negative occ workers": {"giga.execution.occ_workers": "-1"}, "empty result pool": {"giga.execution.block_result_pool_size": "0"}, diff --git a/giga/config/register_test.go b/giga/config/register_test.go index b11d44036a..495f58efd9 100644 --- a/giga/config/register_test.go +++ b/giga/config/register_test.go @@ -21,6 +21,7 @@ func TestTheDeclaredKeysAreTheKeysThisReaderResolves(t *testing.T) { FlagStorageReceipts, FlagStorageRollbackWindow, FlagStorageLookbackWindow, + FlagStorageStateStoreBackend, FlagStoragePruneInterval, FlagStorageCheckpointTimeInterval, FlagStorageCheckpointBlockInterval, diff --git a/giga/evmonly/giga_store.go b/giga/evmonly/giga_store.go index ef95724f4a..4bda64de4a 100644 --- a/giga/evmonly/giga_store.go +++ b/giga/evmonly/giga_store.go @@ -24,6 +24,12 @@ var ( var _ StateReader = gigaSnapshotStateReader{} +// historyStore is a giga store that takes a block's change set alongside its changesets, to keep the +// values the block replaced. +type historyStore interface { + CommitStateChangesWithPrior(blockNum int64, changeset []*proto.NamedChangeSet, changes *gigatypes.StateChangeSet) error +} + // NamedChangeSetEncoder converts an executor-native state result into the on-disk changesets // understood by a giga store. It must treat the input as immutable, and its output must not alias // the input, since the commit outlives the pooled block result. It may read the store only to @@ -240,15 +246,16 @@ func (e *Executor) awaitPipelineCommit() error { // Commits stay ordered because only one is ever in flight: awaitPipelineCommit lands the previous // one before this is called. func (e *Executor) startPipelineCommit(blockNumber int64, changesets []*proto.NamedChangeSet, changes *StateChangeSet) error { + // The commit outlives the block result, which returns to a pool and is reset. + pending := changes.clone() // Close sets closed before taking storeMu, which the caller holds, so a block that sees it // unset is one Close waits for. if e.closed.Load() { if err := e.awaitPipelineCommit(); err != nil { return err } - return e.stateStore.CommitStateChanges(blockNumber, changesets) + return e.commitState(blockNumber, changesets, pending) } - pending := changes.clone() done := make(chan struct{}) e.pipelineMu.Lock() if failure := e.pipelineFailure; failure != nil { @@ -261,7 +268,7 @@ func (e *Executor) startPipelineCommit(blockNumber int64, changesets []*proto.Na e.pipelineMu.Unlock() go func() { - err := e.stateStore.CommitStateChanges(blockNumber, changesets) + err := e.commitState(blockNumber, changesets, pending) e.pipelineMu.Lock() e.pipelineErr = err e.pipelineMu.Unlock() @@ -270,6 +277,37 @@ func (e *Executor) startPipelineCommit(blockNumber int64, changesets []*proto.Na return nil } +// commitState writes the block to the store, handing over changes as well when the store keeps the +// values the block replaced. +func (e *Executor) commitState(blockNumber int64, changesets []*proto.NamedChangeSet, changes *StateChangeSet) error { + if store, ok := e.stateStore.(historyStore); ok { + return store.CommitStateChangesWithPrior(blockNumber, changesets, gigaChangeSet(changes)) + } + return e.stateStore.CommitStateChanges(blockNumber, changesets) +} + +// TODO: Temporal. At some point both struct will be reused to avoid extra copies and allocs. +func gigaChangeSet(changes *StateChangeSet) *gigatypes.StateChangeSet { + if changes == nil { + return nil + } + return &gigatypes.StateChangeSet{ + Balances: convertAll(changes.Balances, func(c BalanceChange) gigatypes.BalanceChange { return gigatypes.BalanceChange(c) }), + Nonces: convertAll(changes.Nonces, func(c NonceChange) gigatypes.NonceChange { return gigatypes.NonceChange(c) }), + Code: convertAll(changes.Code, func(c CodeChange) gigatypes.CodeChange { return gigatypes.CodeChange(c) }), + StorageClears: changes.StorageClears, + Storage: convertAll(changes.Storage, func(c StorageChange) gigatypes.StorageChange { return gigatypes.StorageChange(c) }), + } +} + +func convertAll[S, D any](values []S, convert func(S) D) []D { + out := make([]D, len(values)) + for i, value := range values { + out[i] = convert(value) + } + return out +} + type gigaSnapshotStateReader struct { snapshot gigatypes.EVMStateView missingState StateReader diff --git a/giga/evmonly/giga_store_test.go b/giga/evmonly/giga_store_test.go index ad170d7833..808d2eb3f0 100644 --- a/giga/evmonly/giga_store_test.go +++ b/giga/evmonly/giga_store_test.go @@ -12,10 +12,14 @@ import ( "github.com/stretchr/testify/require" "github.com/sei-protocol/sei-chain/sei-db/proto" + "github.com/sei-protocol/sei-chain/sei-db/state_db/giga" gigatypes "github.com/sei-protocol/sei-chain/sei-db/state_db/giga/types" "github.com/sei-protocol/sei-chain/sei-db/state_db/sc/flatkv/lthash" ) +// The giga state DB must take each block's change set, or its undo log would silently go without it. +var _ historyStore = (*giga.StateDB)(nil) + type recordingGigaStore struct { snapshot gigatypes.StateView openCount int diff --git a/sei-db/config/giga_config.go b/sei-db/config/giga_config.go index b138f2db33..92baa1d239 100644 --- a/sei-db/config/giga_config.go +++ b/sei-db/config/giga_config.go @@ -86,6 +86,14 @@ func AutobahnStorageConfig(homePath string) (*GigaStorageConfig, error) { return storageConfig, nil } +// WithStateStoreBackend has the state store keep its history as backend, in that backend's +// directory under the home path. +func (c *GigaStorageConfig) WithStateStoreBackend(backend string) *GigaStorageConfig { + c.SSConfig.Backend = backend + c.SSConfig.EVMDBDirectory = utils.GetEVMStateStorePath(c.HomePath, backend) + return c +} + func (c *GigaStorageConfig) WithAccountDBCacheSize(sizeInBytes uint64) *GigaStorageConfig { c.FlatKVConfig.AccountStoreConfig.MaxSize = sizeInBytes return c diff --git a/sei-db/db_engine/pebbledb/undo/db.go b/sei-db/db_engine/pebbledb/undo/db.go index 9fa3991b48..8e99b62f95 100644 --- a/sei-db/db_engine/pebbledb/undo/db.go +++ b/sei-db/db_engine/pebbledb/undo/db.go @@ -18,7 +18,6 @@ import ( "math" "os" "path/filepath" - "slices" "sync" "sync/atomic" "time" @@ -26,6 +25,7 @@ import ( "github.com/cockroachdb/pebble/v2" "github.com/cockroachdb/pebble/v2/bloom" "github.com/cockroachdb/pebble/v2/sstable" + "github.com/ethereum/go-ethereum/crypto" "github.com/sei-protocol/seilog" "go.opentelemetry.io/otel/metric" "golang.org/x/sync/errgroup" @@ -36,7 +36,7 @@ import ( "github.com/sei-protocol/sei-chain/sei-db/config" "github.com/sei-protocol/sei-chain/sei-db/controller" pebbledbmetrics "github.com/sei-protocol/sei-chain/sei-db/db_engine/pebbledb" - "github.com/sei-protocol/sei-chain/sei-db/proto" + gigatypes "github.com/sei-protocol/sei-chain/sei-db/state_db/giga/types" ) var logger = seilog.NewLogger("db", "db-engine", "pebbledb", "undo") @@ -129,7 +129,7 @@ type Database struct { // pendingBlock is one entry of the write queue: a block, or, with done set, a barrier. type pendingBlock struct { version int64 - prior []*proto.KVPair + changes *gigatypes.StateChangeSet current LiveStateView done chan struct{} } @@ -330,12 +330,11 @@ func (db *Database) resume(version int64) (uint64, error) { return height, nil } -// ApplyBlock queues prior balance, nonce, code hash, storage and code values for version, -// with Delete marking prior absence, and takes ownership of the post-block current view. -// It panics before a successful Resume or after Close. Unsupported key families and malformed -// records are fatal write errors. -func (db *Database) ApplyBlock(version int64, prior []*proto.KVPair, current LiveStateView) { - if err := db.enqueue(pendingBlock{version: version, prior: prior, current: current}); err != nil { +// ApplyBlock queues block version's change set, whose prior values the store records, and takes +// ownership of the post-block current view. changes is read in the background, so it must not change +// afterwards; nil means the block changed nothing. It panics before a successful Resume or after Close. +func (db *Database) ApplyBlock(version int64, changes *gigatypes.StateChangeSet, current LiveStateView) { + if err := db.enqueue(pendingBlock{version: version, changes: changes, current: current}); err != nil { current.Close() panic(err) } @@ -372,7 +371,7 @@ func (db *Database) writeInBackground() { close(block.done) continue } - if err := db.applyBlock(block.version, block.prior, block.current); err != nil { + if err := db.applyBlock(block.version, block.changes, block.current); err != nil { // A block that cannot be written leaves a gap every later read below it would answer wrongly. panic(err) } @@ -380,8 +379,8 @@ func (db *Database) writeInBackground() { } // applyBlock writes block version and makes current the view reads fall back to. -func (db *Database) applyBlock(version int64, prior []*proto.KVPair, current LiveStateView) error { - height, err := db.writeBlock(version, prior) +func (db *Database) applyBlock(version int64, changes *gigatypes.StateChangeSet, current LiveStateView) error { + height, err := db.writeBlock(version, changes) if err != nil { current.Close() return err @@ -393,11 +392,11 @@ func (db *Database) applyBlock(version int64, prior []*proto.KVPair, current Liv // writeBlock writes block version's records, then moves the latest-height marker to it, and returns // the block's height. A crash in between leaves records only above the marker, which the block // rewrites when it is applied again. -func (db *Database) writeBlock(version int64, prior []*proto.KVPair) (height uint64, err error) { +func (db *Database) writeBlock(version int64, changes *gigatypes.StateChangeSet) (height uint64, err error) { start := time.Now() defer func() { otelMetrics.applyLatency.Record(context.Background(), time.Since(start).Seconds(), successAttr(err)) - otelMetrics.applyRecords.Record(context.Background(), int64(len(prior))) + otelMetrics.applyRecords.Record(context.Background(), int64(recordCount(changes))) }() if height, err = heightOf(version); err != nil { return 0, err @@ -407,18 +406,7 @@ func (db *Database) writeBlock(version int64, prior []*proto.KVPair) (height uin if latest := db.latestHeight.Load(); height != latest+1 { return 0, fmt.Errorf("undo: block %d does not follow the latest applied block %d", height, latest) } - if err := validateRecords(prior); err != nil { - return 0, err - } - bucket := height / db.bucketSize - - // Pebble inserts a batch into the memtable on one goroutine, and concurrent batches - // concurrently, so the records go out as batches committed in parallel. - var g errgroup.Group - for records := range slices.Chunk(prior, max(minBatchRecords, (len(prior)+writeParallelism-1)/writeParallelism)) { - g.Go(func() error { return db.writeRecords(records, bucket, height) }) - } - if err := g.Wait(); err != nil { + if err := db.writeRecords(changes, height/db.bucketSize, height); err != nil { return 0, err } if err := writeMarker(db.storage, latestVersionKey, height, pebble.NoSync); err != nil { @@ -427,42 +415,128 @@ func (db *Database) writeBlock(version int64, prior []*proto.KVPair) (height uin return height, nil } -// validateRecords checks that every prior record has a supported EVM key and value length. -func validateRecords(prior []*proto.KVPair) error { - for _, pair := range prior { - if pair == nil { - return errors.New("undo: nil prior record") - } - keyLen, valueLen := keyLayout(pair.Key) - if keyLen == 0 { - return fmt.Errorf("undo: unsupported EVM key %x", pair.Key) +// recordCount returns how many records changes writes: one per key it changed, and two per code +// change, which changes the code hash too. +func recordCount(changes *gigatypes.StateChangeSet) int { + if changes == nil { + return 0 + } + return len(changes.Balances) + len(changes.Nonces) + 2*len(changes.Code) + len(changes.Storage) +} + +// writeRecords writes a record of the value before the block at height of every key changes writes, +// absent for a key that held a zero value or none. A storage clear writes none: it only clears accounts +// created in the same transaction, whose storage was empty. +func (db *Database) writeRecords(changes *gigatypes.StateChangeSet, bucket, height uint64) error { + if changes == nil { + return nil + } + w := &recordWriter{ + db: db, + bucket: bucket, + height: height, + size: max(minBatchRecords, (recordCount(changes)+writeParallelism-1)/writeParallelism), + } + for _, change := range changes.Balances { + var balance [wordLen]byte + if change.PriorBalance != nil { + change.PriorBalance.FillBytes(balance[:]) } - if len(pair.Key) != keyLen { - return fmt.Errorf("undo: key %x has length %d, want %d", pair.Key, len(pair.Key), keyLen) + w.add(BalanceKeyPrefix[0], change.Address[:], nil, nonZero(balance[:])) + } + for _, change := range changes.Nonces { + var nonce [8]byte + binary.BigEndian.PutUint64(nonce[:], change.PriorNonce) + w.add(NonceKeyPrefix[0], change.Address[:], nil, nonZero(nonce[:])) + } + for _, change := range changes.Code { + var codeHash []byte + if len(change.PriorCode) != 0 { + codeHash = crypto.Keccak256(change.PriorCode) } - if !pair.Delete && valueLen >= 0 && len(pair.Value) != valueLen { - return fmt.Errorf("undo: value for key %x has length %d, want %d", pair.Key, len(pair.Value), valueLen) + w.add(CodeHashKeyPrefix[0], change.Address[:], nil, codeHash) + w.add(CodeKeyPrefix[0], change.Address[:], nil, nonZero(change.PriorCode)) + } + for _, change := range changes.Storage { + w.add(StateKeyPrefix[0], change.Address[:], change.Key[:], nonZero(change.PriorValue[:])) + } + return w.finish() +} + +// nonZero returns value, or nil when it is empty or all zeros. +func nonZero(value []byte) []byte { + for _, b := range value { + if b != 0 { + return value } } return nil } -// writeRecords commits the records of prior, all written by the block at height, as one batch. -func (db *Database) writeRecords(prior []*proto.KVPair, bucket, height uint64) error { - b := db.storage.NewBatch() - defer func() { _ = b.Close() }() - for _, pair := range prior { - d := b.SetDeferred(recordKeyLen(pair.Key), valueLen(pair)) - encodeRecordKey(d.Key, bucket, pair.Key, height) - encodeValue(d.Value, pair) - if err := d.Finish(); err != nil { - return fmt.Errorf("undo: stage record: %w", err) - } +// recordWriter stages one block's records into batches of size records, each committed in the +// background once full. Pebble inserts a batch into the memtable on one goroutine, and concurrent +// batches concurrently, so a large block goes out as batches committed in parallel. +type recordWriter struct { + db *Database + bucket, height uint64 + size int + + batch *pebble.Batch + staged int + err error + g errgroup.Group +} + +// add stages the record of the key prefix | addr | slot, holding value, or absent when value is nil. +func (w *recordWriter) add(prefix byte, addr, slot, value []byte) { + if w.err != nil { + return + } + if w.batch == nil { + w.batch = w.db.storage.NewBatch() + } + var buf [storageKeyLen]byte + buf[0] = prefix + key := buf[:1+copy(buf[1:], addr)+copy(buf[1+len(addr):], slot)] + d := w.batch.SetDeferred(recordKeyLen(key), 1+len(value)) + encodeRecordKey(d.Key, w.bucket, key, w.height) + d.Value[0] = valueAbsent + if value != nil { + d.Value[0] = valuePresent + copy(d.Value[1:], value) + } + if err := d.Finish(); err != nil { + w.err = fmt.Errorf("undo: stage record: %w", err) + return } - if err := b.Commit(pebble.NoSync); err != nil { - return fmt.Errorf("undo: commit block %d: %w", height, err) + if w.staged++; w.staged == w.size { + w.commit() } - return nil +} + +// commit commits the staged batch in the background. +func (w *recordWriter) commit() { + b, height := w.batch, w.height + w.batch, w.staged = nil, 0 + w.g.Go(func() error { + defer func() { _ = b.Close() }() + if err := b.Commit(pebble.NoSync); err != nil { + return fmt.Errorf("undo: commit block %d: %w", height, err) + } + return nil + }) +} + +// finish commits what is still staged and waits for every batch. +func (w *recordWriter) finish() error { + if w.batch != nil { + if w.err != nil { + _ = w.batch.Close() + } else { + w.commit() + } + } + return errors.Join(w.err, w.g.Wait()) } // head is a current-state view and the height it describes. The store holds one reference and @@ -872,22 +946,6 @@ func (db *Database) Close() error { return db.storage.Close() } -func valueLen(pair *proto.KVPair) int { - if pair.Delete { - return 1 - } - return 1 + len(pair.Value) -} - -func encodeValue(dst []byte, pair *proto.KVPair) { - if pair.Delete { - dst[0] = valueAbsent - return - } - dst[0] = valuePresent - copy(dst[1:], pair.Value) -} - // decodeValue returns a copy of the value a record holds, nil when the key did not exist. func decodeValue(v []byte) ([]byte, error) { if len(v) == 0 { diff --git a/sei-db/db_engine/pebbledb/undo/db_test.go b/sei-db/db_engine/pebbledb/undo/db_test.go index b549aaec8a..b3e5ae3af0 100644 --- a/sei-db/db_engine/pebbledb/undo/db_test.go +++ b/sei-db/db_engine/pebbledb/undo/db_test.go @@ -5,6 +5,7 @@ import ( "encoding/binary" "fmt" "maps" + "math/big" "math/rand/v2" "sync" "sync/atomic" @@ -12,11 +13,12 @@ import ( "github.com/cockroachdb/pebble/v2" "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/crypto" "github.com/stretchr/testify/require" "github.com/sei-protocol/sei-chain/sei-db/common/keys" "github.com/sei-protocol/sei-chain/sei-db/config" - "github.com/sei-protocol/sei-chain/sei-db/proto" + gigatypes "github.com/sei-protocol/sei-chain/sei-db/state_db/giga/types" ) var ( @@ -71,6 +73,34 @@ func (s liveState) Get(_ string, key []byte) ([]byte, bool) { func (liveState) Close() {} +// priorsOf returns the change set of a block that replaced, for each key of prior, the value prior +// holds for it, nil for none. A code hash changes only with its code, so it has no entry of its own. +func priorsOf(t testing.TB, prior map[string][]byte) *gigatypes.StateChangeSet { + t.Helper() + changes := &gigatypes.StateChangeSet{} + for key, old := range prior { + addr := common.BytesToAddress([]byte(key[1 : 1+addressLen])) + switch key[0] { + case BalanceKeyPrefix[0]: + changes.Balances = append(changes.Balances, gigatypes.BalanceChange{Address: addr, PriorBalance: new(big.Int).SetBytes(old)}) + case NonceKeyPrefix[0]: + var n uint64 + if old != nil { + n = binary.BigEndian.Uint64(old) + } + changes.Nonces = append(changes.Nonces, gigatypes.NonceChange{Address: addr, PriorNonce: n}) + case CodeKeyPrefix[0]: + changes.Code = append(changes.Code, gigatypes.CodeChange{Address: addr, PriorCode: old}) + case StateKeyPrefix[0]: + changes.Storage = append(changes.Storage, gigatypes.StorageChange{ + Address: addr, Key: common.BytesToHash([]byte(key[1+addressLen:])), PriorValue: common.BytesToHash(old)}) + default: + t.Fatalf("no change writes key %x on its own", key) + } + } + return changes +} + // TestUndoLogByHand spells the store out: the live state after the last block, the value each block // replaced, and what a read after each block must return. func TestUndoLogByHand(t *testing.T) { @@ -85,20 +115,18 @@ func TestUndoLogByHand(t *testing.T) { } // What each block changed, recorded as the value the key held before the block. - was := func(key string, value []byte) *proto.KVPair { return &proto.KVPair{Key: []byte(key), Value: value} } - wasAbsent := func(key string) *proto.KVPair { return &proto.KVPair{Key: []byte(key), Delete: true} } - blocks := map[int64][]*proto.KVPair{ - 103: {was(balanceKey(alice), word(10))}, // alice 10 -> 15 - 105: {wasAbsent(balanceKey(carol))}, // carol created with 3 - 109: {was(balanceKey(alice), word(15))}, // alice 15 -> 25 - 110: {was(balanceKey(dave), word(1))}, // dave deleted - 112: {was(balanceKey(alice), word(25))}, // alice 25 -> 80 + blocks := map[int64]map[string][]byte{ + 103: {balanceKey(alice): word(10)}, // alice 10 -> 15 + 105: {balanceKey(carol): nil}, // carol created with 3 + 109: {balanceKey(alice): word(15)}, // alice 15 -> 25 + 110: {balanceKey(dave): word(1)}, // dave deleted + 112: {balanceKey(alice): word(25)}, // alice 25 -> 80 } require.NoError(t, db.Resume(100, live)) for height := int64(101); height <= 112; height++ { // Every block is handed the final live state: only the last block's is ever read. - db.ApplyBlock(height, blocks[height], live) + db.ApplyBlock(height, priorsOf(t, blocks[height]), live) } db.WaitForPendingWrites() @@ -202,30 +230,36 @@ func (c *chain) stateAt(height int64, key string) ([]byte, bool) { } // apply commits one block setting (or, for a nil value, deleting) each key, and feeds its prior -// values to the store. +// values to the store. As in the state commit store, a zero value is no value, and code carries its +// code hash. func (c *chain) apply(changes map[string][]byte, async bool) { c.mu.Lock() prev := c.states[len(c.states)-1] version := c.base + int64(len(c.states)) next := maps.Clone(prev) - var prior []*proto.KVPair - for key, value := range changes { - old, existed := prev[key] - prior = append(prior, &proto.KVPair{Key: []byte(key), Value: old, Delete: !existed}) - if value == nil { + prior := map[string][]byte{} + set := func(key string, value []byte) { + if nonZero(value) == nil { delete(next, key) } else { next[key] = value } } + for key, value := range changes { + prior[key] = prev[key] + set(key, value) + if key[0] == CodeKeyPrefix[0] { + set(string(CodeHashKeyPrefix)+key[1:], crypto.Keccak256(nonZero(value))) + } + } c.states = append(c.states, next) c.mu.Unlock() if async { - c.db.ApplyBlock(version, prior, c.view(next)) + c.db.ApplyBlock(version, priorsOf(c.t, prior), c.view(next)) return } - require.NoError(c.t, c.db.applyBlock(version, prior, c.view(next))) + require.NoError(c.t, c.db.applyBlock(version, priorsOf(c.t, prior), c.view(next))) } // read returns key's value after block height through a view of the store. @@ -309,24 +343,6 @@ func TestReadsFollowTheSpecExample(t *testing.T) { }, records(t, c.db)) } -func TestAbsentAndEmptyValuesStayDistinct(t *testing.T) { - c := newChain(t, t.TempDir(), 4, 0) - defer func() { require.NoError(t, c.db.Close()) }() - - code := codeKey(contract) - c.apply(map[string][]byte{code: {}}, false) // 1: created with empty code - c.apply(map[string][]byte{code: {0x60, 0x00, 0xf3}}, false) // 2: PUSH1 0x00 RETURN - c.apply(map[string][]byte{code: nil}, false) // 3: deleted - c.apply(map[string][]byte{codeKey(account(0xbeef)): {0x00}}, false) - - for h := int64(0); h <= 4; h++ { - c.requireRead(h, code) - } - got := read(t, c.db, 1, code) - require.NotNil(t, got) - require.Empty(t, got) -} - func TestRandomHistoryMatchesTheModel(t *testing.T) { for _, async := range []bool{false, true} { t.Run(fmt.Sprintf("async=%v", async), func(t *testing.T) { @@ -550,7 +566,7 @@ func TestNewDatabaseUsesDefaultBucketSize(t *testing.T) { require.Equal(t, uint64(defaultBucketSize), db.bucketSize) key := nonceKey(alice) require.NoError(t, db.Resume(0, liveState{})) - db.ApplyBlock(1, []*proto.KVPair{{Key: []byte(key), Delete: true}}, liveState{key: nonce(1)}) + db.ApplyBlock(1, priorsOf(t, map[string][]byte{key: nil}), liveState{key: nonce(1)}) db.WaitForPendingWrites() require.Nil(t, read(t, db, 0, key)) require.Equal(t, nonce(1), read(t, db, 1, key)) @@ -656,8 +672,7 @@ func TestDiscardStateAboveClearsAHalfWrittenBlock(t *testing.T) { for h := uint64(1); h <= 9; h++ { c.apply(map[string][]byte{slot: word(h)}, false) } - stale := []*proto.KVPair{{Key: []byte(slot), Value: word(0xbad)}} - require.NoError(t, c.db.writeRecords(stale, 1, 10)) + require.NoError(t, c.db.writeRecords(priorsOf(t, map[string][]byte{slot: word(0xbad)}), 1, 10)) require.NoError(t, c.db.Close()) require.NoError(t, DiscardStateAbove(dir, testConfig(), target)) @@ -803,7 +818,7 @@ func TestBlocksRequireSuccessfulResume(t *testing.T) { key := nonceKey(alice) require.NoError(t, db.Resume(5, liveState{key: nonce(1)})) - db.ApplyBlock(6, []*proto.KVPair{{Key: []byte(key), Value: nonce(1)}}, liveState{key: nonce(2)}) + db.ApplyBlock(6, priorsOf(t, map[string][]byte{key: nonce(1)}), liveState{key: nonce(2)}) db.WaitForPendingWrites() require.Equal(t, int64(6), db.GetLatestVersion()) require.Equal(t, nonce(1), read(t, db, 5, key)) @@ -1066,80 +1081,40 @@ func TestLargeBlocksLandWhole(t *testing.T) { require.Equal(t, slots, atTwo) } -func TestValidEVMRecords(t *testing.T) { +func TestEveryFamilyIsRecorded(t *testing.T) { db := openTestDB(t, t.TempDir(), 10) defer func() { require.NoError(t, db.Close()) }() - var prior []*proto.KVPair - for _, kind := range evmTypes { - _, valueSize := keyLayout([]byte{kind}) - prior = append(prior, - &proto.KVPair{Key: evmKey(kind, 0), Value: make([]byte, max(valueSize, 0))}, - &proto.KVPair{Key: evmKey(kind, 1), Delete: true}, - ) - } - require.Zero(t, testing.AllocsPerRun(100, func() { - if err := validateRecords(prior); err != nil { - t.Fatal(err) - } - })) - height, err := db.writeBlock(1, prior) + changes := priorsOf(t, map[string][]byte{ + balanceKey(alice): word(1), + nonceKey(alice): nonce(2), + codeKey(contract): {0x60, 0x00}, + storageKey(contract, 1): word(3), + }) + height, err := db.writeBlock(1, changes) require.NoError(t, err) require.Equal(t, uint64(1), height) - require.Len(t, records(t, db), len(prior)) + require.Len(t, records(t, db), recordCount(changes)) + require.Equal(t, 5, recordCount(changes), "code records its code hash too") db.latestHeight.Store(height) // An empty block must still advance the committed height. height, err = db.writeBlock(2, nil) require.NoError(t, err) require.Equal(t, uint64(2), height) - require.Len(t, records(t, db), len(prior)) + require.Len(t, records(t, db), 5) latest, err := readMarker(db.storage, latestVersionKey) require.NoError(t, err) require.Equal(t, uint64(2), latest) } -func TestMalformedEVMRecordsLeaveBlockUnwritten(t *testing.T) { - db := openTestDB(t, t.TempDir(), 10) - defer func() { require.NoError(t, db.Close()) }() - valid := &proto.KVPair{Key: evmKey(CodeKeyPrefix[0], 0), Value: []byte("code")} - check := func(name string, bad *proto.KVPair) { - t.Run(name, func(t *testing.T) { - _, err := db.writeBlock(1, []*proto.KVPair{valid, bad}) - require.Error(t, err) - require.Empty(t, records(t, db), "validation must precede every batch write") - latest, err := readMarker(db.storage, latestVersionKey) - require.NoError(t, err) - require.Zero(t, latest) - }) - } - check("nil", nil) - check("empty key", &proto.KVPair{}) - check("unknown type", &proto.KVPair{Key: []byte{0xff}, Delete: true}) - check("unsupported family", &proto.KVPair{ - Key: append([]byte{0x09}, make([]byte, addressLen)...), Value: make([]byte, 8), - }) - for _, kind := range evmTypes { - key := evmKey(kind, 1) - for _, size := range []int{1, len(key) - 1, len(key) + 1} { - malformed := make([]byte, size) - copy(malformed, key) - check(fmt.Sprintf("type=%x/key=%d", kind, size), &proto.KVPair{Key: malformed, Delete: true}) - } - _, valueSize := keyLayout(key) - if valueSize < 0 { - continue - } - for _, size := range []int{0, 1, valueSize - 1, valueSize + 1} { - check(fmt.Sprintf("type=%x/value=%d", kind, size), &proto.KVPair{Key: key, Value: make([]byte, size)}) - } - } -} - func TestEVMFamiliesReadAcrossFlushCompactionAndReopen(t *testing.T) { dir := t.TempDir() c := newChain(t, dir, 2, 0) initial, updated, deleted := map[string][]byte{}, map[string][]byte{}, map[string][]byte{} for _, kind := range evmTypes { + if kind == CodeHashKeyPrefix[0] { + continue // written with its code + } _, size := keyLayout([]byte{kind}) for _, fill := range []byte{0, 0xff} { key := string(evmKey(kind, fill)) @@ -1161,7 +1136,7 @@ func TestEVMFamiliesReadAcrossFlushCompactionAndReopen(t *testing.T) { require.NoError(t, c.db.storage.Flush()) require.NoError(t, c.db.storage.Compact(t.Context(), bucketBoundary(0), bucketBoundary(2), true)) verify := func() { - for key := range initial { + for key := range c.states[1] { for h := int64(0); h <= 3; h++ { c.requireRead(h, key) } diff --git a/sei-db/state_db/giga/historical_view.go b/sei-db/state_db/giga/historical_view.go new file mode 100644 index 0000000000..7f4b71e049 --- /dev/null +++ b/sei-db/state_db/giga/historical_view.go @@ -0,0 +1,117 @@ +package giga + +import ( + "encoding/binary" + "fmt" + "sync" + + "github.com/ethereum/go-ethereum/common" + + "github.com/sei-protocol/sei-chain/sei-db/common/keys" + gigatypes "github.com/sei-protocol/sei-chain/sei-db/state_db/giga/types" +) + +var _ gigatypes.StateView = (*historicalView)(nil) + +// historicalView serves the Giga read API at a past height from the EVM state store's history. The +// EVM accessors read the same logical keys the executor writes, so they answer what those keys held +// after the view's block. +type historicalView struct { + height int64 + + // read returns the EVM key's value at height, nil when it did not exist then. + read func(key []byte) ([]byte, error) + + // release frees whatever the view pins. Guarded by closeOnce. + release func() + closeOnce sync.Once +} + +func (v *historicalView) GetBlockHeight() int64 { + return v.height +} + +// Get serves only the EVM module, the one the EVM state store keeps the history of. +func (v *historicalView) Get(module string, key []byte) ([]byte, bool) { + if module != keys.EVMStoreKey { + panic(fmt.Sprintf("giga: the EVM state store keeps no history of module %q", module)) + } + value, err := v.read(key) + if err != nil { + // A view has no recoverable errors: a read it cannot serve is fatal to its caller. + panic(fmt.Sprintf("giga: historical read of %s/%x at height %d: %v", module, key, v.height, err)) + } + return value, value != nil +} + +func (v *historicalView) Close() { + v.closeOnce.Do(v.release) +} + +// evm reads the EVM key of kind for keyBytes. +func (v *historicalView) evm(kind keys.EVMKeyKind, keyBytes []byte) ([]byte, bool) { + return v.Get(keys.EVMStoreKey, keys.BuildEVMKey(kind, keyBytes)) +} + +func (v *historicalView) AccountExists(addr gigatypes.Address) bool { + _, ok := v.ReadAccount(addr) + return ok +} + +func (v *historicalView) GetStorage(addr gigatypes.Address, slot gigatypes.Hash) gigatypes.Hash { + value, _ := v.evm(keys.EVMKeyStorage, append(addr[:], slot[:]...)) + return common.BytesToHash(value) +} + +func (v *historicalView) GetBalance(addr gigatypes.Address) gigatypes.Hash { + value, _ := v.evm(keys.EVMKeyBalance, addr[:]) + return common.BytesToHash(value) +} + +func (v *historicalView) GetNonce(addr gigatypes.Address) uint64 { + value, found := v.evm(keys.EVMKeyNonce, addr[:]) + if !found || len(value) != 8 { + return 0 + } + return binary.BigEndian.Uint64(value) +} + +func (v *historicalView) GetCodeSize(addr gigatypes.Address) int { + return len(v.GetCode(addr)) +} + +func (v *historicalView) GetCodeHash(addr gigatypes.Address) gigatypes.Hash { + account, ok := v.ReadAccount(addr) + if !ok { + return gigatypes.Hash{} + } + return account.CodeHash +} + +func (v *historicalView) GetCode(addr gigatypes.Address) []byte { + value, _ := v.evm(keys.EVMKeyCode, addr[:]) + return value +} + +// ReadAccount reads the three account fields at the view's height. An account exists when any of +// them is non-zero, as the state commit store keeps a row only for such an account; one with no code +// hash stored has no code. +func (v *historicalView) ReadAccount(addr gigatypes.Address) (gigatypes.Account, bool) { + balance, _ := v.evm(keys.EVMKeyBalance, addr[:]) + nonce, _ := v.evm(keys.EVMKeyNonce, addr[:]) + codeHash, hasCodeHash := v.evm(keys.EVMKeyCodeHash, addr[:]) + account := gigatypes.Account{ + Balance: common.BytesToHash(balance), + CodeHash: gigatypes.EmptyCodeHash, + } + if len(nonce) == 8 { + account.Nonce = binary.BigEndian.Uint64(nonce) + } + if hasCodeHash { + account.CodeHash = common.BytesToHash(codeHash) + } + if account.Balance == (gigatypes.Hash{}) && account.Nonce == 0 && !hasCodeHash { + return gigatypes.Account{}, false + } + return account, true +} diff --git a/sei-db/state_db/giga/historical_view_test.go b/sei-db/state_db/giga/historical_view_test.go new file mode 100644 index 0000000000..cd970529c0 --- /dev/null +++ b/sei-db/state_db/giga/historical_view_test.go @@ -0,0 +1,51 @@ +package giga + +import ( + "testing" + + "github.com/ethereum/go-ethereum/common" + "github.com/stretchr/testify/require" + + "github.com/sei-protocol/sei-chain/sei-db/common/keys" + gigatypes "github.com/sei-protocol/sei-chain/sei-db/state_db/giga/types" +) + +// An account exists at a past height exactly when the state commit store would have held a row for +// it: when its balance, nonce or code hash is non-zero. A stored zero nonce alone is no account. +func TestHistoricalViewAccountExistsWhenAFieldIsNonZero(t *testing.T) { + addr := common.Address{0x42} + codeHash := common.Hash{0xc0} + for _, tc := range []struct { + name string + stored map[keys.EVMKeyKind][]byte + want gigatypes.Account + exists bool + }{ + {"nothing stored", nil, gigatypes.Account{}, false}, + {"zero nonce alone", map[keys.EVMKeyKind][]byte{keys.EVMKeyNonce: nonceValue(0)}, gigatypes.Account{}, false}, + {"balance", map[keys.EVMKeyKind][]byte{keys.EVMKeyBalance: common.Hash{31: 5}.Bytes(), keys.EVMKeyNonce: nonceValue(0)}, + gigatypes.Account{Balance: common.Hash{31: 5}, CodeHash: gigatypes.EmptyCodeHash}, true}, + {"nonce", map[keys.EVMKeyKind][]byte{keys.EVMKeyNonce: nonceValue(3)}, + gigatypes.Account{Nonce: 3, CodeHash: gigatypes.EmptyCodeHash}, true}, + {"code hash", map[keys.EVMKeyKind][]byte{keys.EVMKeyCodeHash: codeHash.Bytes()}, + gigatypes.Account{CodeHash: codeHash}, true}, + } { + t.Run(tc.name, func(t *testing.T) { + stored := map[string][]byte{} + for kind, value := range tc.stored { + stored[string(keys.BuildEVMKey(kind, addr[:]))] = value + } + view := &historicalView{ + height: 7, + read: func(key []byte) ([]byte, error) { + return stored[string(key)], nil + }, + release: func() {}, + } + account, exists := view.ReadAccount(addr) + require.Equal(t, tc.exists, exists) + require.Equal(t, tc.want, account) + require.Equal(t, tc.exists, view.AccountExists(addr)) + }) + } +} diff --git a/sei-db/state_db/giga/state_db.go b/sei-db/state_db/giga/state_db.go index a804051f1e..4f089d46ac 100644 --- a/sei-db/state_db/giga/state_db.go +++ b/sei-db/state_db/giga/state_db.go @@ -12,6 +12,7 @@ import ( "github.com/sei-protocol/sei-chain/sei-db/common/utils" "github.com/sei-protocol/sei-chain/sei-db/config" "github.com/sei-protocol/sei-chain/sei-db/controller" + "github.com/sei-protocol/sei-chain/sei-db/db_engine/pebbledb/undo" "github.com/sei-protocol/sei-chain/sei-db/proto" gigatypes "github.com/sei-protocol/sei-chain/sei-db/state_db/giga/types" "github.com/sei-protocol/sei-chain/sei-db/state_db/sc/flatkv" @@ -43,9 +44,13 @@ type StateDB struct { // The state commit store, which both receives writes and serves current-block reads. sc *flatkv.CommitStore - // ss is nil when the EVM state store is disabled. + // ss is nil when the EVM state store is disabled or keeps its history as an undo log. ss *evm.EVMStateStore + // undo is the EVM state store when it keeps its history as an undo log, and nil otherwise. At + // most one of ss and undo is open. + undo *undo.Database + // The checkpoint schedule SC and SS take their snapshot boundaries from. checkpointer *controller.CheckpointScheduler @@ -109,6 +114,9 @@ func NewStateDB( if err := s.catchUpToWAL(ctx); err != nil { return nil, err } + if err := s.resumeUndoLog(); err != nil { + return nil, err + } return s, nil } @@ -204,6 +212,9 @@ func (s *StateDB) openSS() error { if !s.ssCfg.Enable { return nil } + if usesUndoLog(s.ssCfg) { + return s.openUndoLog() + } ss, err := evm.NewEVMStateStore(s.ssCfg.EVMDBDirectory, s.ssCfg) if err != nil { return fmt.Errorf("open EVM state store: %w", err) @@ -215,6 +226,35 @@ func (s *StateDB) openSS() error { return nil } +// usesUndoLog reports whether cfg opens the EVM state store as an undo log. +func usesUndoLog(cfg config.StateStoreConfig) bool { + return cfg.Enable && cfg.Backend == config.PebbleDBUndoBackend +} + +// openUndoLog opens the EVM state store as an undo log. It takes no snapshots: every block its log +// covers is readable directly, and it reads current state from SC rather than restoring any. +func (s *StateDB) openUndoLog() error { + db, err := undo.OpenDB(s.ssCfg.EVMDBDirectory, s.ssCfg) + if err != nil { + return fmt.Errorf("open EVM undo-log state store: %w", err) + } + s.undo = db + return nil +} + +// resumeUndoLog binds the undo log to SC's current block, which answers every read the log holds no +// later record for. It runs once SC is on the WAL head: the log cannot replay the WAL, whose blocks +// carry no prior values, so a log left behind SC stops serving the history below SC's height. +func (s *StateDB) resumeUndoLog() error { + if s.undo == nil { + return nil + } + if err := s.undo.Resume(s.sc.Version(), s.sc.OpenView()); err != nil { + return fmt.Errorf("resume the EVM undo-log state store: %w", err) + } + return nil +} + // startCheckpointSchedule puts SC and SS on one snapshot cadence. It runs before either store is on a // height, so the blocks SC replays offer themselves to the schedule as live commits do. func (s *StateDB) startCheckpointSchedule(cfg config.CheckpointConfig) { @@ -294,6 +334,12 @@ func (s *StateDB) Close() error { errs = errors.Join(errs, fmt.Errorf("close EVM state store: %w", err)) } } + // Before SC: the undo log holds a view of SC's current block until it closes. + if s.undo != nil { + if err := timer.Close("ss", s.undo.Close); err != nil { + errs = errors.Join(errs, fmt.Errorf("close EVM undo-log state store: %w", err)) + } + } if s.sc != nil { if err := timer.Close("sc", s.sc.Close); err != nil { errs = errors.Join(errs, fmt.Errorf("close state commit store: %w", err)) @@ -311,9 +357,12 @@ func (s *StateDB) Close() error { // SC returns the state commit store. func (s *StateDB) SC() *flatkv.CommitStore { return s.sc } -// SS returns the EVM state store, or nil when it is disabled. +// SS returns the EVM state store, or nil when it is disabled or kept as an undo log. func (s *StateDB) SS() *evm.EVMStateStore { return s.ss } +// UndoSS returns the EVM state store when it is kept as an undo log, and nil otherwise. +func (s *StateDB) UndoSS() *undo.Database { return s.undo } + // WAL returns the state WAL. It is the one this StateDB opened, and is not replaced for the StateDB's // lifetime. func (s *StateDB) WAL() statewal.StateWAL { return s.wal } @@ -333,21 +382,31 @@ func (s *StateDB) PrunableStores() []controller.PrunableStore { if s.ss != nil { stores = append(stores, s.ss) } + if s.undo != nil { + stores = append(stores, s.undo) + } return stores } -// CommitStateChanges writes a block to the state WAL, the state commit store and the EVM state store, -// in that order. Callers must not run two commits at once. +// CommitStateChanges is CommitStateChangesWithPrior without the block's change set, so an undo-log EVM +// state store records no history for the block. +func (s *StateDB) CommitStateChanges(blockNum int64, changeset []*proto.NamedChangeSet) error { + return s.CommitStateChangesWithPrior(blockNum, changeset, nil) +} + +// CommitStateChangesWithPrior writes a block to the state WAL, the state commit store and the EVM +// state store, in that order. Callers must not run two commits at once. changes is the block's change +// set, which an undo-log EVM state store keeps the replaced values from; nil when the block changed no +// EVM state. // // The time each of the three takes is published under commitPhaseTimerName, split by phase, which is // what makes a slow store on the commit path attributable to that store. -func (s *StateDB) CommitStateChanges(blockNum int64, changeset []*proto.NamedChangeSet) error { +func (s *StateDB) CommitStateChangesWithPrior(blockNum int64, changeset []*proto.NamedChangeSet, changes *gigatypes.StateChangeSet) error { if blockNum < 0 { // The WAL numbers blocks with a uint64, so a negative height converts to a block far in the // future that the WAL has no way to recognize as a mistake. return fmt.Errorf("commit block %d: block number must not be negative", blockNum) } - // Ends the phase in flight, so the gap until the next commit is not charged to the last store. defer s.commitPhases.Reset() @@ -370,6 +429,12 @@ func (s *StateDB) CommitStateChanges(blockNum int64, changeset []*proto.NamedCha return fmt.Errorf("commit block %d to the EVM state store: %w", blockNum, err) } } + // The undo log takes SC's view along with the block. Opened before the next commit, it describes + // exactly this block, which is what the log answers reads from until a later block replaces it. + if s.undo != nil { + s.commitPhases.SetPhase("enqueue_ss") + s.undo.ApplyBlock(blockNum, changes, s.sc.OpenView()) + } return nil } @@ -378,11 +443,27 @@ func (s *StateDB) OpenView() gigatypes.StateView { return s.sc.OpenView() } -// OpenViewAt panics. Serving a past height requires the historical state DB, which is not wired into -// StateDB. +// OpenViewAt returns a view of the state after blockNum served from the EVM state store's history, +// and false when the store does not hold blockNum or no EVM state store is open. +// +// A view over the undo log pins SC's view of the log's head and the history it reads until it is +// closed, holding back SC's flushes and pruning, so it must be closed promptly. func (s *StateDB) OpenViewAt(blockNum int64) (gigatypes.StateView, bool) { - panic(fmt.Sprintf( - "giga: OpenViewAt(%d) is not implemented: the historical state DB is not wired in", blockNum)) + switch { + case s.undo != nil: + view, ok := s.undo.OpenView(blockNum) + if !ok { + return nil, false + } + return &historicalView{height: blockNum, read: view.Get, release: view.Close}, true + case s.ss != nil: + if blockNum < s.ss.GetEarliestVersion() || blockNum > s.ss.GetLatestVersion() { + return nil, false + } + read := func(key []byte) ([]byte, error) { return s.ss.Get(evm.EVMStoreKey, blockNum, key) } + return &historicalView{height: blockNum, read: read, release: func() {}}, true + } + return nil, false } // RegisterHashListener forwards to the state commit store, which is the layer that hashes blocks and diff --git a/sei-db/state_db/giga/state_db_replay.go b/sei-db/state_db/giga/state_db_replay.go index 01ffd48573..5aeeee6abe 100644 --- a/sei-db/state_db/giga/state_db_replay.go +++ b/sei-db/state_db/giga/state_db_replay.go @@ -5,6 +5,7 @@ import ( "fmt" "time" + "github.com/sei-protocol/sei-chain/sei-db/db_engine/pebbledb/undo" "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-db/state_db/ss/evm" @@ -76,6 +77,14 @@ func (s *StateDB) discardStateAbove(wal storedWALRange, target int64) error { if !s.ssCfg.Enable { return nil } + // Records above target describe blocks that are executed again, possibly differently. The log + // needs no snapshot to drop them, and none to replay from: it holds no current state. + if usesUndoLog(s.ssCfg) { + if err := undo.DiscardStateAbove(s.ssCfg.EVMDBDirectory, s.ssCfg, target); err != nil { + return fmt.Errorf("the EVM undo-log state store cannot reach %d: %w", target, err) + } + return nil + } if _, err := evm.DiscardStateAbove( s.ssCfg, s.ssSnapshotRoot(), target, wal.first); err != nil { return fmt.Errorf("the EVM state store cannot reach %d: %w", target, err) @@ -92,7 +101,8 @@ func (s *StateDB) dropSnapshotsAbove(target int64) error { if err := flatkv.DropSnapshotsAbove(s.flatkvCfg.DataDir, target); err != nil { return fmt.Errorf("cannot roll back the state commit store to %d: %w", target, err) } - if !s.ssCfg.Enable { + // The undo log keeps no snapshots. + if !s.ssCfg.Enable || usesUndoLog(s.ssCfg) { return nil } if err := evm.DropSnapshotsAbove(s.ssSnapshotRoot(), target); err != nil { diff --git a/sei-db/state_db/giga/state_db_test.go b/sei-db/state_db/giga/state_db_test.go index 18b4c4b097..64c9b7df77 100644 --- a/sei-db/state_db/giga/state_db_test.go +++ b/sei-db/state_db/giga/state_db_test.go @@ -180,12 +180,12 @@ func TestOpenViewServesTheBlockCommittedToTheLiveStateDB(t *testing.T) { require.Equal(t, []byte("value"), value) } -// Serving a past height needs the historical state DB, which is not wired in. Answering from the live -// state DB instead would return the current block under the name of a historical one. -func TestOpenViewAtPanicsUntilTheHistoricalStateDBIsWired(t *testing.T) { +// Without an EVM state store no past height is served. Answering from the live state DB instead would +// return the current block under the name of a historical one. +func TestOpenViewAtServesNoHeightWithoutAStateStore(t *testing.T) { stateDB, _, _ := newTestStateDB(t) - require.PanicsWithValue(t, - "giga: OpenViewAt(5) is not implemented: the historical state DB is not wired in", - func() { stateDB.OpenViewAt(5) }) + view, ok := stateDB.OpenViewAt(5) + require.False(t, ok) + require.Nil(t, view) } diff --git a/sei-db/state_db/giga/types/state_change_set.go b/sei-db/state_db/giga/types/state_change_set.go new file mode 100644 index 0000000000..cbb672420f --- /dev/null +++ b/sei-db/state_db/giga/types/state_change_set.go @@ -0,0 +1,40 @@ +package types + +import "math/big" + +// TODO: Temporal copy of giga/evmonly/types.go +// Both should be merged into a single package. +type StateChangeSet struct { + Balances []BalanceChange + Nonces []NonceChange + Code []CodeChange + StorageClears []Address + Storage []StorageChange +} + +type BalanceChange struct { + Address Address + Balance *big.Int + PriorBalance *big.Int +} + +type NonceChange struct { + Address Address + Nonce uint64 + PriorNonce uint64 +} + +type CodeChange struct { + Address Address + Delete bool + Code []byte + PriorCode []byte +} + +type StorageChange struct { + Address Address + Key Hash + Delete bool + Value Hash + PriorValue Hash +} diff --git a/sei-db/state_db/giga/undo_state_db_test.go b/sei-db/state_db/giga/undo_state_db_test.go new file mode 100644 index 0000000000..dd2ed81261 --- /dev/null +++ b/sei-db/state_db/giga/undo_state_db_test.go @@ -0,0 +1,255 @@ +package giga + +import ( + "encoding/binary" + "maps" + "math/big" + "math/rand/v2" + "path/filepath" + "testing" + + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/crypto" + "github.com/stretchr/testify/require" + + "github.com/sei-protocol/sei-chain/sei-db/common/keys" + "github.com/sei-protocol/sei-chain/sei-db/config" + "github.com/sei-protocol/sei-chain/sei-db/proto" + gigatypes "github.com/sei-protocol/sei-chain/sei-db/state_db/giga/types" + flatkvconfig "github.com/sei-protocol/sei-chain/sei-db/state_db/sc/flatkv/config" +) + +// undoTestHome holds the configs a StateDB over an undo-log SS reopens from. +type undoTestHome struct { + flatkv *flatkvconfig.Config + ss config.StateStoreConfig +} + +func newUndoTestHome(t *testing.T) undoTestHome { + ssCfg := config.DefaultStateStoreConfig() + ssCfg.Enable = true + ssCfg.Backend = config.PebbleDBUndoBackend + ssCfg.EVMDBDirectory = filepath.Join(t.TempDir(), "ss") + ssCfg.ExternalPruning = true + return undoTestHome{flatkv: flatkvconfig.DefaultTestConfig(t), ss: ssCfg} +} + +func (h undoTestHome) open(t *testing.T) *StateDB { + t.Helper() + db, err := NewStateDB(t.Context(), h.flatkv, h.ss, config.CheckpointConfig{BlockInterval: 1_000}) + require.NoError(t, err) + require.NotNil(t, db.UndoSS()) + require.Nil(t, db.SS()) + return db +} + +// undoModel is the reference: every key's value after each block, as a current-state view reports it. +type undoModel struct { + states []map[string][]byte +} + +func (m *undoModel) at(height int64, key []byte) ([]byte, bool) { + v, ok := m.states[height][string(key)] + return v, ok +} + +// commitBlock commits one block writing each nonce or storage key, with the block's change set +// carrying the value each key held before it, as execution hands them over. +func (m *undoModel) commitBlock(t *testing.T, db *StateDB, writes map[string][]byte) { + t.Helper() + height := int64(len(m.states)) + prev := m.states[height-1] + next := maps.Clone(prev) + var pairs []*proto.KVPair + var changes gigatypes.StateChangeSet + for key, value := range writes { + old := prev[key] + if key[0] == nonceKey(common.Address{})[0] { + changes.Nonces = append(changes.Nonces, gigatypes.NonceChange{ + Address: common.BytesToAddress([]byte(key[1:])), Nonce: nonceOf(value), PriorNonce: nonceOf(old)}) + } else { + changes.Storage = append(changes.Storage, gigatypes.StorageChange{ + Address: common.BytesToAddress([]byte(key[1:21])), Key: common.BytesToHash([]byte(key[21:])), + Value: common.BytesToHash(value), PriorValue: common.BytesToHash(old)}) + } + pairs = append(pairs, &proto.KVPair{Key: []byte(key), Value: value}) + next[key] = value + } + m.states = append(m.states, next) + require.NoError(t, db.CommitStateChangesWithPrior(height, + []*proto.NamedChangeSet{{Name: keys.EVMStoreKey, Changeset: proto.ChangeSet{Pairs: pairs}}}, &changes)) +} + +func storageKey(addr common.Address, slot byte) []byte { + var slotHash common.Hash + slotHash[31] = slot + key := make([]byte, 1+len(addr)+len(slotHash)) + keys.PutEVMKey(key, keys.EVMKeyStorage, addr[:], slotHash[:]) + return key +} + +func nonceKey(addr common.Address) []byte { + return keys.BuildEVMKey(keys.EVMKeyNonce, addr[:]) +} + +func nonceValue(nonce uint64) []byte { + v := make([]byte, 8) + binary.BigEndian.PutUint64(v, nonce) + return v +} + +// nonceOf decodes a nonce value, 0 for none. +func nonceOf(value []byte) uint64 { + if value == nil { + return 0 + } + return binary.BigEndian.Uint64(value) +} + +func (m *undoModel) requireHistory(t *testing.T, db *StateDB, from, to int64, keyList [][]byte) { + t.Helper() + db.UndoSS().WaitForPendingWrites() + for h := from; h <= to; h++ { + view, ok := db.OpenViewAt(h) + require.True(t, ok, "height %d", h) + require.Equal(t, h, view.GetBlockHeight()) + for _, key := range keyList { + want, wantFound := m.at(h, key) + got, found := view.Get(keys.EVMStoreKey, key) + require.Equal(t, wantFound, found, "key %x at %d", key, h) + require.Equal(t, want, got, "key %x at %d", key, h) + } + view.Close() + } +} + +func TestUndoStateDBServesHistoryAcrossReopen(t *testing.T) { + home := newUndoTestHome(t) + db := home.open(t) + rng := rand.New(rand.NewPCG(1, 1)) + + addrs := []common.Address{{1}, {2}, {3}} + var keyList [][]byte + for _, addr := range addrs { + keyList = append(keyList, nonceKey(addr), storageKey(addr, 1), storageKey(addr, 2)) + } + model := &undoModel{states: []map[string][]byte{{}}} + for block := 1; block <= 30; block++ { + writes := map[string][]byte{} + for range 1 + rng.IntN(3) { + addr := addrs[rng.IntN(len(addrs))] + writes[string(nonceKey(addr))] = nonceValue(uint64(block)) + writes[string(storageKey(addr, byte(1+rng.IntN(2))))] = common.Hash{byte(block), 0xff}.Bytes() + } + model.commitBlock(t, db, writes) + } + model.requireHistory(t, db, 0, 30, keyList) + + view, ok := db.OpenViewAt(12) + require.True(t, ok) + want, _ := model.at(12, nonceKey(addrs[0])) + require.Equal(t, binary.BigEndian.Uint64(append(make([]byte, 8-len(want)), want...)), view.GetNonce(addrs[0])) + view.Close() + + _, ok = db.OpenViewAt(31) + require.False(t, ok, "a height above the applied head is not served") + require.NoError(t, db.Close()) + + // A clean close drains the log onto SC's height, so reopening keeps every block readable. + db = home.open(t) + require.Equal(t, int64(30), db.UndoSS().GetLatestVersion()) + model.requireHistory(t, db, 0, 30, keyList) + model.commitBlock(t, db, map[string][]byte{string(nonceKey(addrs[1])): nonceValue(99)}) + model.requireHistory(t, db, 25, 31, keyList) + require.NoError(t, db.Close()) +} + +func TestUndoStateDBPrunesThroughTheCollectorInterface(t *testing.T) { + home := newUndoTestHome(t) + db := home.open(t) + defer func() { require.NoError(t, db.Close()) }() + addr := common.Address{7} + model := &undoModel{states: []map[string][]byte{{}}} + for block := 1; block <= 20; block++ { + model.commitBlock(t, db, map[string][]byte{string(nonceKey(addr)): nonceValue(uint64(block))}) + } + db.UndoSS().WaitForPendingWrites() + + var pruned bool + for _, store := range db.PrunableStores() { + if store.Name() != db.UndoSS().Name() { + continue + } + require.True(t, store.ExternalPruning()) + require.NoError(t, store.PruneHistory(11)) + pruned = true + } + require.True(t, pruned, "the undo log joins the prune cycle") + require.Equal(t, int64(11), db.UndoSS().GetEarliestVersion()) + + _, ok := db.OpenViewAt(10) + require.False(t, ok, "history below the cut line is no longer served") + model.requireHistory(t, db, 11, 20, [][]byte{nonceKey(addr)}) +} + +func TestUndoStateDBRollsBackByDiscardingLaterRecords(t *testing.T) { + home := newUndoTestHome(t) + db := home.open(t) + addr, other := common.Address{9}, common.Address{10} + model := &undoModel{states: []map[string][]byte{{}}} + for block := 1; block <= 14; block++ { + writes := map[string][]byte{string(nonceKey(addr)): nonceValue(uint64(block))} + // other changes once below the rollback target and three times above it. + if block == 3 || block == 10 || block == 11 || block == 13 { + writes[string(nonceKey(other))] = nonceValue(uint64(block)) + } + model.commitBlock(t, db, writes) + } + require.NoError(t, db.Close()) + + // The rollback to 9 discards every record above it. + db, err := NewStateDBWithRollback(t.Context(), home.flatkv, home.ss, config.CheckpointConfig{BlockInterval: 1_000}, 9) + require.NoError(t, err) + defer func() { require.NoError(t, db.Close()) }() + require.Equal(t, int64(9), db.UndoSS().GetLatestVersion()) + + // The chain goes on differently from 9 and never writes other again, so a record of 11 or 13 + // left behind would show through as a change it no longer has. + model.states = model.states[:10] + for block := 10; block <= 16; block++ { + model.commitBlock(t, db, map[string][]byte{string(nonceKey(addr)): nonceValue(uint64(100 + block))}) + } + model.requireHistory(t, db, 0, 16, [][]byte{nonceKey(addr), nonceKey(other)}) +} + +// A block's change set carries each balance and code it replaced, which a read below the block serves. +func TestUndoStateDBServesPriorBalanceAndCode(t *testing.T) { + db := newUndoTestHome(t).open(t) + defer func() { require.NoError(t, db.Close()) }() + addr := common.Address{5} + oldCode, newCode := []byte{0x60, 0x00}, []byte{0x60, 0x01} + newCodeHash := crypto.Keccak256Hash(newCode) + + require.NoError(t, db.CommitStateChangesWithPrior(1, []*proto.NamedChangeSet{{Name: keys.EVMStoreKey, Changeset: proto.ChangeSet{ + Pairs: []*proto.KVPair{ + {Key: keys.BuildEVMKey(keys.EVMKeyBalance, addr[:]), Value: common.BigToHash(big.NewInt(9)).Bytes()}, + {Key: keys.BuildEVMKey(keys.EVMKeyCodeHash, addr[:]), Value: newCodeHash.Bytes()}, + {Key: keys.BuildEVMKey(keys.EVMKeyCode, addr[:]), Value: newCode}, + }}}}, &gigatypes.StateChangeSet{ + Balances: []gigatypes.BalanceChange{{Address: addr, Balance: big.NewInt(9), PriorBalance: big.NewInt(4)}}, + Code: []gigatypes.CodeChange{{Address: addr, Code: newCode, PriorCode: oldCode}}, + })) + db.UndoSS().WaitForPendingWrites() + + for height, want := range map[int64]struct { + balance int64 + code []byte + }{0: {4, oldCode}, 1: {9, newCode}} { + view, ok := db.OpenViewAt(height) + require.True(t, ok) + require.Equal(t, common.BigToHash(big.NewInt(want.balance)), view.GetBalance(addr), "height %d", height) + require.Equal(t, want.code, view.GetCode(addr), "height %d", height) + require.Equal(t, crypto.Keccak256Hash(want.code), view.GetCodeHash(addr), "height %d", height) + view.Close() + } +} From dda19d4441a76f70e37aa1e241b8fbcf7a9dde77 Mon Sep 17 00:00:00 2001 From: alrevuelta Date: Wed, 14 Oct 2026 13:18:42 +0200 Subject: [PATCH 2/3] Fix clone issues that were not outliving --- giga/evmonly/pipeline_overlay.go | 12 ++++++------ giga/evmonly/pipeline_overlay_test.go | 8 ++++++-- sei-tendermint/node/giga_config_test.go | 2 ++ sei-tendermint/node/setup.go | 1 + 4 files changed, 15 insertions(+), 8 deletions(-) diff --git a/giga/evmonly/pipeline_overlay.go b/giga/evmonly/pipeline_overlay.go index 1551795693..f0910f5e99 100644 --- a/giga/evmonly/pipeline_overlay.go +++ b/giga/evmonly/pipeline_overlay.go @@ -159,14 +159,14 @@ func (c *StateChangeSet) clone() *StateChangeSet { Storage: append([]StorageChange(nil), c.Storage...), } for i, change := range c.Balances { - cp.Balances[i] = BalanceChange{Address: change.Address, Balance: cloneBig(change.Balance)} + change.Balance = cloneBig(change.Balance) + change.PriorBalance = cloneBig(change.PriorBalance) + cp.Balances[i] = change } for i, change := range c.Code { - cp.Code[i] = CodeChange{ - Address: change.Address, - Code: append([]byte(nil), change.Code...), - Delete: change.Delete, - } + change.Code = append([]byte(nil), change.Code...) + change.PriorCode = append([]byte(nil), change.PriorCode...) + cp.Code[i] = change } return cp } diff --git a/giga/evmonly/pipeline_overlay_test.go b/giga/evmonly/pipeline_overlay_test.go index ec9bd525d5..181d0d1929 100644 --- a/giga/evmonly/pipeline_overlay_test.go +++ b/giga/evmonly/pipeline_overlay_test.go @@ -152,15 +152,19 @@ func TestOverlayReportsDeletedCodeAsAbsent(t *testing.T) { func TestCloneSurvivesTheBlockResultBeingReused(t *testing.T) { addr := common.HexToAddress("0x1") original := &StateChangeSet{ - Balances: []BalanceChange{{Address: addr, Balance: big.NewInt(5)}}, - Code: []CodeChange{{Address: addr, Code: []byte{0x01}}}, + Balances: []BalanceChange{{Address: addr, Balance: big.NewInt(5), PriorBalance: big.NewInt(3)}}, + Code: []CodeChange{{Address: addr, Code: []byte{0x01}, PriorCode: []byte{0x02}}}, } clone := original.clone() original.Balances[0].Balance.SetInt64(999) + original.Balances[0].PriorBalance.SetInt64(999) original.Code[0].Code[0] = 0xff + original.Code[0].PriorCode[0] = 0xff original.Balances = original.Balances[:0] require.Equal(t, big.NewInt(5), clone.Balances[0].Balance) + require.Equal(t, big.NewInt(3), clone.Balances[0].PriorBalance) require.Equal(t, []byte{0x01}, clone.Code[0].Code) + require.Equal(t, []byte{0x02}, clone.Code[0].PriorCode) } diff --git a/sei-tendermint/node/giga_config_test.go b/sei-tendermint/node/giga_config_test.go index b4c47d84f9..7f8911fa9c 100644 --- a/sei-tendermint/node/giga_config_test.go +++ b/sei-tendermint/node/giga_config_test.go @@ -44,6 +44,7 @@ func TestBuildGigaStorageConfigAppliesSection(t *testing.T) { Receipts: true, RollbackWindow: 42, LookbackWindow: 7, + StateStoreBackend: seidbconfig.PebbleDBUndoBackend, PruneInterval: time.Second, CheckpointTimeInterval: time.Minute, CheckpointBlockInterval: 9, @@ -54,6 +55,7 @@ func TestBuildGigaStorageConfigAppliesSection(t *testing.T) { require.True(t, got.ReceiptDBConfig.Enable) require.Equal(t, uint64(42), got.PruningConfig.RollbackWindow) require.Equal(t, int64(7), got.PruningConfig.LookbackWindow) + require.Equal(t, seidbconfig.PebbleDBUndoBackend, got.SSConfig.Backend) require.Equal(t, time.Second, got.PruningConfig.PruneInterval) require.Equal(t, time.Minute, got.CheckpointConfig.TimeInterval) require.Equal(t, int64(9), got.CheckpointConfig.BlockInterval) diff --git a/sei-tendermint/node/setup.go b/sei-tendermint/node/setup.go index b43c6d4e78..6fa8ea02ad 100644 --- a/sei-tendermint/node/setup.go +++ b/sei-tendermint/node/setup.go @@ -422,6 +422,7 @@ func buildGigaStorageConfig( storageConfig.PruningConfig.PruneInterval = storage.PruneInterval storageConfig.CheckpointConfig.TimeInterval = storage.CheckpointTimeInterval storageConfig.CheckpointConfig.BlockInterval = storage.CheckpointBlockInterval + storageConfig.WithStateStoreBackend(storage.StateStoreBackend) if resolveGigaStorageMode(nodeMode, storage.Mode) == gigaconfig.StorageModeFull { storageConfig.WithFullNodeMode() } From bf423c0489cf577dd03dc1ce45ba6fd2c8bc3a95 Mon Sep 17 00:00:00 2001 From: alrevuelta Date: Wed, 14 Oct 2026 14:59:41 +0200 Subject: [PATCH 3/3] Fix issues --- sei-db/db_engine/pebbledb/undo/db.go | 7 +++--- sei-db/db_engine/pebbledb/undo/db_test.go | 17 +++++++++---- sei-db/state_db/giga/state_db.go | 23 +++++++++++++----- sei-db/state_db/giga/state_db_test.go | 28 ++++++++++++++++++++++ sei-db/state_db/giga/undo_state_db_test.go | 10 ++++++++ 5 files changed, 72 insertions(+), 13 deletions(-) diff --git a/sei-db/db_engine/pebbledb/undo/db.go b/sei-db/db_engine/pebbledb/undo/db.go index 8e99b62f95..f08c50fe35 100644 --- a/sei-db/db_engine/pebbledb/undo/db.go +++ b/sei-db/db_engine/pebbledb/undo/db.go @@ -450,12 +450,13 @@ func (db *Database) writeRecords(changes *gigatypes.StateChangeSet, bucket, heig w.add(NonceKeyPrefix[0], change.Address[:], nil, nonZero(nonce[:])) } for _, change := range changes.Code { - var codeHash []byte + // Code is absent only when empty: zero bytes are valid code. + var code, codeHash []byte if len(change.PriorCode) != 0 { - codeHash = crypto.Keccak256(change.PriorCode) + code, codeHash = change.PriorCode, crypto.Keccak256(change.PriorCode) } w.add(CodeHashKeyPrefix[0], change.Address[:], nil, codeHash) - w.add(CodeKeyPrefix[0], change.Address[:], nil, nonZero(change.PriorCode)) + w.add(CodeKeyPrefix[0], change.Address[:], nil, code) } for _, change := range changes.Storage { w.add(StateKeyPrefix[0], change.Address[:], change.Key[:], nonZero(change.PriorValue[:])) diff --git a/sei-db/db_engine/pebbledb/undo/db_test.go b/sei-db/db_engine/pebbledb/undo/db_test.go index b3e5ae3af0..9c18871d6e 100644 --- a/sei-db/db_engine/pebbledb/undo/db_test.go +++ b/sei-db/db_engine/pebbledb/undo/db_test.go @@ -230,8 +230,8 @@ func (c *chain) stateAt(height int64, key string) ([]byte, bool) { } // apply commits one block setting (or, for a nil value, deleting) each key, and feeds its prior -// values to the store. As in the state commit store, a zero value is no value, and code carries its -// code hash. +// values to the store. As in the state commit store, a zero value is no value, except for code, which +// is absent only when empty and carries its code hash. func (c *chain) apply(changes map[string][]byte, async bool) { c.mu.Lock() prev := c.states[len(c.states)-1] @@ -239,7 +239,7 @@ func (c *chain) apply(changes map[string][]byte, async bool) { next := maps.Clone(prev) prior := map[string][]byte{} set := func(key string, value []byte) { - if nonZero(value) == nil { + if len(value) == 0 || key[0] != CodeKeyPrefix[0] && nonZero(value) == nil { delete(next, key) } else { next[key] = value @@ -249,7 +249,7 @@ func (c *chain) apply(changes map[string][]byte, async bool) { prior[key] = prev[key] set(key, value) if key[0] == CodeKeyPrefix[0] { - set(string(CodeHashKeyPrefix)+key[1:], crypto.Keccak256(nonZero(value))) + set(string(CodeHashKeyPrefix)+key[1:], crypto.Keccak256(value)) } } c.states = append(c.states, next) @@ -1107,6 +1107,15 @@ func TestEveryFamilyIsRecorded(t *testing.T) { require.Equal(t, uint64(2), latest) } +func TestZeroByteCodeIsPresent(t *testing.T) { + c := newChain(t, t.TempDir(), 10, 0) + defer func() { require.NoError(t, c.db.Close()) }() + c.apply(map[string][]byte{codeKey(contract): {0x00}}, false) + c.apply(map[string][]byte{codeKey(contract): {0x60, 0x00}}, false) + c.requireRead(1, codeKey(contract)) + c.requireRead(1, string(CodeHashKeyPrefix)+codeKey(contract)[1:]) +} + func TestEVMFamiliesReadAcrossFlushCompactionAndReopen(t *testing.T) { dir := t.TempDir() c := newChain(t, dir, 2, 0) diff --git a/sei-db/state_db/giga/state_db.go b/sei-db/state_db/giga/state_db.go index 4f089d46ac..38b5b32003 100644 --- a/sei-db/state_db/giga/state_db.go +++ b/sei-db/state_db/giga/state_db.go @@ -388,16 +388,15 @@ func (s *StateDB) PrunableStores() []controller.PrunableStore { return stores } -// CommitStateChanges is CommitStateChangesWithPrior without the block's change set, so an undo-log EVM -// state store records no history for the block. +// CommitStateChanges is CommitStateChangesWithPrior without the block's change set. It fails for a +// block that changes state when the EVM state store is an undo log, which needs the change set. func (s *StateDB) CommitStateChanges(blockNum int64, changeset []*proto.NamedChangeSet) error { return s.CommitStateChangesWithPrior(blockNum, changeset, nil) } // CommitStateChangesWithPrior writes a block to the state WAL, the state commit store and the EVM // state store, in that order. Callers must not run two commits at once. changes is the block's change -// set, which an undo-log EVM state store keeps the replaced values from; nil when the block changed no -// EVM state. +// set, which an undo-log EVM state store keeps the replaced values from; nil only when changeset is empty. // // The time each of the three takes is published under commitPhaseTimerName, split by phase, which is // what makes a slow store on the commit path attributable to that store. @@ -407,6 +406,10 @@ func (s *StateDB) CommitStateChangesWithPrior(blockNum int64, changeset []*proto // future that the WAL has no way to recognize as a mistake. return fmt.Errorf("commit block %d: block number must not be negative", blockNum) } + if s.undo != nil && changes == nil && len(changeset) != 0 { + // The undo log would advance past the block with no record of what it replaced. + return fmt.Errorf("commit block %d: the undo-log EVM state store needs the block's change set", blockNum) + } // Ends the phase in flight, so the gap until the next commit is not charged to the last store. defer s.commitPhases.Reset() @@ -447,7 +450,8 @@ func (s *StateDB) OpenView() gigatypes.StateView { // and false when the store does not hold blockNum or no EVM state store is open. // // A view over the undo log pins SC's view of the log's head and the history it reads until it is -// closed, holding back SC's flushes and pruning, so it must be closed promptly. +// closed, holding back SC's flushes and pruning, so it must be closed promptly. A view over the +// pebbledb state store pins nothing, and its reads fail once its height is pruned. func (s *StateDB) OpenViewAt(blockNum int64) (gigatypes.StateView, bool) { switch { case s.undo != nil: @@ -460,7 +464,14 @@ func (s *StateDB) OpenViewAt(blockNum int64) (gigatypes.StateView, bool) { if blockNum < s.ss.GetEarliestVersion() || blockNum > s.ss.GetLatestVersion() { return nil, false } - read := func(key []byte) ([]byte, error) { return s.ss.Get(evm.EVMStoreKey, blockNum, key) } + read := func(key []byte) ([]byte, error) { + value, err := s.ss.Get(evm.EVMStoreKey, blockNum, key) + // The view pins nothing, and a read below the earliest version finds nothing rather than failing. + if err == nil && blockNum < s.ss.GetEarliestVersion() { + return nil, fmt.Errorf("height %d was pruned", blockNum) + } + return value, err + } return &historicalView{height: blockNum, read: read, release: func() {}}, true } return nil, false diff --git a/sei-db/state_db/giga/state_db_test.go b/sei-db/state_db/giga/state_db_test.go index 64c9b7df77..2bbf8994ee 100644 --- a/sei-db/state_db/giga/state_db_test.go +++ b/sei-db/state_db/giga/state_db_test.go @@ -3,10 +3,13 @@ package giga import ( "context" "errors" + "path/filepath" "testing" + "github.com/ethereum/go-ethereum/common" "github.com/stretchr/testify/require" + "github.com/sei-protocol/sei-chain/sei-db/common/keys" "github.com/sei-protocol/sei-chain/sei-db/config" "github.com/sei-protocol/sei-chain/sei-db/proto" gigatypes "github.com/sei-protocol/sei-chain/sei-db/state_db/giga/types" @@ -189,3 +192,28 @@ func TestOpenViewAtServesNoHeightWithoutAStateStore(t *testing.T) { require.False(t, ok) require.Nil(t, view) } + +// A view over the pebbledb state store pins nothing, so once its height is pruned a read fails rather +// than reporting every key absent. +func TestSSViewFailsReadsOnceItsHeightIsPruned(t *testing.T) { + ssCfg := config.DefaultStateStoreConfig() + ssCfg.Enable = true + ssCfg.EVMDBDirectory = filepath.Join(t.TempDir(), "ss") + ssCfg.ExternalPruning = true + db, err := NewStateDB(t.Context(), flatkvconfig.DefaultTestConfig(t), ssCfg, config.CheckpointConfig{BlockInterval: 1_000}) + require.NoError(t, err) + defer func() { require.NoError(t, db.Close()) }() + addr := common.Address{1} + for height := int64(1); height <= 3; height++ { + require.NoError(t, db.CommitStateChanges(height, []*proto.NamedChangeSet{{Name: keys.EVMStoreKey, Changeset: proto.ChangeSet{ + Pairs: []*proto.KVPair{{Key: nonceKey(addr), Value: nonceValue(uint64(height))}}}}})) + } + db.SS().WaitForPendingWrites() + + view, ok := db.OpenViewAt(1) + require.True(t, ok) + defer view.Close() + require.Equal(t, uint64(1), view.GetNonce(addr)) + require.NoError(t, db.SS().PruneHistory(3)) + require.Panics(t, func() { view.GetNonce(addr) }) +} diff --git a/sei-db/state_db/giga/undo_state_db_test.go b/sei-db/state_db/giga/undo_state_db_test.go index dd2ed81261..53c06db18c 100644 --- a/sei-db/state_db/giga/undo_state_db_test.go +++ b/sei-db/state_db/giga/undo_state_db_test.go @@ -253,3 +253,13 @@ func TestUndoStateDBServesPriorBalanceAndCode(t *testing.T) { view.Close() } } + +func TestUndoStateDBRefusesABlockWithoutItsChangeSet(t *testing.T) { + db := newUndoTestHome(t).open(t) + defer func() { require.NoError(t, db.Close()) }() + changeset := []*proto.NamedChangeSet{{Name: keys.EVMStoreKey, Changeset: proto.ChangeSet{ + Pairs: []*proto.KVPair{{Key: nonceKey(common.Address{1}), Value: nonceValue(1)}}}}} + + require.Error(t, db.CommitStateChanges(1, changeset)) + require.NoError(t, db.CommitStateChanges(1, nil), "a block that changes nothing has nothing to record") +}