From a16dd0309e257204c9a95d71c8b11d540c0ca6d0 Mon Sep 17 00:00:00 2001 From: yzang2019 Date: Thu, 12 Mar 2026 17:07:18 -0700 Subject: [PATCH 1/4] Add exporter for FlatKV and CompositeSC --- sei-db/state_db/sc/composite/exporter.go | 90 +++++++ sei-db/state_db/sc/composite/importer.go | 15 +- sei-db/state_db/sc/composite/store.go | 25 +- sei-db/state_db/sc/composite/store_test.go | 236 +++++++++++++++++ sei-db/state_db/sc/flatkv/exporter.go | 209 +++++++++++++++ sei-db/state_db/sc/flatkv/exporter_test.go | 260 +++++++++++++++++++ sei-db/state_db/sc/flatkv/importer.go | 7 + sei-db/state_db/sc/flatkv/store_lifecycle.go | 20 +- 8 files changed, 852 insertions(+), 10 deletions(-) create mode 100644 sei-db/state_db/sc/composite/exporter.go create mode 100644 sei-db/state_db/sc/flatkv/exporter.go create mode 100644 sei-db/state_db/sc/flatkv/exporter_test.go diff --git a/sei-db/state_db/sc/composite/exporter.go b/sei-db/state_db/sc/composite/exporter.go new file mode 100644 index 0000000000..75650692f6 --- /dev/null +++ b/sei-db/state_db/sc/composite/exporter.go @@ -0,0 +1,90 @@ +package composite + +import ( + "errors" + + errorutils "github.com/sei-protocol/sei-chain/sei-db/common/errors" + "github.com/sei-protocol/sei-chain/sei-db/state_db/sc/types" +) + +var _ types.Exporter = (*SnapshotExporter)(nil) + +type exportPhase int + +const ( + phaseCosmos exportPhase = iota + phaseFlatKV + phaseDone +) + +// SnapshotExporter coordinates export from cosmos (memiavl) and flatKV backends. +// +// FlatKV data is exported as a separate "evm_flatkv" module appended after all +// cosmos modules complete. This keeps the two backends fully independent in the +// snapshot stream. +type SnapshotExporter struct { + cosmosExporter types.Exporter + evmExporter types.Exporter + phase exportPhase +} + +func NewExporter(cosmosExporter types.Exporter, evmExporter types.Exporter) *SnapshotExporter { + return &SnapshotExporter{ + cosmosExporter: cosmosExporter, + evmExporter: evmExporter, + phase: phaseCosmos, + } +} + +func (s *SnapshotExporter) Next() (interface{}, error) { + switch s.phase { + case phaseCosmos: + return s.nextFromCosmos() + case phaseFlatKV: + return s.nextFromFlatKV() + default: + return nil, errorutils.ErrorExportDone + } +} + +func (s *SnapshotExporter) nextFromCosmos() (interface{}, error) { + item, err := s.cosmosExporter.Next() + if err != nil { + if !errors.Is(err, errorutils.ErrorExportDone) { + return nil, err + } + + // Cosmos done. Append flatKV as a separate module. + if s.evmExporter != nil { + s.phase = phaseFlatKV + return EVMFlatKVStoreName, nil + } + + s.phase = phaseDone + return nil, errorutils.ErrorExportDone + } + return item, nil +} + +func (s *SnapshotExporter) nextFromFlatKV() (interface{}, error) { + item, err := s.evmExporter.Next() + if err != nil { + if !errors.Is(err, errorutils.ErrorExportDone) { + return nil, err + } + s.phase = phaseDone + return nil, errorutils.ErrorExportDone + } + return item, nil +} + +func (s *SnapshotExporter) Close() error { + var errCosmos, errEVM error + if s.cosmosExporter != nil { + errCosmos = s.cosmosExporter.Close() + } + if s.evmExporter != nil { + errEVM = s.evmExporter.Close() + } + return errors.Join(errCosmos, errEVM) +} diff --git a/sei-db/state_db/sc/composite/importer.go b/sei-db/state_db/sc/composite/importer.go index 4fffd5de2e..3b0b358c2d 100644 --- a/sei-db/state_db/sc/composite/importer.go +++ b/sei-db/state_db/sc/composite/importer.go @@ -34,6 +34,12 @@ func (si *SnapshotImporter) Close() error { func (si *SnapshotImporter) AddModule(name string) error { si.currentModule = name + if name == EVMFlatKVStoreName { + if si.evmImporter != nil { + return si.evmImporter.AddModule(name) + } + return nil + } if si.cosmosImporter != nil { return si.cosmosImporter.AddModule(name) } @@ -41,10 +47,13 @@ func (si *SnapshotImporter) AddModule(name string) error { } func (si *SnapshotImporter) AddNode(node *types.SnapshotNode) { + if si.currentModule == EVMFlatKVStoreName { + if si.evmImporter != nil { + si.evmImporter.AddNode(node) + } + return + } if si.cosmosImporter != nil { si.cosmosImporter.AddNode(node) } - if si.evmImporter != nil && si.currentModule == "evm" && node.Height == 0 { - si.evmImporter.AddNode(node) - } } diff --git a/sei-db/state_db/sc/composite/store.go b/sei-db/state_db/sc/composite/store.go index a2dca7a431..ad9e36738a 100644 --- a/sei-db/state_db/sc/composite/store.go +++ b/sei-db/state_db/sc/composite/store.go @@ -19,9 +19,14 @@ import ( var logger = seilog.NewLogger("db", "state-db", "sc", "composite") -// EVMStoreName is the module name for the EVM store +// EVMStoreName is the module name for the EVM store in memiavl. const EVMStoreName = "evm" +// EVMFlatKVStoreName is the module name used when exporting/importing +// EVM data from the FlatKV backend. Treated as a separate module in +// state-sync snapshots so that import routes data exclusively to FlatKV. +const EVMFlatKVStoreName = "evm_flatkv" + // For backward compatibility purpose reuse current interface var _ types.Committer = (*CompositeCommitStore)(nil) @@ -270,8 +275,22 @@ func (cs *CompositeCommitStore) Exporter(version int64) (types.Exporter, error) if version < 0 || version > math.MaxUint32 { return nil, fmt.Errorf("version %d out of range", version) } - // TODO: Add evm committer for exporter - return cs.cosmosCommitter.Exporter(version) + + cosmosExporter, err := cs.cosmosCommitter.Exporter(version) + if err != nil { + return nil, fmt.Errorf("failed to create cosmos exporter: %w", err) + } + + var evmExporter types.Exporter + if cs.evmCommitter != nil && cs.config.WriteMode == config.SplitWrite { + evmExporter, err = cs.evmCommitter.Exporter(version) + if err != nil { + _ = cosmosExporter.Close() + return nil, fmt.Errorf("failed to create evm exporter: %w", err) + } + } + + return NewExporter(cosmosExporter, evmExporter), nil } // Importer returns an importer for state sync diff --git a/sei-db/state_db/sc/composite/store_test.go b/sei-db/state_db/sc/composite/store_test.go index 43d77d5c94..8de913910e 100644 --- a/sei-db/state_db/sc/composite/store_test.go +++ b/sei-db/state_db/sc/composite/store_test.go @@ -1,13 +1,18 @@ package composite import ( + "errors" "fmt" "testing" "github.com/stretchr/testify/require" + errorutils "github.com/sei-protocol/sei-chain/sei-db/common/errors" + "github.com/sei-protocol/sei-chain/sei-db/common/evm" "github.com/sei-protocol/sei-chain/sei-db/common/metrics" "github.com/sei-protocol/sei-chain/sei-db/config" + flatkvpkg "github.com/sei-protocol/sei-chain/sei-db/state_db/sc/flatkv" + "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/sc/types" @@ -292,3 +297,234 @@ func TestReadOnlyLoadVersionSoftFailsWhenFlatKVUnavailable(t *testing.T) { val := store.Get([]byte("key1")) require.Equal(t, []byte("value1"), val) } + +// ============================================================================= +// Export / Import Tests +// ============================================================================= + +// exportedItem stores one item produced by an exporter (module name or snapshot node). +type exportedItem struct { + moduleName string + node *types.SnapshotNode +} + +// drainCompositeExporter collects all items from an exporter in stream order. +func drainCompositeExporter(t *testing.T, exp types.Exporter) []exportedItem { + t.Helper() + var items []exportedItem + for { + raw, err := exp.Next() + if err != nil { + require.True(t, errors.Is(err, errorutils.ErrorExportDone), "unexpected error: %v", err) + break + } + switch v := raw.(type) { + case string: + items = append(items, exportedItem{moduleName: v}) + case *types.SnapshotNode: + items = append(items, exportedItem{node: v}) + default: + t.Fatalf("unexpected item type %T", raw) + } + } + return items +} + +// replayImport feeds exported items into an importer. +func replayImport(t *testing.T, imp types.Importer, items []exportedItem) { + t.Helper() + for _, it := range items { + if it.moduleName != "" { + require.NoError(t, imp.AddModule(it.moduleName)) + } else { + imp.AddNode(it.node) + } + } +} + +// splitWriteConfig returns a StateCommitConfig with SplitWrite mode and +// fast snapshot intervals so that memiavl snapshots exist for the exporter. +func splitWriteConfig() config.StateCommitConfig { + cfg := config.DefaultStateCommitConfig() + cfg.WriteMode = config.SplitWrite + cfg.MemIAVLConfig.SnapshotInterval = 1 + cfg.MemIAVLConfig.SnapshotMinTimeInterval = 0 + cfg.MemIAVLConfig.AsyncCommitBuffer = 0 + return cfg +} + +func TestExportImportSplitWrite(t *testing.T) { + cfg := splitWriteConfig() + + // --- Source store: write cosmos + EVM data --- + srcDir := t.TempDir() + src := NewCompositeCommitStore(t.Context(), srcDir, cfg) + src.Initialize([]string{"bank", EVMStoreName}) + _, err := src.LoadVersion(0, false) + require.NoError(t, err) + + addr := flatkvpkg.Address{0xAA} + slot := flatkvpkg.Slot{0xBB} + storageKey := evm.BuildMemIAVLEVMKey(evm.EVMKeyStorage, + flatkvpkg.StorageKey(addr, slot)) + storageVal := []byte{0x42} + + nonceKey := evm.BuildMemIAVLEVMKey(evm.EVMKeyNonce, addr[:]) + nonceVal := []byte{0, 0, 0, 0, 0, 0, 0, 10} + + err = src.ApplyChangeSets([]*proto.NamedChangeSet{ + {Name: "bank", Changeset: iavl.ChangeSet{Pairs: []*iavl.KVPair{ + {Key: []byte("balance_alice"), Value: []byte("100")}, + }}}, + {Name: EVMStoreName, Changeset: iavl.ChangeSet{Pairs: []*iavl.KVPair{ + {Key: storageKey, Value: storageVal}, + {Key: nonceKey, Value: nonceVal}, + }}}, + }) + require.NoError(t, err) + _, err = src.Commit() + require.NoError(t, err) + + // --- Export --- + exporter, err := src.Exporter(1) + require.NoError(t, err) + items := drainCompositeExporter(t, exporter) + require.NoError(t, exporter.Close()) + require.NoError(t, src.Close()) + + // Verify export stream structure: cosmos modules first, evm_flatkv last. + var moduleNames []string + for _, it := range items { + if it.moduleName != "" { + moduleNames = append(moduleNames, it.moduleName) + } + } + require.Contains(t, moduleNames, "bank") + require.Contains(t, moduleNames, EVMFlatKVStoreName) + // evm_flatkv should be the last module + require.Equal(t, EVMFlatKVStoreName, moduleNames[len(moduleNames)-1]) + + // --- Destination store: import --- + dstDir := t.TempDir() + dst := NewCompositeCommitStore(t.Context(), dstDir, cfg) + dst.Initialize([]string{"bank", EVMStoreName}) + _, err = dst.LoadVersion(0, false) + require.NoError(t, err) + require.NoError(t, dst.Close()) + + importer, err := dst.Importer(1) + require.NoError(t, err) + replayImport(t, importer, items) + require.NoError(t, importer.Close()) + + // Reload the store at version 1 to verify + _, err = dst.LoadVersion(1, false) + require.NoError(t, err) + defer dst.Close() + + // Verify cosmos data + bankStore := dst.GetChildStoreByName("bank") + require.NotNil(t, bankStore) + require.Equal(t, []byte("100"), bankStore.Get([]byte("balance_alice"))) + + // Verify FlatKV data + require.NotNil(t, dst.evmCommitter) + got, found := dst.evmCommitter.Get(storageKey) + require.True(t, found, "storage key should exist in FlatKV after import") + require.Equal(t, storageVal, got) + + got, found = dst.evmCommitter.Get(nonceKey) + require.True(t, found, "nonce key should exist in FlatKV after import") + require.Equal(t, nonceVal, got) +} + +func TestExportCosmosOnlyHasNoFlatKVModule(t *testing.T) { + cfg := config.DefaultStateCommitConfig() + cfg.MemIAVLConfig.SnapshotInterval = 1 + cfg.MemIAVLConfig.SnapshotMinTimeInterval = 0 + cfg.MemIAVLConfig.AsyncCommitBuffer = 0 + + dir := t.TempDir() + cs := NewCompositeCommitStore(t.Context(), dir, cfg) + cs.Initialize([]string{"bank"}) + _, err := cs.LoadVersion(0, false) + require.NoError(t, err) + + err = cs.ApplyChangeSets([]*proto.NamedChangeSet{ + {Name: "bank", Changeset: iavl.ChangeSet{Pairs: []*iavl.KVPair{ + {Key: []byte("key1"), Value: []byte("val1")}, + }}}, + }) + require.NoError(t, err) + _, err = cs.Commit() + require.NoError(t, err) + + exporter, err := cs.Exporter(1) + require.NoError(t, err) + items := drainCompositeExporter(t, exporter) + require.NoError(t, exporter.Close()) + require.NoError(t, cs.Close()) + + // In cosmos_only mode, evm_flatkv should NOT appear + for _, it := range items { + require.NotEqual(t, EVMFlatKVStoreName, it.moduleName, + "evm_flatkv should not appear in cosmos_only export") + } +} + +func TestCompositeImporterRouting(t *testing.T) { + // Verify that the composite importer routes evm_flatkv exclusively + // to the evm importer and other modules only to cosmos. + var cosmosModules, evmModules []string + var cosmosNodes, evmNodes []*types.SnapshotNode + + cosmosImp := &trackingImporter{ + modules: &cosmosModules, + nodes: &cosmosNodes, + } + evmImp := &trackingImporter{ + modules: &evmModules, + nodes: &evmNodes, + } + + imp := NewImporter(cosmosImp, evmImp) + + require.NoError(t, imp.AddModule("bank")) + imp.AddNode(&types.SnapshotNode{Key: []byte("k1"), Value: []byte("v1")}) + + require.NoError(t, imp.AddModule(EVMFlatKVStoreName)) + imp.AddNode(&types.SnapshotNode{Key: []byte("k2"), Value: []byte("v2")}) + + require.NoError(t, imp.AddModule("staking")) + imp.AddNode(&types.SnapshotNode{Key: []byte("k3"), Value: []byte("v3")}) + + // bank and staking → cosmos only + require.Equal(t, []string{"bank", "staking"}, cosmosModules) + require.Len(t, cosmosNodes, 2) + require.Equal(t, []byte("k1"), cosmosNodes[0].Key) + require.Equal(t, []byte("k3"), cosmosNodes[1].Key) + + // evm_flatkv → evm only + require.Equal(t, []string{EVMFlatKVStoreName}, evmModules) + require.Len(t, evmNodes, 1) + require.Equal(t, []byte("k2"), evmNodes[0].Key) + + require.NoError(t, imp.Close()) +} + +// trackingImporter records calls for test assertions. +type trackingImporter struct { + modules *[]string + nodes *[]*types.SnapshotNode +} + +func (ti *trackingImporter) AddModule(name string) error { + *ti.modules = append(*ti.modules, name) + return nil +} + +func (ti *trackingImporter) AddNode(node *types.SnapshotNode) { + *ti.nodes = append(*ti.nodes, node) +} + +func (ti *trackingImporter) Close() error { return nil } diff --git a/sei-db/state_db/sc/flatkv/exporter.go b/sei-db/state_db/sc/flatkv/exporter.go new file mode 100644 index 0000000000..7d38ed4e8b --- /dev/null +++ b/sei-db/state_db/sc/flatkv/exporter.go @@ -0,0 +1,209 @@ +package flatkv + +import ( + "bytes" + "encoding/binary" + "fmt" + + errorutils "github.com/sei-protocol/sei-chain/sei-db/common/errors" + "github.com/sei-protocol/sei-chain/sei-db/common/evm" + dbtypes "github.com/sei-protocol/sei-chain/sei-db/db_engine/types" + "github.com/sei-protocol/sei-chain/sei-db/state_db/sc/types" +) + +var _ types.Exporter = (*KVExporter)(nil) + +type exportDBKind int + +const ( + exportDBAccount exportDBKind = iota + exportDBCode + exportDBStorage + exportDBLegacy + exportDBDone +) + +// KVExporter exports all committed EVM data from a read-only FlatKV store +// as SnapshotNode items. The caller must Close the exporter when done. +type KVExporter struct { + store *CommitStore + version int64 + + currentDB exportDBKind + currentIter dbtypes.KeyValueDBIterator + + // accountDB entries decompose into multiple snapshot nodes (nonce + codehash). + pendingNodes []*types.SnapshotNode +} + +func NewKVExporter(store *CommitStore, version int64) *KVExporter { + return &KVExporter{ + store: store, + version: version, + } +} + +func (e *KVExporter) Next() (interface{}, error) { + if len(e.pendingNodes) > 0 { + node := e.pendingNodes[0] + e.pendingNodes = e.pendingNodes[1:] + return node, nil + } + + for e.currentDB < exportDBDone { + if e.currentIter == nil { + iter, err := e.openIterForDB(e.currentDB) + if err != nil { + return nil, fmt.Errorf("open iterator for db %d: %w", e.currentDB, err) + } + if iter == nil || !iter.First() { + if iter != nil { + _ = iter.Close() + } + e.currentDB++ + continue + } + e.currentIter = iter + } + + if !e.currentIter.Valid() { + if err := e.currentIter.Error(); err != nil { + return nil, fmt.Errorf("iterator error: %w", err) + } + _ = e.currentIter.Close() + e.currentIter = nil + e.currentDB++ + continue + } + + key := bytes.Clone(e.currentIter.Key()) + value := bytes.Clone(e.currentIter.Value()) + e.currentIter.Next() + + nodes := e.convertToNodes(e.currentDB, key, value) + if len(nodes) == 0 { + continue + } + + if len(nodes) > 1 { + e.pendingNodes = nodes[1:] + } + return nodes[0], nil + } + + return nil, errorutils.ErrorExportDone +} + +func (e *KVExporter) Close() error { + if e.currentIter != nil { + _ = e.currentIter.Close() + e.currentIter = nil + } + if e.store != nil { + err := e.store.Close() + e.store = nil + return err + } + return nil +} + +func (e *KVExporter) openIterForDB(db exportDBKind) (dbtypes.KeyValueDBIterator, error) { + var kvDB dbtypes.KeyValueDB + switch db { + case exportDBAccount: + kvDB = e.store.accountDB + case exportDBCode: + kvDB = e.store.codeDB + case exportDBStorage: + kvDB = e.store.storageDB + case exportDBLegacy: + kvDB = e.store.legacyDB + default: + return nil, nil + } + if kvDB == nil { + return nil, nil + } + return kvDB.NewIter(&dbtypes.IterOptions{ + LowerBound: metaKeyLowerBound(), + }) +} + +func (e *KVExporter) convertToNodes(db exportDBKind, key, value []byte) []*types.SnapshotNode { + switch db { + case exportDBAccount: + return e.accountToNodes(key, value) + case exportDBCode: + return e.codeToNodes(key, value) + case exportDBStorage: + return e.storageToNodes(key, value) + case exportDBLegacy: + return e.legacyToNodes(key, value) + default: + return nil + } +} + +func (e *KVExporter) accountToNodes(key, value []byte) []*types.SnapshotNode { + av, err := DecodeAccountValue(value) + if err != nil { + logger.Error("skip corrupt account entry during export", + "key", fmt.Sprintf("%x", key), "err", err) + return nil + } + + var nodes []*types.SnapshotNode + + nonceKey := evm.BuildMemIAVLEVMKey(evm.EVMKeyNonce, key) + nonceValue := make([]byte, NonceLen) + binary.BigEndian.PutUint64(nonceValue, av.Nonce) + nodes = append(nodes, &types.SnapshotNode{ + Key: nonceKey, + Value: nonceValue, + Version: e.version, + Height: 0, + }) + + if av.HasCode() { + codeHashKey := evm.BuildMemIAVLEVMKey(evm.EVMKeyCodeHash, key) + codeHashValue := make([]byte, CodeHashLen) + copy(codeHashValue, av.CodeHash[:]) + nodes = append(nodes, &types.SnapshotNode{ + Key: codeHashKey, + Value: codeHashValue, + Version: e.version, + Height: 0, + }) + } + + return nodes +} + +func (e *KVExporter) codeToNodes(key, value []byte) []*types.SnapshotNode { + memiavlKey := evm.BuildMemIAVLEVMKey(evm.EVMKeyCode, key) + return []*types.SnapshotNode{{ + Key: memiavlKey, + Value: value, + Version: e.version, + Height: 0, + }} +} + +func (e *KVExporter) storageToNodes(key, value []byte) []*types.SnapshotNode { + memiavlKey := evm.BuildMemIAVLEVMKey(evm.EVMKeyStorage, key) + return []*types.SnapshotNode{{ + Key: memiavlKey, + Value: value, + Version: e.version, + Height: 0, + }} +} + +func (e *KVExporter) legacyToNodes(key, value []byte) []*types.SnapshotNode { + return []*types.SnapshotNode{{ + Key: key, + Value: value, + Version: e.version, + Height: 0, + }} +} diff --git a/sei-db/state_db/sc/flatkv/exporter_test.go b/sei-db/state_db/sc/flatkv/exporter_test.go new file mode 100644 index 0000000000..ff17609bd9 --- /dev/null +++ b/sei-db/state_db/sc/flatkv/exporter_test.go @@ -0,0 +1,260 @@ +package flatkv + +import ( + "errors" + "testing" + + "github.com/stretchr/testify/require" + + errorutils "github.com/sei-protocol/sei-chain/sei-db/common/errors" + "github.com/sei-protocol/sei-chain/sei-db/common/evm" + "github.com/sei-protocol/sei-chain/sei-db/proto" + "github.com/sei-protocol/sei-chain/sei-db/state_db/sc/types" + iavl "github.com/sei-protocol/sei-chain/sei-iavl/proto" +) + +// drainExporter collects all SnapshotNode items from an exporter. +func drainExporter(t *testing.T, exp types.Exporter) []*types.SnapshotNode { + t.Helper() + var nodes []*types.SnapshotNode + for { + item, err := exp.Next() + if err != nil { + require.True(t, errors.Is(err, errorutils.ErrorExportDone)) + break + } + node, ok := item.(*types.SnapshotNode) + require.True(t, ok, "expected *SnapshotNode, got %T", item) + nodes = append(nodes, node) + } + return nodes +} + +func TestExporterEmptyStore(t *testing.T) { + s := setupTestStore(t) + + exp, err := s.Exporter(0) + require.NoError(t, err) + defer exp.Close() + + _, err = exp.Next() + require.True(t, errors.Is(err, errorutils.ErrorExportDone)) + require.NoError(t, s.Close()) +} + +func TestExporterStorageKeys(t *testing.T) { + s := setupTestStore(t) + defer s.Close() + + addr := Address{0xAA} + slot1 := Slot{0x01} + slot2 := Slot{0x02} + val1 := []byte{0x11} + val2 := []byte{0x22} + + key1 := evm.BuildMemIAVLEVMKey(evm.EVMKeyStorage, StorageKey(addr, slot1)) + key2 := evm.BuildMemIAVLEVMKey(evm.EVMKeyStorage, StorageKey(addr, slot2)) + + require.NoError(t, s.ApplyChangeSets([]*proto.NamedChangeSet{ + {Name: "evm", Changeset: iavl.ChangeSet{Pairs: []*iavl.KVPair{ + {Key: key1, Value: val1}, + {Key: key2, Value: val2}, + }}}, + })) + commitAndCheck(t, s) + + exp, err := s.Exporter(1) + require.NoError(t, err) + nodes := drainExporter(t, exp) + require.NoError(t, exp.Close()) + + require.Len(t, nodes, 2) + for _, n := range nodes { + require.Equal(t, int64(1), n.Version) + require.Equal(t, int8(0), n.Height) + kind, _ := evm.ParseEVMKey(n.Key) + require.Equal(t, evm.EVMKeyStorage, kind) + } +} + +func TestExporterAccountKeys(t *testing.T) { + s := setupTestStore(t) + defer s.Close() + + addr := Address{0xBB} + nonceKey := evm.BuildMemIAVLEVMKey(evm.EVMKeyNonce, addr[:]) + nonceVal := []byte{0, 0, 0, 0, 0, 0, 0, 42} + + codeHashKey := evm.BuildMemIAVLEVMKey(evm.EVMKeyCodeHash, addr[:]) + codeHashVal := make([]byte, CodeHashLen) + codeHashVal[0] = 0xDE + + require.NoError(t, s.ApplyChangeSets([]*proto.NamedChangeSet{ + {Name: "evm", Changeset: iavl.ChangeSet{Pairs: []*iavl.KVPair{ + {Key: nonceKey, Value: nonceVal}, + {Key: codeHashKey, Value: codeHashVal}, + }}}, + })) + commitAndCheck(t, s) + + exp, err := s.Exporter(1) + require.NoError(t, err) + nodes := drainExporter(t, exp) + require.NoError(t, exp.Close()) + + // accountDB produces nonce + codehash nodes per account + require.Len(t, nodes, 2) + + kindMap := map[evm.EVMKeyKind]*types.SnapshotNode{} + for _, n := range nodes { + kind, _ := evm.ParseEVMKey(n.Key) + kindMap[kind] = n + } + + require.Contains(t, kindMap, evm.EVMKeyNonce) + require.Equal(t, nonceVal, kindMap[evm.EVMKeyNonce].Value) + + require.Contains(t, kindMap, evm.EVMKeyCodeHash) + require.Equal(t, codeHashVal, kindMap[evm.EVMKeyCodeHash].Value) +} + +func TestExporterCodeKeys(t *testing.T) { + s := setupTestStore(t) + defer s.Close() + + addr := Address{0xCC} + codeKey := evm.BuildMemIAVLEVMKey(evm.EVMKeyCode, addr[:]) + codeVal := []byte{0x60, 0x80, 0x60, 0x40} + + require.NoError(t, s.ApplyChangeSets([]*proto.NamedChangeSet{ + {Name: "evm", Changeset: iavl.ChangeSet{Pairs: []*iavl.KVPair{ + {Key: codeKey, Value: codeVal}, + }}}, + })) + commitAndCheck(t, s) + + exp, err := s.Exporter(1) + require.NoError(t, err) + nodes := drainExporter(t, exp) + require.NoError(t, exp.Close()) + + // code key, but also account gets a nonce entry (nonce=0) + var codeNodes []*types.SnapshotNode + for _, n := range nodes { + kind, _ := evm.ParseEVMKey(n.Key) + if kind == evm.EVMKeyCode { + codeNodes = append(codeNodes, n) + } + } + require.Len(t, codeNodes, 1) + require.Equal(t, codeVal, codeNodes[0].Value) +} + +func TestExporterRoundTrip(t *testing.T) { + s := setupTestStore(t) + defer s.Close() + + addr := Address{0xDD} + slot := Slot{0xEE} + + storageKey := evm.BuildMemIAVLEVMKey(evm.EVMKeyStorage, StorageKey(addr, slot)) + storageVal := []byte{0xFF} + nonceKey := evm.BuildMemIAVLEVMKey(evm.EVMKeyNonce, addr[:]) + nonceVal := []byte{0, 0, 0, 0, 0, 0, 0, 7} + codeKey := evm.BuildMemIAVLEVMKey(evm.EVMKeyCode, addr[:]) + codeVal := []byte{0x60, 0x80} + codeHashKey := evm.BuildMemIAVLEVMKey(evm.EVMKeyCodeHash, addr[:]) + codeHashVal := make([]byte, CodeHashLen) + codeHashVal[31] = 0xAB + + require.NoError(t, s.ApplyChangeSets([]*proto.NamedChangeSet{ + {Name: "evm", Changeset: iavl.ChangeSet{Pairs: []*iavl.KVPair{ + {Key: storageKey, Value: storageVal}, + {Key: nonceKey, Value: nonceVal}, + {Key: codeKey, Value: codeVal}, + {Key: codeHashKey, Value: codeHashVal}, + }}}, + })) + commitAndCheck(t, s) + + // --- Export --- + exp, err := s.Exporter(1) + require.NoError(t, err) + nodes := drainExporter(t, exp) + require.NoError(t, exp.Close()) + require.Greater(t, len(nodes), 0) + + // --- Import into fresh store --- + s2 := setupTestStore(t) + imp, err := s2.Importer(1) + require.NoError(t, err) + + require.NoError(t, imp.AddModule("evm_flatkv")) + for _, n := range nodes { + imp.AddNode(n) + } + require.NoError(t, imp.Close()) + + // --- Verify round-trip --- + require.Equal(t, int64(1), s2.Version()) + + got, found := s2.Get(storageKey) + require.True(t, found, "storage key should exist after import") + require.Equal(t, storageVal, got) + + got, found = s2.Get(nonceKey) + require.True(t, found, "nonce key should exist after import") + require.Equal(t, nonceVal, got) + + got, found = s2.Get(codeKey) + require.True(t, found, "code key should exist after import") + require.Equal(t, codeVal, got) + + got, found = s2.Get(codeHashKey) + require.True(t, found, "codehash key should exist after import") + require.Equal(t, codeHashVal, got) + + require.NoError(t, s2.Close()) +} + +func TestExporterReadOnlyGuard(t *testing.T) { + s := setupTestStore(t) + defer s.Close() + + commitAndCheck(t, s) + + ro, err := s.LoadVersion(0, true) + require.NoError(t, err) + defer ro.Close() + + _, err = ro.Exporter(1) + require.ErrorIs(t, err, errReadOnly) +} + +func TestExporterEOAAccountOmitsCodeHash(t *testing.T) { + s := setupTestStore(t) + defer s.Close() + + addr := Address{0xAA} + nonceKey := evm.BuildMemIAVLEVMKey(evm.EVMKeyNonce, addr[:]) + nonceVal := []byte{0, 0, 0, 0, 0, 0, 0, 1} + + // EOA: only nonce, no codehash + require.NoError(t, s.ApplyChangeSets([]*proto.NamedChangeSet{ + {Name: "evm", Changeset: iavl.ChangeSet{Pairs: []*iavl.KVPair{ + {Key: nonceKey, Value: nonceVal}, + }}}, + })) + commitAndCheck(t, s) + + exp, err := s.Exporter(1) + require.NoError(t, err) + nodes := drainExporter(t, exp) + require.NoError(t, exp.Close()) + + // EOA should only produce a nonce node (no codehash) + require.Len(t, nodes, 1) + kind, _ := evm.ParseEVMKey(nodes[0].Key) + require.Equal(t, evm.EVMKeyNonce, kind) + require.Equal(t, nonceVal, nodes[0].Value) +} diff --git a/sei-db/state_db/sc/flatkv/importer.go b/sei-db/state_db/sc/flatkv/importer.go index 6dfb7eea3c..66e8b69783 100644 --- a/sei-db/state_db/sc/flatkv/importer.go +++ b/sei-db/state_db/sc/flatkv/importer.go @@ -80,5 +80,12 @@ func (imp *KVImporter) Close() error { return fmt.Errorf("import global metadata: %w", err) } + // Write a snapshot so the imported data survives store reopen / restart. + // Import bypasses the WAL, so without a snapshot the next LoadVersion + // would clone from the pre-import snapshot and lose all imported data. + if err := imp.store.WriteSnapshot(""); err != nil { + return fmt.Errorf("import snapshot: %w", err) + } + return nil } diff --git a/sei-db/state_db/sc/flatkv/store_lifecycle.go b/sei-db/state_db/sc/flatkv/store_lifecycle.go index 0d14386d62..c91731c450 100644 --- a/sei-db/state_db/sc/flatkv/store_lifecycle.go +++ b/sei-db/state_db/sc/flatkv/store_lifecycle.go @@ -121,9 +121,21 @@ func (s *CommitStore) CleanupOrphanedReadOnlyDirs() error { return nil } -// Exporter creates an exporter for the given version. -// NOTE: Not yet implemented. Will be added with state-sync support. -// The future implementation will export each DB separately with internal key format. +// Exporter creates an exporter for the given version by opening a read-only +// clone and performing a full scan of all DBs. The returned exporter must be +// closed when done (which also closes the read-only clone). func (s *CommitStore) Exporter(version int64) (types.Exporter, error) { - return nil, fmt.Errorf("not implemented") + if s.readOnly { + return nil, errReadOnly + } + roStore, err := s.LoadVersion(version, true) + if err != nil { + return nil, fmt.Errorf("load readonly version for export: %w", err) + } + cs, ok := roStore.(*CommitStore) + if !ok { + _ = roStore.Close() + return nil, fmt.Errorf("unexpected store type from LoadVersion") + } + return NewKVExporter(cs, version), nil } From 5209b6eb25ba3c7d00819ac53fe6ddb9f9a047c8 Mon Sep 17 00:00:00 2001 From: yzang2019 Date: Thu, 12 Mar 2026 17:57:14 -0700 Subject: [PATCH 2/4] Fix test --- sei-db/state_db/sc/composite/store.go | 2 +- sei-db/state_db/sc/flatkv/exporter.go | 4 +++- sei-db/state_db/sc/flatkv/exporter_test.go | 6 +++++- 3 files changed, 9 insertions(+), 3 deletions(-) diff --git a/sei-db/state_db/sc/composite/store.go b/sei-db/state_db/sc/composite/store.go index ad9e36738a..7b577e747d 100644 --- a/sei-db/state_db/sc/composite/store.go +++ b/sei-db/state_db/sc/composite/store.go @@ -282,7 +282,7 @@ func (cs *CompositeCommitStore) Exporter(version int64) (types.Exporter, error) } var evmExporter types.Exporter - if cs.evmCommitter != nil && cs.config.WriteMode == config.SplitWrite { + if cs.evmCommitter != nil && (cs.config.WriteMode == config.SplitWrite || cs.config.WriteMode == config.DualWrite) { evmExporter, err = cs.evmCommitter.Exporter(version) if err != nil { _ = cosmosExporter.Close() diff --git a/sei-db/state_db/sc/flatkv/exporter.go b/sei-db/state_db/sc/flatkv/exporter.go index 7d38ed4e8b..c6bac6603f 100644 --- a/sei-db/state_db/sc/flatkv/exporter.go +++ b/sei-db/state_db/sc/flatkv/exporter.go @@ -24,7 +24,9 @@ const ( ) // KVExporter exports all committed EVM data from a read-only FlatKV store -// as SnapshotNode items. The caller must Close the exporter when done. +// as SnapshotNode items. Keys are emitted in memiavl EVM format so the +// importer can feed them through ApplyChangeSets unchanged. +// The caller must Close the exporter when done. type KVExporter struct { store *CommitStore version int64 diff --git a/sei-db/state_db/sc/flatkv/exporter_test.go b/sei-db/state_db/sc/flatkv/exporter_test.go index ff17609bd9..5bfca09667 100644 --- a/sei-db/state_db/sc/flatkv/exporter_test.go +++ b/sei-db/state_db/sc/flatkv/exporter_test.go @@ -138,7 +138,6 @@ func TestExporterCodeKeys(t *testing.T) { nodes := drainExporter(t, exp) require.NoError(t, exp.Close()) - // code key, but also account gets a nonce entry (nonce=0) var codeNodes []*types.SnapshotNode for _, n := range nodes { kind, _ := evm.ParseEVMKey(n.Key) @@ -177,6 +176,8 @@ func TestExporterRoundTrip(t *testing.T) { })) commitAndCheck(t, s) + srcHash := s.RootHash() + // --- Export --- exp, err := s.Exporter(1) require.NoError(t, err) @@ -214,6 +215,9 @@ func TestExporterRoundTrip(t *testing.T) { require.True(t, found, "codehash key should exist after import") require.Equal(t, codeHashVal, got) + // LtHash should match source since import recomputes it via ApplyChangeSets + require.Equal(t, srcHash, s2.RootHash()) + require.NoError(t, s2.Close()) } From bbc9d4cdeaa41dffbf0345f766b9d1a2fcf47949 Mon Sep 17 00:00:00 2001 From: yzang2019 Date: Thu, 12 Mar 2026 18:32:07 -0700 Subject: [PATCH 3/4] Add error checking --- sei-db/state_db/sc/composite/exporter.go | 14 +++++++++-- sei-db/state_db/sc/composite/store.go | 5 ++-- sei-db/state_db/sc/flatkv/exporter.go | 32 ++++++++++++++++-------- 3 files changed, 36 insertions(+), 15 deletions(-) diff --git a/sei-db/state_db/sc/composite/exporter.go b/sei-db/state_db/sc/composite/exporter.go index 75650692f6..622afa72d5 100644 --- a/sei-db/state_db/sc/composite/exporter.go +++ b/sei-db/state_db/sc/composite/exporter.go @@ -2,6 +2,7 @@ package composite import ( "errors" + "fmt" errorutils "github.com/sei-protocol/sei-chain/sei-db/common/errors" "github.com/sei-protocol/sei-chain/sei-db/state_db/sc/types" @@ -19,6 +20,10 @@ const ( // SnapshotExporter coordinates export from cosmos (memiavl) and flatKV backends. // +// Next() returns items in stream order. Each item is either: +// - string: a module name header that starts a new module section +// - *types.SnapshotNode: a leaf key/value belonging to the current module +// // FlatKV data is exported as a separate "evm_flatkv" module appended after all // cosmos modules complete. This keeps the two backends fully independent in the // snapshot stream. @@ -28,12 +33,17 @@ type SnapshotExporter struct { phase exportPhase } -func NewExporter(cosmosExporter types.Exporter, evmExporter types.Exporter) *SnapshotExporter { +// NewExporter creates a composite exporter. cosmosExporter must not be nil. +// evmExporter may be nil when FlatKV is not active. +func NewExporter(cosmosExporter types.Exporter, evmExporter types.Exporter) (*SnapshotExporter, error) { + if cosmosExporter == nil { + return nil, fmt.Errorf("cosmosExporter must not be nil") + } return &SnapshotExporter{ cosmosExporter: cosmosExporter, evmExporter: evmExporter, phase: phaseCosmos, - } + }, nil } func (s *SnapshotExporter) Next() (interface{}, error) { diff --git a/sei-db/state_db/sc/composite/store.go b/sei-db/state_db/sc/composite/store.go index 7b577e747d..a7bccc0613 100644 --- a/sei-db/state_db/sc/composite/store.go +++ b/sei-db/state_db/sc/composite/store.go @@ -290,7 +290,7 @@ func (cs *CompositeCommitStore) Exporter(version int64) (types.Exporter, error) } } - return NewExporter(cosmosExporter, evmExporter), nil + return NewExporter(cosmosExporter, evmExporter) } // Importer returns an importer for state sync @@ -303,7 +303,8 @@ func (cs *CompositeCommitStore) Importer(version int64) (types.Importer, error) if cs.evmCommitter != nil { evmImporter, err = cs.evmCommitter.Importer(version) if err != nil { - return nil, err + _ = cosmosImporter.Close() + return nil, fmt.Errorf("failed to create evm importer: %w", err) } } compositeImporter := NewImporter(cosmosImporter, evmImporter) diff --git a/sei-db/state_db/sc/flatkv/exporter.go b/sei-db/state_db/sc/flatkv/exporter.go index c6bac6603f..56cbedb037 100644 --- a/sei-db/state_db/sc/flatkv/exporter.go +++ b/sei-db/state_db/sc/flatkv/exporter.go @@ -26,6 +26,11 @@ const ( // KVExporter exports all committed EVM data from a read-only FlatKV store // as SnapshotNode items. Keys are emitted in memiavl EVM format so the // importer can feed them through ApplyChangeSets unchanged. +// +// All emitted SnapshotNodes carry the export version and Height=0 (leaf). +// This intentionally flattens version history: state sync only transfers the +// latest state at a given height, not the full edit history. +// // The caller must Close the exporter when done. type KVExporter struct { store *CommitStore @@ -82,7 +87,10 @@ func (e *KVExporter) Next() (interface{}, error) { value := bytes.Clone(e.currentIter.Value()) e.currentIter.Next() - nodes := e.convertToNodes(e.currentDB, key, value) + nodes, err := e.convertToNodes(e.currentDB, key, value) + if err != nil { + return nil, err + } if len(nodes) == 0 { continue } @@ -109,6 +117,10 @@ func (e *KVExporter) Close() error { return nil } +// openIterForDB returns an iterator over all user data in the given DB, +// excluding internal metadata. metaKeyLowerBound() returns {0x00, 0x00} which +// skips the single-byte DBLocalMetaKey ({0x00}) while including all user keys +// (minimum 20 bytes for an EVM address). func (e *KVExporter) openIterForDB(db exportDBKind) (dbtypes.KeyValueDBIterator, error) { var kvDB dbtypes.KeyValueDB switch db { @@ -131,27 +143,25 @@ func (e *KVExporter) openIterForDB(db exportDBKind) (dbtypes.KeyValueDBIterator, }) } -func (e *KVExporter) convertToNodes(db exportDBKind, key, value []byte) []*types.SnapshotNode { +func (e *KVExporter) convertToNodes(db exportDBKind, key, value []byte) ([]*types.SnapshotNode, error) { switch db { case exportDBAccount: return e.accountToNodes(key, value) case exportDBCode: - return e.codeToNodes(key, value) + return e.codeToNodes(key, value), nil case exportDBStorage: - return e.storageToNodes(key, value) + return e.storageToNodes(key, value), nil case exportDBLegacy: - return e.legacyToNodes(key, value) + return e.legacyToNodes(key, value), nil default: - return nil + return nil, nil } } -func (e *KVExporter) accountToNodes(key, value []byte) []*types.SnapshotNode { +func (e *KVExporter) accountToNodes(key, value []byte) ([]*types.SnapshotNode, error) { av, err := DecodeAccountValue(value) if err != nil { - logger.Error("skip corrupt account entry during export", - "key", fmt.Sprintf("%x", key), "err", err) - return nil + return nil, fmt.Errorf("corrupt account entry key=%x: %w", key, err) } var nodes []*types.SnapshotNode @@ -178,7 +188,7 @@ func (e *KVExporter) accountToNodes(key, value []byte) []*types.SnapshotNode { }) } - return nodes + return nodes, nil } func (e *KVExporter) codeToNodes(key, value []byte) []*types.SnapshotNode { From 873f066adde6332134ae49f9a19279f4690d87d5 Mon Sep 17 00:00:00 2001 From: yzang2019 Date: Thu, 12 Mar 2026 18:54:35 -0700 Subject: [PATCH 4/4] Add more unit test --- sei-db/state_db/sc/flatkv/exporter_test.go | 63 ++++++++++++++++++++++ 1 file changed, 63 insertions(+) diff --git a/sei-db/state_db/sc/flatkv/exporter_test.go b/sei-db/state_db/sc/flatkv/exporter_test.go index 5bfca09667..a4ba6edfff 100644 --- a/sei-db/state_db/sc/flatkv/exporter_test.go +++ b/sei-db/state_db/sc/flatkv/exporter_test.go @@ -2,6 +2,7 @@ package flatkv import ( "errors" + "path/filepath" "testing" "github.com/stretchr/testify/require" @@ -262,3 +263,65 @@ func TestExporterEOAAccountOmitsCodeHash(t *testing.T) { require.Equal(t, evm.EVMKeyNonce, kind) require.Equal(t, nonceVal, nodes[0].Value) } + +func TestImportSurvivesReopen(t *testing.T) { + src := setupTestStore(t) + defer src.Close() + + addr := Address{0xDD} + slot := Slot{0xEE} + + storageKey := evm.BuildMemIAVLEVMKey(evm.EVMKeyStorage, StorageKey(addr, slot)) + storageVal := []byte{0xFF} + nonceKey := evm.BuildMemIAVLEVMKey(evm.EVMKeyNonce, addr[:]) + nonceVal := []byte{0, 0, 0, 0, 0, 0, 0, 7} + + require.NoError(t, src.ApplyChangeSets([]*proto.NamedChangeSet{ + {Name: "evm", Changeset: iavl.ChangeSet{Pairs: []*iavl.KVPair{ + {Key: storageKey, Value: storageVal}, + {Key: nonceKey, Value: nonceVal}, + }}}, + })) + commitAndCheck(t, src) + srcHash := src.RootHash() + + exp, err := src.Exporter(1) + require.NoError(t, err) + nodes := drainExporter(t, exp) + require.NoError(t, exp.Close()) + + // Import into a fresh store at a known directory. + dir := t.TempDir() + dbPath := filepath.Join(dir, flatkvRootDir) + + s1 := NewCommitStore(t.Context(), dbPath, DefaultConfig()) + _, err = s1.LoadVersion(0, false) + require.NoError(t, err) + + imp, err := s1.Importer(1) + require.NoError(t, err) + require.NoError(t, imp.AddModule("evm_flatkv")) + for _, n := range nodes { + imp.AddNode(n) + } + require.NoError(t, imp.Close()) + require.NoError(t, s1.Close()) + + // Reopen from the same directory — data must survive. + s2 := NewCommitStore(t.Context(), dbPath, DefaultConfig()) + _, err = s2.LoadVersion(1, false) + require.NoError(t, err) + defer s2.Close() + + require.Equal(t, int64(1), s2.Version()) + + got, found := s2.Get(storageKey) + require.True(t, found, "storage key must survive reopen") + require.Equal(t, storageVal, got) + + got, found = s2.Get(nonceKey) + require.True(t, found, "nonce key must survive reopen") + require.Equal(t, nonceVal, got) + + require.Equal(t, srcHash, s2.RootHash()) +}