diff --git a/vms/evm/sync/customrawdb/db.go b/vms/evm/sync/customrawdb/db.go new file mode 100644 index 000000000000..a5e8a7dcf816 --- /dev/null +++ b/vms/evm/sync/customrawdb/db.go @@ -0,0 +1,36 @@ +// Copyright (C) 2019-2025, Ava Labs, Inc. All rights reserved. +// See the file LICENSE for licensing terms. + +package customrawdb + +import ( + "errors" + + "github.com/ava-labs/libevm/core/rawdb" + "github.com/ava-labs/libevm/ethdb" +) + +var ( + // ErrEntryNotFound indicates the requested key/value was not present in the DB. + ErrEntryNotFound = errors.New("entry not found") + // errStateSchemeConflict indicates the provided state scheme conflicts with what is on disk. + errStateSchemeConflict = errors.New("state scheme conflict") + // FirewoodScheme is the scheme for the Firewood storage scheme. + FirewoodScheme = "firewood" +) + +// ParseStateScheme parses the state scheme from the provided string. +func ParseStateScheme(provided string, db ethdb.Database) (string, error) { + // Check for custom scheme + if provided == FirewoodScheme { + if diskScheme := rawdb.ReadStateScheme(db); diskScheme != "" { + // Valid scheme on db mismatched + return "", errStateSchemeConflict + } + // If no conflicting scheme is found, is valid. + return FirewoodScheme, nil + } + + // Check for valid eth scheme + return rawdb.ParseStateScheme(provided, db) +} diff --git a/vms/evm/sync/customrawdb/db_test.go b/vms/evm/sync/customrawdb/db_test.go new file mode 100644 index 000000000000..060a4e44e6f1 --- /dev/null +++ b/vms/evm/sync/customrawdb/db_test.go @@ -0,0 +1,31 @@ +// Copyright (C) 2019-2025, Ava Labs, Inc. All rights reserved. +// See the file LICENSE for licensing terms. + +package customrawdb + +import ( + "testing" + + "github.com/ava-labs/libevm/core/rawdb" + "github.com/stretchr/testify/require" +) + +func TestParseStateScheme(t *testing.T) { + db := rawdb.NewMemoryDatabase() + + // Provided Firewood on empty disk -> allowed. + scheme, err := ParseStateScheme(FirewoodScheme, db) + require.NoError(t, err) + require.Equal(t, FirewoodScheme, scheme) + + // Simulate disk has non-empty path scheme by writing persistent state id. + rawdb.WritePersistentStateID(db, 1) + scheme2, _ := ParseStateScheme(FirewoodScheme, db) + require.Empty(t, scheme2) + + // Pass-through to rawdb for non-Firewood using a fresh empty DB. + db2 := rawdb.NewMemoryDatabase() + scheme, err = ParseStateScheme("hash", db2) + require.NoError(t, err) + require.Equal(t, "hash", scheme) +} diff --git a/vms/evm/sync/customrawdb/markers.go b/vms/evm/sync/customrawdb/markers.go new file mode 100644 index 000000000000..62a77c8a246f --- /dev/null +++ b/vms/evm/sync/customrawdb/markers.go @@ -0,0 +1,235 @@ +// Copyright (C) 2019-2025, Ava Labs, Inc. All rights reserved. +// See the file LICENSE for licensing terms. + +package customrawdb + +import ( + "encoding/json" + "errors" + "fmt" + "time" + + "github.com/ava-labs/libevm/common" + "github.com/ava-labs/libevm/core/rawdb" + "github.com/ava-labs/libevm/ethdb" + "github.com/ava-labs/libevm/params" + "github.com/ava-labs/libevm/rlp" +) + +var ( + // errInvalidData indicates the stored value exists but is malformed or undecodable. + errInvalidData = errors.New("invalid data") + errFailedToGetUpgradeConfig = errors.New("failed to get upgrade config") + errFailedToMarshalUpgradeConfig = errors.New("failed to marshal upgrade config") + + upgradeConfigPrefix = []byte("upgrade-config-") + // offlinePruningKey tracks runs of offline pruning. + offlinePruningKey = []byte("OfflinePruning") + // populateMissingTriesKey tracks runs of trie backfills. + populateMissingTriesKey = []byte("PopulateMissingTries") + // pruningDisabledKey tracks whether the node has ever run in archival mode + // to ensure that a user does not accidentally corrupt an archival node. + pruningDisabledKey = []byte("PruningDisabled") + // acceptorTipKey tracks the tip of the last accepted block that has been fully processed. + acceptorTipKey = []byte("AcceptorTipKey") + // snapshotBlockHashKey tracks the block hash of the last snapshot. + snapshotBlockHashKey = []byte("SnapshotBlockHash") +) + +// WriteOfflinePruning writes a time marker of the last attempt to run offline pruning. +// The marker is written when offline pruning completes and is deleted when the node +// is started successfully with offline pruning disabled. This ensures users must +// disable offline pruning and start their node successfully between runs of offline +// pruning. +func WriteOfflinePruning(db ethdb.KeyValueStore, ts time.Time) error { + return writeTimeMarker(db, offlinePruningKey, ts) +} + +// ReadOfflinePruning reads the most recent timestamp of an attempt to run offline +// pruning if present. +func ReadOfflinePruning(db ethdb.KeyValueStore) (time.Time, error) { + return readTimeMarker(db, offlinePruningKey) +} + +// DeleteOfflinePruning deletes any marker of the last attempt to run offline pruning. +func DeleteOfflinePruning(db ethdb.KeyValueStore) error { + return db.Delete(offlinePruningKey) +} + +// WritePopulateMissingTries writes a marker for the current attempt to populate +// missing tries. +func WritePopulateMissingTries(db ethdb.KeyValueStore, ts time.Time) error { + return writeTimeMarker(db, populateMissingTriesKey, ts) +} + +// ReadPopulateMissingTries reads the most recent timestamp of an attempt to +// re-populate missing trie nodes. +func ReadPopulateMissingTries(db ethdb.KeyValueStore) (time.Time, error) { + return readTimeMarker(db, populateMissingTriesKey) +} + +// DeletePopulateMissingTries deletes any marker of the last attempt to +// re-populate missing trie nodes. +func DeletePopulateMissingTries(db ethdb.KeyValueStore) error { + return db.Delete(populateMissingTriesKey) +} + +// WritePruningDisabled writes a marker to track whether the node has ever run +// with pruning disabled. +func WritePruningDisabled(db ethdb.KeyValueStore) error { + return db.Put(pruningDisabledKey, nil) +} + +// HasPruningDisabled returns true if there is a marker present indicating that +// the node has run with pruning disabled at some point. +func HasPruningDisabled(db ethdb.KeyValueStore) (bool, error) { + return db.Has(pruningDisabledKey) +} + +// WriteAcceptorTip writes `hash` as the last accepted block that has been fully processed. +func WriteAcceptorTip(db ethdb.KeyValueWriter, hash common.Hash) error { + return db.Put(acceptorTipKey, hash[:]) +} + +// ReadAcceptorTip reads the hash of the last accepted block that was fully processed. +// If there is no value present (the index is being initialized for the first time), then the +// empty hash is returned. +func ReadAcceptorTip(db ethdb.KeyValueReader) (common.Hash, error) { + ok, err := db.Has(acceptorTipKey) + if err != nil { + return common.Hash{}, err + } + if !ok { + return common.Hash{}, ErrEntryNotFound + } + h, err := db.Get(acceptorTipKey) + if err != nil { + return common.Hash{}, err + } + if len(h) != common.HashLength { + return common.Hash{}, fmt.Errorf("%w: length %d", errInvalidData, len(h)) + } + return common.BytesToHash(h), nil +} + +// ReadChainConfig retrieves the consensus settings based on the given genesis hash. +// The provided `upgradeConfig` (any JSON-unmarshalable type) will be populated if present on disk. +func ReadChainConfig[T any](db ethdb.KeyValueReader, hash common.Hash, upgradeConfig T) (*params.ChainConfig, error) { + config := rawdb.ReadChainConfig(db, hash) + if config == nil { + return nil, ErrEntryNotFound + } + + upgrade, err := db.Get(upgradeConfigKey(hash)) + if err != nil { + return nil, fmt.Errorf("%w: %w", errFailedToGetUpgradeConfig, err) + } + + if len(upgrade) == 0 { + return config, nil + } + + if err := json.Unmarshal(upgrade, &upgradeConfig); err != nil { + return nil, errInvalidData + } + + return config, nil +} + +// WriteChainConfig writes the chain config settings to the database. +// The provided `upgradeConfig` (any JSON-marshalable type) will be stored alongside the chain config. +func WriteChainConfig[T any](db ethdb.KeyValueWriter, hash common.Hash, config *params.ChainConfig, upgradeConfig T) error { + rawdb.WriteChainConfig(db, hash, config) + if config == nil { + return nil + } + + data, err := json.Marshal(upgradeConfig) + if err != nil { + return fmt.Errorf("%w: %w", errFailedToMarshalUpgradeConfig, err) + } + if err := db.Put(upgradeConfigKey(hash), data); err != nil { + return err + } + return nil +} + +// NewAccountSnapshotsIterator returns an iterator for walking all of the accounts in the snapshot +func NewAccountSnapshotsIterator(db ethdb.Iteratee) ethdb.Iterator { + it := db.NewIterator(rawdb.SnapshotAccountPrefix, nil) + keyLen := len(rawdb.SnapshotAccountPrefix) + common.HashLength + return rawdb.NewKeyLengthIterator(it, keyLen) +} + +// ReadSnapshotBlockHash retrieves the hash of the block whose state is contained in +// the persisted snapshot. +func ReadSnapshotBlockHash(db ethdb.KeyValueReader) (common.Hash, error) { + ok, err := db.Has(snapshotBlockHashKey) + if err != nil { + return common.Hash{}, err + } + if !ok { + return common.Hash{}, ErrEntryNotFound + } + + data, err := db.Get(snapshotBlockHashKey) + if err != nil { + return common.Hash{}, err + } + if len(data) != common.HashLength { + return common.Hash{}, fmt.Errorf("%w: length %d", errInvalidData, len(data)) + } + return common.BytesToHash(data), nil +} + +// WriteSnapshotBlockHash stores the root of the block whose state is contained in +// the persisted snapshot. +func WriteSnapshotBlockHash(db ethdb.KeyValueWriter, blockHash common.Hash) error { + return db.Put(snapshotBlockHashKey, blockHash[:]) +} + +// DeleteSnapshotBlockHash deletes the hash of the block whose state is contained in +// the persisted snapshot. Since snapshots are not immutable, this method can +// be used during updates, so a crash or failure will mark the entire snapshot +// invalid. +func DeleteSnapshotBlockHash(db ethdb.KeyValueWriter) error { + return db.Delete(snapshotBlockHashKey) +} + +func writeTimeMarker(db ethdb.KeyValueStore, key []byte, ts time.Time) error { + data, err := rlp.EncodeToBytes(uint64(ts.Unix())) + if err != nil { + return err + } + return db.Put(key, data) +} + +func readTimeMarker(db ethdb.KeyValueStore, key []byte) (time.Time, error) { + // Check existence first to map missing marker to a stable sentinel error. + ok, err := db.Has(key) + if err != nil { + return time.Time{}, err + } + if !ok { + return time.Time{}, ErrEntryNotFound + } + + data, err := db.Get(key) + if err != nil { + return time.Time{}, err + } + if len(data) == 0 { + return time.Time{}, ErrEntryNotFound + } + + var unix uint64 + if err := rlp.DecodeBytes(data, &unix); err != nil { + return time.Time{}, fmt.Errorf("%w: %w", errInvalidData, err) + } + + return time.Unix(int64(unix), 0), nil +} + +func upgradeConfigKey(hash common.Hash) []byte { + return append(upgradeConfigPrefix, hash.Bytes()...) +} diff --git a/vms/evm/sync/customrawdb/markers_test.go b/vms/evm/sync/customrawdb/markers_test.go new file mode 100644 index 000000000000..6e17b7bd1280 --- /dev/null +++ b/vms/evm/sync/customrawdb/markers_test.go @@ -0,0 +1,242 @@ +// Copyright (C) 2019-2025, Ava Labs, Inc. All rights reserved. +// See the file LICENSE for licensing terms. + +package customrawdb + +import ( + "math/big" + "testing" + "time" + + "github.com/ava-labs/libevm/common" + "github.com/ava-labs/libevm/core/rawdb" + "github.com/ava-labs/libevm/params" + "github.com/stretchr/testify/require" +) + +func TestOfflinePruning(t *testing.T) { + db := rawdb.NewMemoryDatabase() + + // Not present initially. + _, err := ReadOfflinePruning(db) + require.ErrorIs(t, err, ErrEntryNotFound) + + // Write marker and read back fixed time. + fixed := time.Unix(1_700_000_000, 0) + require.NoError(t, WriteOfflinePruning(db, fixed)) + ts, err := ReadOfflinePruning(db) + require.NoError(t, err) + require.Equal(t, fixed.Unix(), ts.Unix()) + + // Delete marker. + require.NoError(t, DeleteOfflinePruning(db)) + _, err = ReadOfflinePruning(db) + require.ErrorIs(t, err, ErrEntryNotFound) +} + +func TestPopulateMissingTries(t *testing.T) { + db := rawdb.NewMemoryDatabase() + + // Not present initially. + _, err := ReadPopulateMissingTries(db) + require.ErrorIs(t, err, ErrEntryNotFound) + + // Write marker and read back fixed time. + fixed := time.Unix(1_700_000_000, 0) + require.NoError(t, WritePopulateMissingTries(db, fixed)) + ts, err := ReadPopulateMissingTries(db) + require.NoError(t, err) + require.Equal(t, fixed.Unix(), ts.Unix()) + + // Delete marker. + require.NoError(t, DeletePopulateMissingTries(db)) + _, err = ReadPopulateMissingTries(db) + require.ErrorIs(t, err, ErrEntryNotFound) +} + +func TestOfflinePruning_BadEncoding(t *testing.T) { + db := rawdb.NewMemoryDatabase() + // Write invalid RLP bytes (0xB8 indicates a long string length with missing payload). + require.NoError(t, db.Put(offlinePruningKey, []byte{0xB8})) + _, err := ReadOfflinePruning(db) + require.ErrorIs(t, err, errInvalidData) +} + +func TestPopulateMissingTries_BadEncoding(t *testing.T) { + db := rawdb.NewMemoryDatabase() + // Write invalid RLP bytes (0xB8 indicates a long string length with missing payload). + require.NoError(t, db.Put(populateMissingTriesKey, []byte{0xB8})) + _, err := ReadPopulateMissingTries(db) + require.ErrorIs(t, err, errInvalidData) +} + +func TestPruningDisabledFlag(t *testing.T) { + db := rawdb.NewMemoryDatabase() + + ok, err := HasPruningDisabled(db) + require.NoError(t, err) + require.False(t, ok) + + require.NoError(t, WritePruningDisabled(db)) + + ok, err = HasPruningDisabled(db) + require.NoError(t, err) + require.True(t, ok) +} + +func TestReadAcceptorTip_InvalidLength(t *testing.T) { + db := rawdb.NewMemoryDatabase() + // Write an invalid value under acceptor tip key (wrong length). + require.NoError(t, db.Put(acceptorTipKey, []byte("short"))) + _, err := ReadAcceptorTip(db) + require.ErrorIs(t, err, errInvalidData) +} + +func TestWriteAcceptorTip(t *testing.T) { + tests := []struct { + name string + writes []common.Hash + want common.Hash + wantErr error + }{ + { + name: "none", + writes: nil, + want: common.Hash{}, + wantErr: ErrEntryNotFound, + }, + { + name: "single_write", + writes: []common.Hash{common.HexToHash("0xabc1")}, + want: common.HexToHash("0xabc1"), + }, + { + name: "overwrite", + writes: []common.Hash{common.HexToHash("0xabc1"), common.HexToHash("0xabc2")}, + want: common.HexToHash("0xabc2"), + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + db := rawdb.NewMemoryDatabase() + for _, h := range tc.writes { + require.NoError(t, WriteAcceptorTip(db, h)) + } + tip, err := ReadAcceptorTip(db) + require.ErrorIs(t, err, tc.wantErr) + require.Equal(t, tc.want, tip) + }) + } +} + +func TestSnapshotBlockHashReadWriteDelete(t *testing.T) { + db := rawdb.NewMemoryDatabase() + + // Initially empty. + got, err := ReadSnapshotBlockHash(db) + require.ErrorIs(t, err, ErrEntryNotFound) + require.Equal(t, common.Hash{}, got) + + // Write and read back. + want := common.HexToHash("0xdeadbeef") + require.NoError(t, WriteSnapshotBlockHash(db, want)) + got, err = ReadSnapshotBlockHash(db) + require.NoError(t, err) + require.Equal(t, want, got) + + // Delete and verify empty. + require.NoError(t, DeleteSnapshotBlockHash(db)) + got, err = ReadSnapshotBlockHash(db) + require.ErrorIs(t, err, ErrEntryNotFound) + require.Equal(t, common.Hash{}, got) +} + +func TestNewAccountSnapshotsIterator(t *testing.T) { + db := rawdb.NewMemoryDatabase() + + // Keys that match and don't match the iterator length filter. + a1 := common.HexToHash("0x01") + a2 := common.HexToHash("0x02") + key1 := append(append([]byte{}, rawdb.SnapshotAccountPrefix...), a1.Bytes()...) + key2 := append(append([]byte{}, rawdb.SnapshotAccountPrefix...), a2.Bytes()...) + // Non-matching: extra byte appended. + bad := append(append([]byte{}, rawdb.SnapshotAccountPrefix...), append(a1.Bytes(), 0x00)...) + + require.NoError(t, db.Put(key1, []byte("v1"))) + require.NoError(t, db.Put(key2, []byte("v2"))) + require.NoError(t, db.Put(bad, []byte("nope"))) + + it := NewAccountSnapshotsIterator(db) + defer it.Release() + count := 0 + for it.Next() { + count++ + } + require.NoError(t, it.Error()) + require.Equal(t, 2, count) +} + +func TestSnapshotBlockHash_InvalidLength(t *testing.T) { + db := rawdb.NewMemoryDatabase() + // Write wrong length value and assert invalid encoding. + require.NoError(t, db.Put(snapshotBlockHashKey, []byte("short"))) + _, err := ReadSnapshotBlockHash(db) + require.ErrorIs(t, err, errInvalidData) +} + +func TestChainConfigCases(t *testing.T) { + type upgrade struct { + X int `json:"x"` + } + + tests := []struct { + name string + cfg *params.ChainConfig + inputUpgrade *upgrade // nil => no overwrite + wantErr error + }{ + { + name: "valid_upgrade", + cfg: ¶ms.ChainConfig{ChainID: big.NewInt(1)}, + inputUpgrade: &upgrade{X: 7}, + }, + { + name: "nil_config", + wantErr: ErrEntryNotFound, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + db := rawdb.NewMemoryDatabase() + h := common.HexToHash("0x100") + + require.NoError(t, WriteChainConfig(db, h, tc.cfg, upgrade{X: 0})) + if tc.inputUpgrade != nil && tc.cfg != nil { + require.NoError(t, WriteChainConfig(db, h, tc.cfg, *tc.inputUpgrade)) + } + + var out upgrade + _, err := ReadChainConfig(db, h, &out) + require.ErrorIs(t, err, tc.wantErr) + if tc.wantErr == nil { + require.Equal(t, *tc.inputUpgrade, out) + } + }) + } +} + +func TestReadChainConfig_InvalidUpgradeJSONReturnsNil(t *testing.T) { + db := rawdb.NewMemoryDatabase() + hash := common.HexToHash("0xbeef") + // Write a valid base chain config. + rawdb.WriteChainConfig(db, hash, ¶ms.ChainConfig{}) + // Write invalid upgrade JSON. + require.NoError(t, db.Put(upgradeConfigKey(hash), []byte("{"))) + + var out struct{} + got, err := ReadChainConfig(db, hash, &out) + require.ErrorIs(t, err, errInvalidData) + require.Nil(t, got) +} diff --git a/vms/evm/sync/customrawdb/sync_progress.go b/vms/evm/sync/customrawdb/sync_progress.go new file mode 100644 index 000000000000..7c7cdcd9027d --- /dev/null +++ b/vms/evm/sync/customrawdb/sync_progress.go @@ -0,0 +1,234 @@ +// Copyright (C) 2019-2025, Ava Labs, Inc. All rights reserved. +// See the file LICENSE for licensing terms. + +package customrawdb + +import ( + "encoding/binary" + + "github.com/ava-labs/libevm/common" + "github.com/ava-labs/libevm/core/rawdb" + "github.com/ava-labs/libevm/ethdb" + + "github.com/ava-labs/avalanchego/utils/wrappers" +) + +var ( + // syncRootKey indicates the root of the main account trie currently being synced. + syncRootKey = []byte("sync_root") + // syncStorageTriesPrefix + trie root + account hash indicates a storage trie must be fetched for the account. + syncStorageTriesPrefix = []byte("sync_storage") + // syncSegmentsPrefix + trie root + 32-byte start key indicates the trie at root has a segment starting at the specified key. + syncSegmentsPrefix = []byte("sync_segments") + // CodeToFetchPrefix + code hash -> empty value tracks the outstanding code hashes we need to fetch. + CodeToFetchPrefix = []byte("CP") + + // === State sync progress key lengths === + syncStorageTriesKeyLength = len(syncStorageTriesPrefix) + 2*common.HashLength + syncSegmentsKeyLength = len(syncSegmentsPrefix) + 2*common.HashLength + codeToFetchKeyLength = len(CodeToFetchPrefix) + common.HashLength + + // === State sync metadata === + syncPerformedPrefix = []byte("sync_performed") + // syncPerformedKeyLength is the length of the key for the sync performed metadata key, + // and is equal to [syncPerformedPrefix] + block number as uint64. + syncPerformedKeyLength = len(syncPerformedPrefix) + wrappers.LongLen +) + +// ReadSyncRoot reads the root corresponding to the main trie of an in-progress +// sync and returns common.Hash{} if no in-progress sync was found. +func ReadSyncRoot(db ethdb.KeyValueReader) (common.Hash, error) { + ok, err := db.Has(syncRootKey) + if err != nil { + return common.Hash{}, err + } + if !ok { + return common.Hash{}, ErrEntryNotFound + } + root, err := db.Get(syncRootKey) + if err != nil { + return common.Hash{}, err + } + return common.BytesToHash(root), nil +} + +// WriteSyncRoot writes root as the root of the main trie of the in-progress sync. +func WriteSyncRoot(db ethdb.KeyValueWriter, root common.Hash) error { + return db.Put(syncRootKey, root[:]) +} + +// WriteCodeToFetch adds a marker that we need to fetch the code for `hash`. +func WriteCodeToFetch(db ethdb.KeyValueWriter, codeHash common.Hash) error { + return db.Put(codeToFetchKey(codeHash), nil) +} + +// DeleteCodeToFetch removes the marker that the code corresponding to `hash` needs to be fetched. +func DeleteCodeToFetch(db ethdb.KeyValueWriter, codeHash common.Hash) error { + return db.Delete(codeToFetchKey(codeHash)) +} + +// NewCodeToFetchIterator returns a KeyLength iterator over all code +// hashes that are pending syncing. It is the caller's responsibility to +// parse the key and call Release on the returned iterator. +func NewCodeToFetchIterator(db ethdb.Iteratee) ethdb.Iterator { + return rawdb.NewKeyLengthIterator( + db.NewIterator(CodeToFetchPrefix, nil), + codeToFetchKeyLength, + ) +} + +func codeToFetchKey(codeHash common.Hash) []byte { + codeToFetchKey := make([]byte, codeToFetchKeyLength) + copy(codeToFetchKey, CodeToFetchPrefix) + copy(codeToFetchKey[len(CodeToFetchPrefix):], codeHash[:]) + return codeToFetchKey +} + +// NewSyncSegmentsIterator returns a KeyLength iterator over all trie segments +// added for root. It is the caller's responsibility to parse the key and call +// Release on the returned iterator. +func NewSyncSegmentsIterator(db ethdb.Iteratee, root common.Hash) ethdb.Iterator { + segmentsPrefix := make([]byte, len(syncSegmentsPrefix)+common.HashLength) + copy(segmentsPrefix, syncSegmentsPrefix) + copy(segmentsPrefix[len(syncSegmentsPrefix):], root[:]) + + return rawdb.NewKeyLengthIterator( + db.NewIterator(segmentsPrefix, nil), + syncSegmentsKeyLength, + ) +} + +// WriteSyncSegment adds a trie segment for root at the given start position. +func WriteSyncSegment(db ethdb.KeyValueWriter, root common.Hash, start common.Hash) error { + // packs root and account into a key for storage in db. + bytes := make([]byte, syncSegmentsKeyLength) + copy(bytes, syncSegmentsPrefix) + copy(bytes[len(syncSegmentsPrefix):], root[:]) + copy(bytes[len(syncSegmentsPrefix)+common.HashLength:], start.Bytes()) + return db.Put(bytes, []byte{0x01}) +} + +// ClearSyncSegments removes segment markers for root from db +func ClearSyncSegments(db ethdb.KeyValueStore, root common.Hash) error { + segmentsPrefix := make([]byte, len(syncSegmentsPrefix)+common.HashLength) + copy(segmentsPrefix, syncSegmentsPrefix) + copy(segmentsPrefix[len(syncSegmentsPrefix):], root[:]) + return clearPrefix(db, segmentsPrefix, syncSegmentsKeyLength) +} + +// ClearAllSyncSegments removes all segment markers from db +func ClearAllSyncSegments(db ethdb.KeyValueStore) error { + return clearPrefix(db, syncSegmentsPrefix, syncSegmentsKeyLength) +} + +// ParseSyncSegmentKey returns the root and start position for a trie segment +// key returned from NewSyncSegmentsIterator. +func ParseSyncSegmentKey(keyBytes []byte) (common.Hash, []byte) { + keyBytes = keyBytes[len(syncSegmentsPrefix):] // skip prefix + root := common.BytesToHash(keyBytes[:common.HashLength]) + start := keyBytes[common.HashLength:] + return root, start +} + +// NewSyncStorageTriesIterator returns a KeyLength iterator over all storage tries +// added for syncing (beginning at seek). It is the caller's responsibility to parse +// the key and call Release on the returned iterator. +func NewSyncStorageTriesIterator(db ethdb.Iteratee, seek []byte) ethdb.Iterator { + return rawdb.NewKeyLengthIterator(db.NewIterator(syncStorageTriesPrefix, seek), syncStorageTriesKeyLength) +} + +// WriteSyncStorageTrie adds a storage trie for account (with the given root) to be synced. +func WriteSyncStorageTrie(db ethdb.KeyValueWriter, root common.Hash, account common.Hash) error { + bytes := make([]byte, 0, syncStorageTriesKeyLength) + bytes = append(bytes, syncStorageTriesPrefix...) + bytes = append(bytes, root[:]...) + bytes = append(bytes, account[:]...) + return db.Put(bytes, []byte{0x01}) +} + +// ClearSyncStorageTrie removes all storage trie accounts (with the given root) from db. +// Intended for use when the trie with root has completed syncing. +func ClearSyncStorageTrie(db ethdb.KeyValueStore, root common.Hash) error { + accountsPrefix := make([]byte, len(syncStorageTriesPrefix)+common.HashLength) + copy(accountsPrefix, syncStorageTriesPrefix) + copy(accountsPrefix[len(syncStorageTriesPrefix):], root[:]) + return clearPrefix(db, accountsPrefix, syncStorageTriesKeyLength) +} + +// ClearAllSyncStorageTries removes all storage tries added for syncing from db +func ClearAllSyncStorageTries(db ethdb.KeyValueStore) error { + return clearPrefix(db, syncStorageTriesPrefix, syncStorageTriesKeyLength) +} + +// ParseSyncStorageTrieKey returns the root and account for a storage trie +// key returned from NewSyncStorageTriesIterator. +func ParseSyncStorageTrieKey(keyBytes []byte) (common.Hash, common.Hash) { + keyBytes = keyBytes[len(syncStorageTriesPrefix):] // skip prefix + root := common.BytesToHash(keyBytes[:common.HashLength]) + account := common.BytesToHash(keyBytes[common.HashLength:]) + return root, account +} + +// WriteSyncPerformed logs an entry in `db` indicating the VM state synced to `blockNumber`. +func WriteSyncPerformed(db ethdb.KeyValueWriter, blockNumber uint64) error { + syncPerformedPrefixLen := len(syncPerformedPrefix) + bytes := make([]byte, syncPerformedPrefixLen+wrappers.LongLen) + copy(bytes[:syncPerformedPrefixLen], syncPerformedPrefix) + binary.BigEndian.PutUint64(bytes[syncPerformedPrefixLen:], blockNumber) + return db.Put(bytes, []byte{0x01}) +} + +// NewSyncPerformedIterator returns an iterator over all block numbers the VM +// has state synced to. +func NewSyncPerformedIterator(db ethdb.Iteratee) ethdb.Iterator { + return rawdb.NewKeyLengthIterator(db.NewIterator(syncPerformedPrefix, nil), syncPerformedKeyLength) +} + +// ParseSyncPerformedKey returns the block number from keys the iterator returned +// from NewSyncPerformedIterator. +func ParseSyncPerformedKey(key []byte) uint64 { + return binary.BigEndian.Uint64(key[len(syncPerformedPrefix):]) +} + +// GetLatestSyncPerformed returns the latest block number state synced performed to. +func GetLatestSyncPerformed(db ethdb.Iteratee) (uint64, error) { + it := NewSyncPerformedIterator(db) + defer it.Release() + + var latestSyncPerformed uint64 + for it.Next() { + syncPerformed := ParseSyncPerformedKey(it.Key()) + if syncPerformed > latestSyncPerformed { + latestSyncPerformed = syncPerformed + } + } + return latestSyncPerformed, it.Error() +} + +// clearPrefix removes all keys in db that begin with prefix and match an +// expected key length. `keyLen` must include the length of the prefix. +func clearPrefix(db ethdb.KeyValueStore, prefix []byte, keyLen int) error { + it := db.NewIterator(prefix, nil) + defer it.Release() + + batch := db.NewBatch() + for it.Next() { + key := common.CopyBytes(it.Key()) + if len(key) != keyLen { + continue + } + if err := batch.Delete(key); err != nil { + return err + } + if batch.ValueSize() > ethdb.IdealBatchSize { + if err := batch.Write(); err != nil { + return err + } + batch.Reset() + } + } + if err := it.Error(); err != nil { + return err + } + return batch.Write() +} diff --git a/vms/evm/sync/customrawdb/sync_progress_test.go b/vms/evm/sync/customrawdb/sync_progress_test.go new file mode 100644 index 000000000000..54374620d562 --- /dev/null +++ b/vms/evm/sync/customrawdb/sync_progress_test.go @@ -0,0 +1,414 @@ +// Copyright (C) 2019-2025, Ava Labs, Inc. All rights reserved. +// See the file LICENSE for licensing terms. + +package customrawdb + +import ( + "math/big" + "slices" + "testing" + + "github.com/ava-labs/libevm/common" + "github.com/ava-labs/libevm/core/rawdb" + "github.com/ava-labs/libevm/ethdb" + "github.com/ava-labs/libevm/params" + "github.com/stretchr/testify/require" + + "github.com/ava-labs/avalanchego/utils/set" +) + +func TestClearAllSyncSegments(t *testing.T) { + db := rawdb.NewMemoryDatabase() + // add a key that should be cleared + require.NoError(t, WriteSyncSegment(db, common.Hash{1}, common.Hash{})) + + // add a key that should not be cleared + key := slices.Concat(syncSegmentsPrefix, []byte("foo")) + require.NoError(t, db.Put(key, []byte("bar"))) + + require.NoError(t, ClearAllSyncSegments(db)) + + // No well-formed segment keys should remain. + iter := rawdb.NewKeyLengthIterator(db.NewIterator(syncSegmentsPrefix, nil), syncSegmentsKeyLength) + keys := mapIterator(t, iter, common.CopyBytes) + require.Empty(t, keys) + // The malformed key should still be present. + has, err := db.Has(key) + require.NoError(t, err) + require.True(t, has) +} + +func TestWriteReadSyncRoot(t *testing.T) { + db := rawdb.NewMemoryDatabase() + + // No root written yet + root, err := ReadSyncRoot(db) + require.ErrorIs(t, err, ErrEntryNotFound) + require.Zero(t, root) + + // Write and read back + want := common.HexToHash("0x01") + require.NoError(t, WriteSyncRoot(db, want)) + got, err := ReadSyncRoot(db) + require.NoError(t, err) + require.Equal(t, want, got) +} + +func TestCodeToFetchIteratorAndDelete(t *testing.T) { + db := rawdb.NewMemoryDatabase() + + h1 := common.HexToHash("0x11") + h2 := common.HexToHash("0x22") + + require.NoError(t, WriteCodeToFetch(db, h1)) + require.NoError(t, WriteCodeToFetch(db, h2)) + + // Insert a malformed key that should be ignored by the iterator (wrong length) + bad := append(append([]byte{}, CodeToFetchPrefix...), append(h1.Bytes(), 0x00)...) + require.NoError(t, db.Put(bad, []byte("x"))) + + // Collect hashes from iterator and assert presence. + vals := mapIterator(t, NewCodeToFetchIterator(db), func(key []byte) common.Hash { + return common.BytesToHash(key[len(CodeToFetchPrefix):]) + }) + + seen := set.Of(vals...) + require.Contains(t, seen, h1) + require.Contains(t, seen, h2) + + // Delete one and confirm only one remains + require.NoError(t, DeleteCodeToFetch(db, h1)) + iter := rawdb.NewKeyLengthIterator(db.NewIterator(CodeToFetchPrefix, nil), codeToFetchKeyLength) + keys := mapIterator(t, iter, common.CopyBytes) + require.Len(t, keys, 1) +} + +func TestSyncSegmentsIteratorUnpackAndClear(t *testing.T) { + db := rawdb.NewMemoryDatabase() + rootA := common.HexToHash("0xaaa") + rootB := common.HexToHash("0xbbb") + start1 := common.HexToHash("0x01") + start2 := common.HexToHash("0x02") + start3 := common.HexToHash("0x03") + + require.NoError(t, WriteSyncSegment(db, rootA, start1)) + require.NoError(t, WriteSyncSegment(db, rootA, start2)) + require.NoError(t, WriteSyncSegment(db, rootB, start3)) + + // Iterate only over rootA and assert exact keys. + keys := mapIterator(t, NewSyncSegmentsIterator(db, rootA), common.CopyBytes) + expectedA := [][]byte{buildSegmentKey(rootA, start1), buildSegmentKey(rootA, start2)} + require.ElementsMatch(t, keys, expectedA) + + // Clear only rootA. + require.NoError(t, ClearSyncSegments(db, rootA)) + keys = mapIterator(t, NewSyncSegmentsIterator(db, rootA), common.CopyBytes) + require.Empty(t, keys) + + // RootB remains. + keys = mapIterator(t, NewSyncSegmentsIterator(db, rootB), common.CopyBytes) + expectedB := [][]byte{buildSegmentKey(rootB, start3)} + require.ElementsMatch(t, keys, expectedB) +} + +func TestStorageTriesIteratorUnpackAndClear(t *testing.T) { + db := rawdb.NewMemoryDatabase() + root := common.HexToHash("0xabc") + acct1 := common.HexToHash("0x01") + acct2 := common.HexToHash("0x02") + + require.NoError(t, WriteSyncStorageTrie(db, root, acct1)) + require.NoError(t, WriteSyncStorageTrie(db, root, acct2)) + + keys := mapIterator(t, NewSyncStorageTriesIterator(db, nil), common.CopyBytes) + expected := [][]byte{buildStorageTrieKey(root, acct1), buildStorageTrieKey(root, acct2)} + require.ElementsMatch(t, keys, expected) + + require.NoError(t, ClearSyncStorageTrie(db, root)) + keys = mapIterator(t, NewSyncStorageTriesIterator(db, nil), common.CopyBytes) + require.Empty(t, keys) +} + +func TestClearAllSyncStorageTries(t *testing.T) { + db := rawdb.NewMemoryDatabase() + root := common.HexToHash("0xabc") + // Keys that should be cleared + require.NoError(t, WriteSyncStorageTrie(db, root, common.HexToHash("0x01"))) + require.NoError(t, WriteSyncStorageTrie(db, root, common.HexToHash("0x02"))) + // Key that should not be cleared due to wrong length. + bad := make([]byte, 0, len(syncStorageTriesPrefix)+2*common.HashLength+1) + bad = append(bad, syncStorageTriesPrefix...) + bad = append(bad, root.Bytes()...) + bad = append(bad, common.HexToHash("0xff").Bytes()...) + bad = append(bad, byte(0x00)) + require.NoError(t, db.Put(bad, []byte("x"))) + + require.NoError(t, ClearAllSyncStorageTries(db)) + + // No well-formed storage trie keys should remain. + iter := rawdb.NewKeyLengthIterator(db.NewIterator(syncStorageTriesPrefix, nil), syncStorageTriesKeyLength) + keys := mapIterator(t, iter, common.CopyBytes) + require.Empty(t, keys) + // The malformed key should still be present. + has, err := db.Has(bad) + require.NoError(t, err) + require.True(t, has) +} + +func TestClear_NoKeys(t *testing.T) { + root := common.HexToHash("0xabc") + tests := []struct { + name string + clear func(db ethdb.KeyValueStore) error + iter func(db ethdb.Iteratee) ethdb.Iterator + }{ + { + name: "segments_no_keys", + clear: func(db ethdb.KeyValueStore) error { return ClearSyncSegments(db, root) }, + iter: func(db ethdb.Iteratee) ethdb.Iterator { return NewSyncSegmentsIterator(db, root) }, + }, + { + name: "storage_no_keys", + clear: func(db ethdb.KeyValueStore) error { return ClearSyncStorageTrie(db, root) }, + iter: func(db ethdb.Iteratee) ethdb.Iterator { return NewSyncStorageTriesIterator(db, nil) }, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + db := rawdb.NewMemoryDatabase() + + require.NoError(t, tc.clear(db)) + + it := tc.iter(db) + require.Empty(t, mapIterator(t, it, common.CopyBytes)) + require.NoError(t, it.Error()) + }) + } +} + +func TestSyncPerformedAndLatest(t *testing.T) { + db := rawdb.NewMemoryDatabase() + + require.NoError(t, WriteSyncPerformed(db, 10)) + require.NoError(t, WriteSyncPerformed(db, 20)) + require.NoError(t, WriteSyncPerformed(db, 15)) + + // Iterator yields all + vals := mapIterator(t, NewSyncPerformedIterator(db), ParseSyncPerformedKey) + + require.Equal(t, []uint64{10, 15, 20}, vals) + + // Latest is max + latest, err := GetLatestSyncPerformed(db) + require.NoError(t, err) + require.Equal(t, uint64(20), latest) +} + +func TestGetLatestSyncPerformedEmpty(t *testing.T) { + db := rawdb.NewMemoryDatabase() + latest, err := GetLatestSyncPerformed(db) + require.NoError(t, err) + require.Zero(t, latest) +} + +func TestChainConfigReadWriteWithUpgrade(t *testing.T) { + db := rawdb.NewMemoryDatabase() + type upgradeCfg struct { + X int `json:"x"` + } + + hash := common.HexToHash("0xcafe") + cfg := ¶ms.ChainConfig{ChainID: big.NewInt(123)} + require.NoError(t, WriteChainConfig(db, hash, cfg, upgradeCfg{X: 7})) + + var out upgradeCfg + gotCfg, err := ReadChainConfig(db, hash, &out) + require.NoError(t, err) + require.NotNil(t, gotCfg) + require.Equal(t, cfg.ChainID, gotCfg.ChainID) + require.Equal(t, 7, out.X) +} + +func TestChainConfigNilDoesNotWriteUpgrade(t *testing.T) { + db := rawdb.NewMemoryDatabase() + hash := common.HexToHash("0xadd") + // Passing nil config should not write upgrade bytes + require.NoError(t, WriteChainConfig(db, hash, nil, struct{}{})) + + ok, err := db.Has(upgradeConfigKey(hash)) + require.NoError(t, err) + require.False(t, ok) +} + +func TestSyncPerformedLatestCases(t *testing.T) { + tests := []struct { + name string + writes []uint64 + want uint64 + }{ + { + name: "empty", + want: 0, + }, + { + name: "increasing", + writes: []uint64{1, 2, 3}, + want: 3, + }, + { + name: "unsorted", + writes: []uint64{10, 5, 7}, + want: 10, + }, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + db := rawdb.NewMemoryDatabase() + for _, n := range tc.writes { + require.NoError(t, WriteSyncPerformed(db, n)) + } + latest, err := GetLatestSyncPerformed(db) + require.NoError(t, err) + require.Equal(t, tc.want, latest) + }) + } +} + +func TestSyncSegmentsByRootTable(t *testing.T) { + tests := []struct { + name string + root common.Hash + starts []common.Hash + }{ + { + name: "segments_multiple_starts", + root: common.HexToHash("0xaaa"), + starts: []common.Hash{common.HexToHash("0x1"), common.HexToHash("0x2")}, + }, + { + name: "segments_single_start", + root: common.HexToHash("0xbbb"), + starts: []common.Hash{common.HexToHash("0x3")}, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + db := rawdb.NewMemoryDatabase() + for _, s := range tc.starts { + require.NoError(t, WriteSyncSegment(db, tc.root, s)) + } + got := mapIterator(t, NewSyncSegmentsIterator(db, tc.root), func(k []byte) common.Hash { + _, start := ParseSyncSegmentKey(k) + return common.BytesToHash(start) + }) + require.ElementsMatch(t, tc.starts, got) + }) + } +} + +func TestSyncStorageTriesByRootTable(t *testing.T) { + tests := []struct { + name string + root common.Hash + accounts []common.Hash + }{ + { + name: "storage_multiple_accounts", + root: common.HexToHash("0xabc"), + accounts: []common.Hash{common.HexToHash("0x1"), common.HexToHash("0x2")}, + }, + { + name: "storage_single_account", + root: common.HexToHash("0xdef"), + accounts: []common.Hash{common.HexToHash("0x3")}, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + db := rawdb.NewMemoryDatabase() + for _, a := range tc.accounts { + require.NoError(t, WriteSyncStorageTrie(db, tc.root, a)) + } + got := mapIterator(t, NewSyncStorageTriesIterator(db, nil), func(k []byte) common.Hash { + _, a := ParseSyncStorageTrieKey(k) + return a + }) + require.ElementsMatch(t, tc.accounts, got) + }) + } +} + +func TestCodeToFetchCases(t *testing.T) { + h1 := common.HexToHash("0x1") + h2 := common.HexToHash("0x2") + h3 := common.HexToHash("0x3") + + tests := []struct { + name string + hashes []common.Hash + delIdx int // -1 => no delete + want int + }{ + { + name: "none", + delIdx: -1, + want: 0, + }, + { + name: "three_keep", + hashes: []common.Hash{h1, h2, h3}, + delIdx: -1, + want: 3, + }, + { + name: "three_delete_one", + hashes: []common.Hash{h1, h2, h3}, + delIdx: 1, + want: 2, + }, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + db := rawdb.NewMemoryDatabase() + for _, h := range tc.hashes { + require.NoError(t, WriteCodeToFetch(db, h)) + } + if tc.delIdx >= 0 { + require.NoError(t, DeleteCodeToFetch(db, tc.hashes[tc.delIdx])) + } + iter := rawdb.NewKeyLengthIterator(db.NewIterator(CodeToFetchPrefix, nil), codeToFetchKeyLength) + keys := mapIterator(t, iter, common.CopyBytes) + require.Len(t, keys, tc.want) + }) + } +} + +func mapIterator[T any](t *testing.T, it ethdb.Iterator, f func([]byte) T) []T { + t.Helper() + defer it.Release() + var out []T + for it.Next() { + out = append(out, f(it.Key())) + } + require.NoError(t, it.Error()) + return out +} + +func buildSegmentKey(root, start common.Hash) []byte { + return buildKey(syncSegmentsPrefix, root, start, syncSegmentsKeyLength) +} + +func buildStorageTrieKey(root, account common.Hash) []byte { + return buildKey(syncStorageTriesPrefix, root, account, syncStorageTriesKeyLength) +} + +func buildKey(prefix []byte, h1, h2 common.Hash, keyLen int) []byte { + b := make([]byte, keyLen) + copy(b, prefix) + copy(b[len(prefix):], h1[:]) + copy(b[len(prefix)+common.HashLength:], h2[:]) + return b +}