diff --git a/core/state/database.go b/core/state/database.go index f4f095512e..d05f59e70f 100644 --- a/core/state/database.go +++ b/core/state/database.go @@ -28,6 +28,7 @@ package state import ( + "github.com/ava-labs/coreth/triedb/firewood" ethstate "github.com/ava-labs/libevm/core/state" "github.com/ava-labs/libevm/ethdb" "github.com/ava-labs/libevm/triedb" @@ -38,14 +39,26 @@ type ( Trie = ethstate.Trie ) -func NewDatabase(db ethdb.Database) ethstate.Database { +func NewDatabase(db ethdb.Database) Database { return ethstate.NewDatabase(db) } -func NewDatabaseWithConfig(db ethdb.Database, config *triedb.Config) ethstate.Database { - return ethstate.NewDatabaseWithConfig(db, config) +func NewDatabaseWithConfig(db ethdb.Database, config *triedb.Config) Database { + coredb := ethstate.NewDatabaseWithConfig(db, config) + return wrapIfFirewood(coredb) } -func NewDatabaseWithNodeDB(db ethdb.Database, triedb *triedb.Database) ethstate.Database { - return ethstate.NewDatabaseWithNodeDB(db, triedb) +func NewDatabaseWithNodeDB(db ethdb.Database, triedb *triedb.Database) Database { + coredb := ethstate.NewDatabaseWithNodeDB(db, triedb) + return wrapIfFirewood(coredb) +} +func wrapIfFirewood(db Database) Database { + fw, ok := db.TrieDB().Backend().(*firewood.Database) + if !ok { + return db + } + return &firewoodAccessorDb{ + Database: db, + fw: fw, + } } diff --git a/core/state/database_test.go b/core/state/database_test.go new file mode 100644 index 0000000000..5101f1f738 --- /dev/null +++ b/core/state/database_test.go @@ -0,0 +1,367 @@ +// Copyright (C) 2019-2025, Ava Labs, Inc. All rights reserved. +// See the file LICENSE for licensing terms. +package state + +import ( + "encoding/binary" + "math/rand" + "path/filepath" + "slices" + "testing" + + "github.com/ava-labs/coreth/triedb/firewood" + "github.com/ava-labs/coreth/triedb/hashdb" + "github.com/ava-labs/libevm/common" + "github.com/ava-labs/libevm/core/rawdb" + "github.com/ava-labs/libevm/core/types" + "github.com/ava-labs/libevm/crypto" + "github.com/ava-labs/libevm/libevm/stateconf" + "github.com/ava-labs/libevm/trie/trienode" + "github.com/ava-labs/libevm/triedb" + "github.com/holiman/uint256" + "github.com/stretchr/testify/require" +) + +const ( + commit byte = iota + createAccount + updateAccount + deleteAccount + addStorage + updateStorage + deleteStorage + maxStep +) + +var ( + stepMap = map[byte]string{ + commit: "commit", + createAccount: "createAccount", + updateAccount: "updateAccount", + deleteAccount: "deleteAccount", + addStorage: "addStorage", + updateStorage: "updateStorage", + deleteStorage: "deleteStorage", + } +) + +type fuzzState struct { + require *require.Assertions + + // current state + currentAddrs []common.Address + currentStorageInputIndices map[common.Address]uint64 + inputCounter uint64 + blockNumber uint64 + + // pending changes to be committed + merkleTries []*merkleTrie +} +type merkleTrie struct { + name string + ethDatabase Database + accountTrie Trie + openStorageTries map[common.Address]Trie + lastRoot common.Hash +} + +func newFuzzState(t *testing.T) *fuzzState { + r := require.New(t) + + hashState := NewDatabaseWithConfig( + rawdb.NewMemoryDatabase(), + &triedb.Config{ + DBOverride: hashdb.Defaults.BackendConstructor, + }) + ethRoot := types.EmptyRootHash + hashTr, err := hashState.OpenTrie(ethRoot) + r.NoError(err) + t.Cleanup(func() { + r.NoError(hashState.TrieDB().Close()) + }) + + firewoodMemdb := rawdb.NewMemoryDatabase() + fwCfg := firewood.Defaults + fwCfg.FilePath = filepath.Join(t.TempDir(), "firewood") // Use a temporary directory for the Firewood + firewoodState := NewDatabaseWithConfig( + firewoodMemdb, + &triedb.Config{ + DBOverride: fwCfg.BackendConstructor, + }, + ) + fwTr, err := firewoodState.OpenTrie(ethRoot) + r.NoError(err) + t.Cleanup(func() { + r.NoError(firewoodState.TrieDB().Close()) + }) + + return &fuzzState{ + merkleTries: []*merkleTrie{ + &merkleTrie{ + name: "hash", + ethDatabase: hashState, + accountTrie: hashTr, + openStorageTries: make(map[common.Address]Trie), + lastRoot: ethRoot, + }, + &merkleTrie{ + name: "firewood", + ethDatabase: firewoodState, + accountTrie: fwTr, + openStorageTries: make(map[common.Address]Trie), + lastRoot: ethRoot, + }, + }, + currentStorageInputIndices: make(map[common.Address]uint64), + require: r, + } +} + +// commit writes the pending changes to both tries and clears the pending changes +func (fs *fuzzState) commit() { + for _, tr := range fs.merkleTries { + mergedNodeSet := trienode.NewMergedNodeSet() + for addr, str := range tr.openStorageTries { + accountStateRoot, set, err := str.Commit(false) + fs.require.NoError(err, "failed to commit storage trie for account %s in %s", addr.Hex(), tr.name) + // A no-op change returns a nil set, which will cause merge to panic. + if set != nil { + fs.require.NoError(mergedNodeSet.Merge(set), "failed to merge storage trie nodeset for account %s in %s", addr.Hex(), tr.name) + } + + acc, err := tr.accountTrie.GetAccount(addr) + fs.require.NoError(err, "failed to get account %s in %s", addr.Hex(), tr.name) + // If the account was deleted, we can skip updating the account's + // state root. + fs.require.NotNil(acc, "account %s is nil in %s", addr.Hex(), tr.name) + + acc.Root = accountStateRoot + fs.require.NoError(tr.accountTrie.UpdateAccount(addr, acc), "failed to update account %s in %s", addr.Hex(), tr.name) + } + + updatedRoot, set, err := tr.accountTrie.Commit(true) + fs.require.NoError(err, "failed to commit account trie in %s", tr.name) + + // A no-op change returns a nil set, which will cause merge to panic. + if set != nil { + fs.require.NoError(mergedNodeSet.Merge(set), "failed to merge account trie nodeset in %s", tr.name) + } + + // HashDB/PathDB only allows updating the triedb if there have been changes. + if _, ok := tr.ethDatabase.TrieDB().Backend().(*firewood.Database); ok { + triedbopt := stateconf.WithTrieDBUpdatePayload(common.Hash{byte(int64(fs.blockNumber - 1))}, common.Hash{byte(int64(fs.blockNumber))}) + fs.require.NoError(tr.ethDatabase.TrieDB().Update(updatedRoot, tr.lastRoot, fs.blockNumber, mergedNodeSet, nil, triedbopt), "failed to update triedb in %s", tr.name) + tr.lastRoot = updatedRoot + } else if updatedRoot != tr.lastRoot { + fs.require.NoError(tr.ethDatabase.TrieDB().Update(updatedRoot, tr.lastRoot, fs.blockNumber, mergedNodeSet, nil), "failed to update triedb in %s", tr.name) + tr.lastRoot = updatedRoot + } + tr.openStorageTries = make(map[common.Address]Trie) + fs.require.NoError(tr.ethDatabase.TrieDB().Commit(updatedRoot, true), + "failed to commit %s: expected hashdb root %s", tr.name, fs.merkleTries[0].lastRoot.Hex()) + tr.accountTrie, err = tr.ethDatabase.OpenTrie(tr.lastRoot) + fs.require.NoError(err, "failed to reopen account trie for %s", tr.name) + } + fs.blockNumber++ + + // After computing the new root for each trie, we can confirm that the hashing matches + expectedRoot := fs.merkleTries[0].lastRoot + for i, tr := range fs.merkleTries[1:] { + fs.require.Equalf(expectedRoot, tr.lastRoot, + "root mismatch for %s: expected %x, got %x (trie index %d)", + tr.name, expectedRoot.Hex(), tr.lastRoot.Hex(), i, + ) + } +} + +// createAccount generates a new, unique account and adds it to both tries and the tracked +// current state. +func (fs *fuzzState) createAccount() { + fs.inputCounter++ + addr := common.BytesToAddress(crypto.Keccak256Hash(binary.BigEndian.AppendUint64(nil, fs.inputCounter)).Bytes()) + acc := &types.StateAccount{ + Nonce: 1, + Balance: uint256.NewInt(100), + Root: types.EmptyRootHash, + CodeHash: types.EmptyCodeHash[:], + } + fs.currentAddrs = append(fs.currentAddrs, addr) + + for _, tr := range fs.merkleTries { + fs.require.NoError(tr.accountTrie.UpdateAccount(addr, acc), "failed to create account %s in %s", addr.Hex(), tr.name) + } +} + +// selectAccount returns a random account and account hash for the provided index +// assumes: addrIndex < len(tr.currentAddrs) +func (fs *fuzzState) selectAccount(addrIndex int) common.Address { + return fs.currentAddrs[addrIndex] +} + +// updateAccount selects a random account, increments its nonce, and adds the update +// to the pending changes for both tries. +func (fs *fuzzState) updateAccount(addrIndex int) { + addr := fs.selectAccount(addrIndex) + + for _, tr := range fs.merkleTries { + acc, err := tr.accountTrie.GetAccount(addr) + fs.require.NoError(err, "failed to get account %s for update in %s", addr.Hex(), tr.name) + fs.require.NotNil(acc, "account %s is nil for update in %s", addr.Hex(), tr.name) + acc.Nonce++ + acc.CodeHash = crypto.Keccak256Hash(acc.CodeHash[:]).Bytes() + acc.Balance.Add(acc.Balance, uint256.NewInt(3)) + fs.require.NoError(tr.accountTrie.UpdateAccount(addr, acc), "failed to update account %s in %s", addr.Hex(), tr.name) + } +} + +// deleteAccount selects a random account and deletes it from both tries and the tracked +// current state. +func (fs *fuzzState) deleteAccount(accountIndex int) { + deleteAddr := fs.selectAccount(accountIndex) + fs.currentAddrs = slices.DeleteFunc(fs.currentAddrs, func(addr common.Address) bool { + return deleteAddr == addr + }) + for _, tr := range fs.merkleTries { + fs.require.NoError(tr.accountTrie.DeleteAccount(deleteAddr), "failed to delete account %s in %s", deleteAddr.Hex(), tr.name) + delete(tr.openStorageTries, deleteAddr) // remove any open storage trie for the deleted account + } +} + +// openStorageTrie opens the storage trie for the provided account address. +// Uses an already opened trie, if there's a pending update to the ethereum nested +// storage trie. +// +// must maintain a map of currently open storage tries, so we can defer committing them +// until commit as opposed to after each storage update. +// This mimics the actual handling of state commitments in the EVM where storage tries are all committed immediately +// before updating the account trie along with the updated storage trie roots: +// https://github.com/ava-labs/libevm/blob/0bfe4a0380c86d7c9bf19fe84368b9695fcb96c7/core/state/statedb.go#L1155 +// +// If we attempt to commit the storage tries after each operation, then attempting to re-open the storage trie +// with an updated storage trie root from ethDatabase will fail since the storage trie root will not have been +// persisted yet - leading to a missing trie node error. +func (fs *fuzzState) openStorageTrie(addr common.Address, tr *merkleTrie) Trie { + storageTrie, ok := tr.openStorageTries[addr] + if ok { + return storageTrie + } + + acc, err := tr.accountTrie.GetAccount(addr) + fs.require.NoError(err, "failed to get account %s for storage trie in %s", addr.Hex(), tr.name) + fs.require.NotNil(acc, "account %s not found in %s", addr.Hex(), tr.name) + storageTrie, err = tr.ethDatabase.OpenStorageTrie(tr.lastRoot, addr, acc.Root, tr.accountTrie) + fs.require.NoError(err, "failed to open storage trie for %s in %s", addr.Hex(), tr.name) + tr.openStorageTries[addr] = storageTrie + return storageTrie +} + +// addStorage selects an account and adds a new storage key-value pair to the account. +func (fs *fuzzState) addStorage(accountIndex int) { + addr := fs.selectAccount(accountIndex) + // Increment storageInputIndices for the account and take the next input to generate + // a new storage key-value pair for the account. + fs.currentStorageInputIndices[addr]++ + storageIndex := fs.currentStorageInputIndices[addr] + key := crypto.Keccak256Hash(binary.BigEndian.AppendUint64(nil, storageIndex)) + keyHash := crypto.Keccak256Hash(key[:]) + val := crypto.Keccak256Hash(keyHash[:]) + + for _, tr := range fs.merkleTries { + str := fs.openStorageTrie(addr, tr) + fs.require.NoError(str.UpdateStorage(addr, key[:], val[:]), "failed to add storage for account %s in %s", addr.Hex(), tr.name) + } + + fs.currentStorageInputIndices[addr]++ +} + +// updateStorage selects an account and updates an existing storage key-value pair +// note: this may "update" a key-value pair that doesn't exist if it was previously deleted. +func (fs *fuzzState) updateStorage(accountIndex int, storageIndexInput uint64) { + addr := fs.selectAccount(accountIndex) + storageIndex := fs.currentStorageInputIndices[addr] + storageIndex %= storageIndexInput + + storageKey := crypto.Keccak256Hash(binary.BigEndian.AppendUint64(nil, storageIndex)) + storageKeyHash := crypto.Keccak256Hash(storageKey[:]) + fs.inputCounter++ + updatedValInput := binary.BigEndian.AppendUint64(storageKeyHash[:], fs.inputCounter) + updatedVal := crypto.Keccak256Hash(updatedValInput[:]) + + for _, tr := range fs.merkleTries { + str := fs.openStorageTrie(addr, tr) + fs.require.NoError(str.UpdateStorage(addr, storageKey[:], updatedVal[:]), "failed to update storage for account %s in %s", addr.Hex(), tr.name) + } +} + +// deleteStorage selects an account and deletes an existing storage key-value pair +// note: this may "delete" a key-value pair that doesn't exist if it was previously deleted. +func (fs *fuzzState) deleteStorage(accountIndex int, storageIndexInput uint64) { + addr := fs.selectAccount(accountIndex) + storageIndex := fs.currentStorageInputIndices[addr] + storageIndex %= storageIndexInput + storageKey := crypto.Keccak256Hash(binary.BigEndian.AppendUint64(nil, storageIndex)) + + for _, tr := range fs.merkleTries { + str := fs.openStorageTrie(addr, tr) + fs.require.NoError(str.DeleteStorage(addr, storageKey[:]), "failed to delete storage for account %s in %s", addr.Hex(), tr.name) + } +} + +func FuzzTree(f *testing.F) { + for randSeed := range int64(1000) { + rand := rand.New(rand.NewSource(randSeed)) + steps := make([]byte, 32) + _, err := rand.Read(steps) + if err != nil { + f.Fatal(err) + } + f.Add(randSeed, steps) + } + f.Fuzz(func(t *testing.T, randSeed int64, byteSteps []byte) { + fuzzState := newFuzzState(t) + rand := rand.New(rand.NewSource(randSeed)) + + for range 10 { + fuzzState.createAccount() + } + fuzzState.commit() + + const maxSteps = 1000 + if len(byteSteps) > maxSteps { + byteSteps = byteSteps[:maxSteps] + } + + for _, step := range byteSteps { + step = step % maxStep + t.Log(stepMap[step]) + switch step { + case commit: + fuzzState.commit() + case createAccount: + fuzzState.createAccount() + case updateAccount: + if len(fuzzState.currentAddrs) > 0 { + fuzzState.updateAccount(rand.Intn(len(fuzzState.currentAddrs))) + } + case deleteAccount: + if len(fuzzState.currentAddrs) > 0 { + fuzzState.deleteAccount(rand.Intn(len(fuzzState.currentAddrs))) + } + case addStorage: + if len(fuzzState.currentAddrs) > 0 { + fuzzState.addStorage(rand.Intn(len(fuzzState.currentAddrs))) + } + case updateStorage: + if len(fuzzState.currentAddrs) > 0 { + fuzzState.updateStorage(rand.Intn(len(fuzzState.currentAddrs)), rand.Uint64()) + } + case deleteStorage: + if len(fuzzState.currentAddrs) > 0 { + fuzzState.deleteStorage(rand.Intn(len(fuzzState.currentAddrs)), rand.Uint64()) + } + default: + t.Fatalf("unknown step: %d", step) + } + } + }) +} diff --git a/core/state/firewood_database.go b/core/state/firewood_database.go new file mode 100644 index 0000000000..856408fcf0 --- /dev/null +++ b/core/state/firewood_database.go @@ -0,0 +1,49 @@ +// Copyright (C) 2019-2025, Ava Labs, Inc. All rights reserved. +// See the file LICENSE for licensing terms. + +package state + +import ( + "fmt" + + "github.com/ava-labs/coreth/triedb/firewood" + "github.com/ava-labs/libevm/common" +) + +var ( + _ Database = (*firewoodAccessorDb)(nil) + _ Trie = (*firewood.AccountTrie)(nil) + _ Trie = (*firewood.StorageTrie)(nil) +) + +type firewoodAccessorDb struct { + Database + fw *firewood.Database +} + +// OpenTrie opens the main account trie. +func (db *firewoodAccessorDb) OpenTrie(root common.Hash) (Trie, error) { + return firewood.NewAccountTrie(root, db.fw) +} + +// OpenStorageTrie opens a wrapped version of the account trie. +func (db *firewoodAccessorDb) OpenStorageTrie(stateRoot common.Hash, address common.Address, root common.Hash, self Trie) (Trie, error) { + accountTrie, ok := self.(*firewood.AccountTrie) + if !ok { + return nil, fmt.Errorf("Invalid account trie type: %T", self) + } + return firewood.NewStorageTrie(accountTrie, root) +} + +// CopyTrie returns a deep copy of the given trie. +// It can be altered by the caller. +func (db *firewoodAccessorDb) CopyTrie(t Trie) Trie { + switch t := t.(type) { + case *firewood.AccountTrie: + return t.Copy() + case *firewood.StorageTrie: + return nil // The storage trie just wraps the account trie, so we must re-open it separately. + default: + panic(fmt.Errorf("unknown trie type %T", t)) + } +} diff --git a/go.mod b/go.mod index 35efb6edf9..4f5d9b941c 100644 --- a/go.mod +++ b/go.mod @@ -5,6 +5,7 @@ go 1.23.9 require ( github.com/VictoriaMetrics/fastcache v1.12.1 github.com/ava-labs/avalanchego v1.13.1-rc.4.0.20250531182522-ce450499b7d7 + github.com/ava-labs/firewood-go-ethhash/ffi v0.0.8 github.com/ava-labs/libevm v1.13.14-0.3.0.rc.1 github.com/davecgh/go-spew v1.1.1 github.com/deckarep/golang-set/v2 v2.1.0 diff --git a/go.sum b/go.sum index bae1bac293..2ac0521111 100644 --- a/go.sum +++ b/go.sum @@ -64,6 +64,8 @@ github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5 h1:0CwZNZbxp69SHPd github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5/go.mod h1:wHh0iHkYZB8zMSxRWpUBQtwG5a7fFgvEO+odwuTv2gs= github.com/ava-labs/avalanchego v1.13.1-rc.4.0.20250531182522-ce450499b7d7 h1:lVAwojBM/msstF2cYiP7X/dO6qdo8nurJInuqByVH/4= github.com/ava-labs/avalanchego v1.13.1-rc.4.0.20250531182522-ce450499b7d7/go.mod h1:hrw1E2ZvgcOMU08qxFg7YAC+k/o1G7AjioiDq8DCTCY= +github.com/ava-labs/firewood-go-ethhash/ffi v0.0.8 h1:f0ZbAiRE1srMiv/0DuXvPQZwgYbLC9OgAWbQUCMebTE= +github.com/ava-labs/firewood-go-ethhash/ffi v0.0.8/go.mod h1:j6spQFNSBAfcXKt9g0xbObW/8tMlGP4bFjPIsJmDg/o= github.com/ava-labs/libevm v1.13.14-0.3.0.rc.1 h1:vBMYo+Iazw0rGTr+cwjkBdh5eadLPlv4ywI4lKye3CA= github.com/ava-labs/libevm v1.13.14-0.3.0.rc.1/go.mod h1:+Iol+sVQ1KyoBsHf3veyrBmHCXr3xXRWq6ZXkgVfNLU= github.com/aymerick/raymond v2.0.3-0.20180322193309-b565731e1464+incompatible/go.mod h1:osfaiScAUVup+UC9Nfq76eWqDhXlp+4UYaA8uhTBO6g= diff --git a/plugin/evm/customrawdb/database_ext.go b/plugin/evm/customrawdb/database_ext.go index ff47885594..f0e76cd105 100644 --- a/plugin/evm/customrawdb/database_ext.go +++ b/plugin/evm/customrawdb/database_ext.go @@ -5,6 +5,7 @@ package customrawdb import ( "bytes" + "fmt" "github.com/ava-labs/libevm/common" "github.com/ava-labs/libevm/core/rawdb" @@ -61,3 +62,19 @@ func InspectDatabase(db ethdb.Database, keyPrefix, keyStart []byte) error { return rawdb.InspectDatabase(db, keyPrefix, keyStart, options...) } + +// ParseStateSchemeExt parses the state scheme from the provided string. +func ParseStateSchemeExt(provided string, disk ethdb.Database) (string, error) { + // Check for custom scheme + if provided == FirewoodScheme { + if diskScheme := rawdb.ReadStateScheme(disk); diskScheme != "" { + // Valid scheme on disk mismatched + return "", fmt.Errorf("State scheme %s already set on disk, can't use Firewood", diskScheme) + } + // If no conflicting scheme is found, is valid. + return FirewoodScheme, nil + } + + // Check for valid eth scheme + return rawdb.ParseStateScheme(provided, disk) +} diff --git a/plugin/evm/customrawdb/schema_ext.go b/plugin/evm/customrawdb/schema_ext.go index 7800573a06..d13f2f7e24 100644 --- a/plugin/evm/customrawdb/schema_ext.go +++ b/plugin/evm/customrawdb/schema_ext.go @@ -51,3 +51,5 @@ var ( // and is equal to [syncPerformedPrefix] + block number as uint64. syncPerformedKeyLength = len(syncPerformedPrefix) + wrappers.LongLen ) + +var FirewoodScheme = "firewood" diff --git a/scripts/upstream_files.txt b/scripts/upstream_files.txt index 874c1bd983..d033d862aa 100644 --- a/scripts/upstream_files.txt +++ b/scripts/upstream_files.txt @@ -14,6 +14,7 @@ internal/* !core/main_test.go !core/predicate_check.go !core/predicate_check_test.go +!core/state/firewood_database.go !core/state/snapshot/snapshot_ext.go !core/state/statedb_multicoin_test.go !core/state_manager_test.go diff --git a/triedb/firewood/account_trie.go b/triedb/firewood/account_trie.go new file mode 100644 index 0000000000..4940e9ae4b --- /dev/null +++ b/triedb/firewood/account_trie.go @@ -0,0 +1,273 @@ +// Copyright (C) 2019-2025, Ava Labs, Inc. All rights reserved. +// See the file LICENSE for licensing terms. + +package firewood + +import ( + "errors" + + "github.com/ava-labs/libevm/common" + "github.com/ava-labs/libevm/core/types" + "github.com/ava-labs/libevm/crypto" + "github.com/ava-labs/libevm/ethdb" + "github.com/ava-labs/libevm/log" + "github.com/ava-labs/libevm/rlp" + "github.com/ava-labs/libevm/trie" + "github.com/ava-labs/libevm/trie/trienode" + "github.com/ava-labs/libevm/triedb/database" +) + +// AccountTrie implements state.Trie for managing account states. +// There are a couple caveats to the current implementation: +// 1. `Commit` is not used as expected in the state package. The `StorageTrie` doesn't return +// values, and we thus rely on the `AccountTrie`. +// 2. The `Hash` method actually creates the proposal, since Firewood cannot calculate +// the hash of the trie without committing it. It is immediately dropped, and this +// can likely be optimized. +type AccountTrie struct { + fw *Database + parentRoot common.Hash + root common.Hash + reader database.Reader + dirtyKeys map[string][]byte // Store dirty changes + updateKeys [][]byte + updateValues [][]byte + hasChanges bool +} + +func NewAccountTrie(root common.Hash, db *Database) (*AccountTrie, error) { + reader, err := db.Reader(root) + if err != nil { + return nil, err + } + return &AccountTrie{ + fw: db, + parentRoot: root, + reader: reader, + dirtyKeys: make(map[string][]byte), + hasChanges: true, // Start with hasChanges true to allow computing the proposal hash + }, nil +} + +// GetAccount implements state.Trie. +func (a *AccountTrie) GetAccount(addr common.Address) (*types.StateAccount, error) { + key := crypto.Keccak256Hash(addr.Bytes()).Bytes() + + // First check if there's a pending update for this account + keyStr := string(key) + if updateValue, exists := a.dirtyKeys[keyStr]; exists { + // If the value is empty, it indicates deletion + // Invariant: All encoded values have length > 0 + if len(updateValue) == 0 { + return nil, nil + } + // Decode and return the updated account + account := new(types.StateAccount) + err := rlp.DecodeBytes(updateValue, account) + return account, err + } + + // No pending update found, read from the underlying reader + accountBytes, err := a.reader.Node(common.Hash{}, key, common.Hash{}) + if err != nil { + return nil, err + } + + if accountBytes == nil { + return nil, nil + } + + // Decode the account node + account := new(types.StateAccount) + err = rlp.DecodeBytes(accountBytes, account) + return account, err +} + +// GetStorage implements state.Trie. +func (a *AccountTrie) GetStorage(addr common.Address, key []byte) ([]byte, error) { + // If the account has been deleted, we should return nil + accountKey := crypto.Keccak256Hash(addr.Bytes()).Bytes() + if val, exists := a.dirtyKeys[string(accountKey)]; exists && len(val) == 0 { + return nil, nil + } + + var combinedKey [2 * common.HashLength]byte + storageKey := crypto.Keccak256Hash(key).Bytes() + copy(combinedKey[:common.HashLength], accountKey) + copy(combinedKey[common.HashLength:], storageKey) + + // Check if there's a pending update for this storage slot + keyStr := string(combinedKey[:]) + if updateValue, exists := a.dirtyKeys[keyStr]; exists { + // If the value is empty, it indicates deletion + if len(updateValue) == 0 { + return nil, nil + } + // Decode and return the updated storage value + _, decoded, _, err := rlp.Split(updateValue) + return decoded, err + } + + // No pending update found, read from the underlying reader + storageBytes, err := a.reader.Node(common.Hash{}, combinedKey[:], common.Hash{}) + if err != nil || storageBytes == nil { + return nil, err + } + + // Decode the storage value + _, decoded, _, err := rlp.Split(storageBytes) + return decoded, err +} + +// UpdateAccount implements state.Trie. +func (a *AccountTrie) UpdateAccount(addr common.Address, account *types.StateAccount) error { + // Queue the keys and values for later commit + key := crypto.Keccak256Hash(addr.Bytes()).Bytes() + data, err := rlp.EncodeToBytes(account) + if err != nil { + return err + } + a.dirtyKeys[string(key)] = data + a.updateKeys = append(a.updateKeys, key) + a.updateValues = append(a.updateValues, data) + a.hasChanges = true // Mark that there are changes to commit + return nil +} + +// UpdateStorage implements state.Trie. +func (a *AccountTrie) UpdateStorage(addr common.Address, key []byte, value []byte) error { + var combinedKey [2 * common.HashLength]byte + accountKey := crypto.Keccak256Hash(addr.Bytes()).Bytes() + storageKey := crypto.Keccak256Hash(key).Bytes() + copy(combinedKey[:common.HashLength], accountKey) + copy(combinedKey[common.HashLength:], storageKey) + + data, err := rlp.EncodeToBytes(value) + if err != nil { + return err + } + + // Queue the keys and values for later commit + a.dirtyKeys[string(combinedKey[:])] = data + a.updateKeys = append(a.updateKeys, combinedKey[:]) + a.updateValues = append(a.updateValues, data) + a.hasChanges = true // Mark that there are changes to commit + return nil +} + +// DeleteAccount implements state.Trie. +func (a *AccountTrie) DeleteAccount(addr common.Address) error { + key := crypto.Keccak256Hash(addr.Bytes()).Bytes() + // Queue the key for deletion + a.dirtyKeys[string(key)] = []byte{} + a.updateKeys = append(a.updateKeys, key) + a.updateValues = append(a.updateValues, []byte{}) // Empty value indicates deletion + a.hasChanges = true // Mark that there are changes to commit + return nil +} + +// DeleteStorage implements state.Trie. +func (a *AccountTrie) DeleteStorage(addr common.Address, key []byte) error { + var combinedKey [2 * common.HashLength]byte + accountKey := crypto.Keccak256Hash(addr.Bytes()).Bytes() + storageKey := crypto.Keccak256Hash(key).Bytes() + copy(combinedKey[:common.HashLength], accountKey) + copy(combinedKey[common.HashLength:], storageKey) + + // Queue the key for deletion + a.dirtyKeys[string(combinedKey[:])] = []byte{} + a.updateKeys = append(a.updateKeys, combinedKey[:]) + a.updateValues = append(a.updateValues, []byte{}) // Empty value indicates deletion + a.hasChanges = true // Mark that there are changes to commit + return nil +} + +// Hash implements state.Trie. +func (a *AccountTrie) Hash() common.Hash { + hash, err := a.hash() + if err != nil { + log.Error("Failed to hash account trie", "error", err) + return common.Hash{} + } + return hash +} + +func (a *AccountTrie) hash() (common.Hash, error) { + // If we haven't already hashed, we need to do so. + if a.hasChanges { + root, err := a.fw.getProposalHash(a.parentRoot, a.updateKeys, a.updateValues) + if err != nil { + return common.Hash{}, err + } + a.root = root + a.hasChanges = false // Avoid re-hashing until next update + } + return a.root, nil +} + +// Commit implements state.Trie. +func (a *AccountTrie) Commit(collectLeaf bool) (common.Hash, *trienode.NodeSet, error) { + // Get the hash of the trie. + hash, err := a.hash() + if err != nil { + return common.Hash{}, nil, err + } + + // Create the NodeSet. This will be sent to `triedb.Update` later. + nodeset := trienode.NewNodeSet(a.parentRoot) + for i, key := range a.updateKeys { + nodeset.AddNode(key, &trienode.Node{ + Blob: a.updateValues[i], + }) + } + + return hash, nodeset, nil +} + +// UpdateContractCode implements state.Trie. +// Contract code is controlled by rawdb, so we don't need to do anything here. +func (a *AccountTrie) UpdateContractCode(_ common.Address, _ common.Hash, _ []byte) error { + return nil +} + +// GetKey implements state.Trie. +func (a *AccountTrie) GetKey(_ []byte) []byte { + return nil // Not implemented, as this is only used in APIs +} + +// NodeIterator implements state.Trie. +func (a *AccountTrie) NodeIterator(_ []byte) (trie.NodeIterator, error) { + return nil, errors.New("NodeIterator not implemented for Firewood") +} + +// Prove implements state.Trie. +func (a *AccountTrie) Prove(_ []byte, _ ethdb.KeyValueWriter) error { + return errors.New("Prove not implemented for Firewood") +} + +func (a *AccountTrie) Copy() *AccountTrie { + // Create a new AccountTrie with the same root and reader + newTrie := &AccountTrie{ + fw: a.fw, + parentRoot: a.parentRoot, + root: a.root, + reader: a.reader, // Share the same reader + hasChanges: a.hasChanges, + dirtyKeys: make(map[string][]byte, len(a.dirtyKeys)), + updateKeys: make([][]byte, len(a.updateKeys)), + updateValues: make([][]byte, len(a.updateValues)), + } + + // Deep copy dirtyKeys map + for k, v := range a.dirtyKeys { + newTrie.dirtyKeys[k] = append([]byte{}, v...) + } + + // Deep copy updateKeys and updateValues slices + for i := range a.updateKeys { + newTrie.updateKeys[i] = append([]byte{}, a.updateKeys[i]...) + newTrie.updateValues[i] = append([]byte{}, a.updateValues[i]...) + } + + return newTrie +} diff --git a/triedb/firewood/database.go b/triedb/firewood/database.go new file mode 100644 index 0000000000..609c7f2852 --- /dev/null +++ b/triedb/firewood/database.go @@ -0,0 +1,600 @@ +// Copyright (C) 2019-2025, Ava Labs, Inc. All rights reserved. +// See the file LICENSE for licensing terms. + +package firewood + +import ( + "fmt" + "os" + "path/filepath" + "sync" + "time" + + "github.com/ava-labs/libevm/common" + "github.com/ava-labs/libevm/core/rawdb" + "github.com/ava-labs/libevm/core/types" + "github.com/ava-labs/libevm/ethdb" + "github.com/ava-labs/libevm/libevm/stateconf" + "github.com/ava-labs/libevm/log" + "github.com/ava-labs/libevm/metrics" + "github.com/ava-labs/libevm/trie/trienode" + "github.com/ava-labs/libevm/trie/triestate" + "github.com/ava-labs/libevm/triedb" + "github.com/ava-labs/libevm/triedb/database" + + ffi "github.com/ava-labs/firewood-go-ethhash/ffi" +) + +var ( + _ proposable = (*ffi.Database)(nil) + _ proposable = (*ffi.Proposal)(nil) + + // FFI triedb operation metrics + ffiProposeCount = metrics.GetOrRegisterCounter("firewood/triedb/propose/count", nil) + ffiProposeTimer = metrics.GetOrRegisterCounter("firewood/triedb/propose/time", nil) + ffiCommitCount = metrics.GetOrRegisterCounter("firewood/triedb/commit/count", nil) + ffiCommitTimer = metrics.GetOrRegisterCounter("firewood/triedb/commit/time", nil) + ffiCleanupTimer = metrics.GetOrRegisterCounter("firewood/triedb/cleanup/time", nil) + ffiOutstandingProposals = metrics.GetOrRegisterGauge("firewood/triedb/propose/outstanding", nil) + + // FFI Trie operation metrics + ffiHashCount = metrics.GetOrRegisterCounter("firewood/triedb/hash/count", nil) + ffiHashTimer = metrics.GetOrRegisterCounter("firewood/triedb/hash/time", nil) + ffiReadCount = metrics.GetOrRegisterCounter("firewood/triedb/read/count", nil) + ffiReadTimer = metrics.GetOrRegisterCounter("firewood/triedb/read/time", nil) +) + +type proposable interface { + // Propose creates a new proposal from the current state with the given keys and values. + Propose(keys, values [][]byte) (*ffi.Proposal, error) +} + +// ProposalContext represents a proposal in the Firewood database. +// This tracks all outstanding proposals to allow dereferencing upon commit. +type ProposalContext struct { + Proposal *ffi.Proposal + Hashes map[common.Hash]struct{} // All corresponding block hashes + Root common.Hash + Block uint64 + Parent *ProposalContext + Children []*ProposalContext +} + +type Config struct { + FilePath string + CleanCacheSize int // Size of the clean cache in bytes + FreeListCacheEntries uint // Number of free list entries to cache + Revisions uint + ReadCacheStrategy ffi.CacheStrategy +} + +// Note that `FilePath` is not specificied, and must always be set by the user. +var Defaults = &Config{ + CleanCacheSize: 1024 * 1024, // 1MB + FreeListCacheEntries: 40_000, + Revisions: 100, + ReadCacheStrategy: ffi.CacheAllReads, +} + +func (c Config) BackendConstructor(_ ethdb.Database) triedb.DBOverride { + return New(&c) +} + +type Database struct { + fwDisk *ffi.Database // The underlying Firewood database, used for storing proposals and revisions. + proposalLock sync.RWMutex + // proposalMap provides O(1) access by state root to all proposals stored in the proposalTree + proposalMap map[common.Hash][]*ProposalContext + // The proposal tree tracks the structure of the current proposals, and which proposals are children of which. + // This is used to ensure that we can dereference proposals correctly and commit the correct ones + // in the case of duplicate state roots. + // The root of the tree is stored here, and represents the top-most layer on disk. + proposalTree *ProposalContext +} + +// New creates a new Firewood database with the given disk database and configuration. +// Any error during creation will cause the program to exit. +func New(config *Config) *Database { + if config == nil { + config = Defaults + } + + fwConfig, err := validatePath(config) + if err != nil { + fmt.Println("firewood: error validating config", "error", err) + } + + fw, err := ffi.New(config.FilePath, fwConfig) + if err != nil { + fmt.Println("firewood: error creating firewood database", "error", err) + } + + currentRoot, err := fw.Root() + if err != nil { + fmt.Println("firewood: error getting current root", "error", err) + } + + return &Database{ + fwDisk: fw, + proposalMap: make(map[common.Hash][]*ProposalContext), + proposalTree: &ProposalContext{ + Root: common.Hash(currentRoot), + }, + } +} + +func validatePath(trieConfig *Config) (*ffi.Config, error) { + if trieConfig.FilePath == "" { + return nil, fmt.Errorf("firewood database file path must be set") + } + + // Check that the directory exists + dir := filepath.Dir(trieConfig.FilePath) + info, err := os.Stat(dir) + if err != nil { + return nil, fmt.Errorf("error checking database directory: %w", err) + } + + // Check if the file exists + info, err = os.Stat(trieConfig.FilePath) + exists := false + if err == nil { + if info.IsDir() { + return nil, fmt.Errorf("database file path is a directory: %s", trieConfig.FilePath) + } + // File exists + log.Info("Database file found", "path", trieConfig.FilePath) + exists = true + } + + // Create the Firewood config from the provided config. + config := &ffi.Config{ + Create: !exists, // Use any existing file + NodeCacheEntries: uint(trieConfig.CleanCacheSize) / 256, // TODO: estimate 256 bytes per node + FreeListCacheEntries: trieConfig.FreeListCacheEntries, + Revisions: trieConfig.Revisions, + ReadCacheStrategy: trieConfig.ReadCacheStrategy, + } + + return config, nil +} + +// Scheme returns the scheme of the database. +// This is only used in some API calls +// and in StateDB to avoid iterating through deleted storage tries. +// WARNING: If cherry-picking anything from upstream that uses this, +// it must be overwritten to use something like: +// `_, ok := db.(*Database); if !ok { return "" }` +// to recognize the Firewood database. +func (db *Database) Scheme() string { + return rawdb.HashScheme +} + +// Initialized checks whether a non-empty genesis block has been written. +func (db *Database) Initialized(_ common.Hash) bool { + rootBytes, err := db.fwDisk.Root() + if err != nil { + log.Error("firewood: error getting current root", "error", err) + return false + } + root := common.BytesToHash(rootBytes) + // If the current root isn't empty, then unless the database is empty, we have a genesis block recorded. + return root != types.EmptyRootHash +} + +// Update takes a root and a set of keys-values and creates a new proposal. +// It will not be committed until the Commit method is called. +// This function should be called even if there are no changes to the state to ensure proper tracking of block hashes. +func (db *Database) Update(root common.Hash, parentRoot common.Hash, block uint64, nodes *trienode.MergedNodeSet, _ *triestate.Set, opts ...stateconf.TrieDBUpdateOption) error { + // We require block hashes to be provided for all blocks in production. + // However, many tests cannot reasonably provide a block hash for genesis, so we allow it to be omitted. + parentHash, hash, ok := stateconf.ExtractTrieDBUpdatePayload(opts...) + if !ok { + log.Error("firewood: no block hash provided for block %d", block) + } + + // The rest of the operations except key-value arranging must occur with a lock + db.proposalLock.Lock() + defer db.proposalLock.Unlock() + + // Check if this proposal already exists. + // During reorgs, we may have already created this proposal. + // Additionally, we may have already created this proposal with a different block hash. + if existingProposals, ok := db.proposalMap[root]; ok { + for _, existing := range existingProposals { + // If the block hash is already tracked, we can skip proposing this again. + if _, exists := existing.Hashes[hash]; exists { + log.Debug("firewood: proposal already exists", "root", root.Hex(), "parent", parentRoot.Hex(), "block", block, "hash", hash.Hex()) + return nil + } + // We already have this proposal, but should create a new context with the correct hash. + // This solves the case of a unique block hash, but the same underlying proposal. + if _, exists := existing.Parent.Hashes[parentHash]; exists { + log.Debug("firewood: proposal already exists, updating hash", "root", root.Hex(), "parent", parentRoot.Hex(), "block", block, "hash", hash.Hex()) + existing.Hashes[hash] = struct{}{} + return nil + } + } + } + + keys, values := arrangeKeyValuePairs(nodes) // may return nil, nil if no changes + return db.propose(root, parentRoot, hash, parentHash, block, keys, values) +} + +// propose creates a new proposal for every possible parent with the given keys and values. +// If the parent cannot be found, an error will be returned. +// +// To avoid having to create a new proposal for each valid state root, the block hashes are +// provided to ensure uniqueness. When this method is called, we can guarantee that the proposalContext +// must be created and tracked. +// +// Should only be accessed with the proposal lock held. +func (db *Database) propose(root common.Hash, parentRoot common.Hash, hash common.Hash, parentHash common.Hash, block uint64, keys [][]byte, values [][]byte) error { + // Find the parent proposal with the correct hash. + // We assume the number of proposals at a given root is small, so we can iterate through them. + for _, parentProposal := range db.proposalMap[parentRoot] { + // If we know this proposal cannot be the parent, we can skip it. + // Since the only possible block that won't have a parent hash is block 1, + // and that will always be proposed from the database root, + // we can guarantee that the parent hash will be present in one of the proposals. + if _, exists := parentProposal.Hashes[parentHash]; !exists { + continue + } + log.Debug("firewood: proposing from parent proposal", "parent", parentProposal.Root.Hex(), "root", root.Hex(), "height", block) + p, err := db.createProposal(parentProposal.Proposal, root, keys, values) + if err != nil { + return err + } + pCtx := &ProposalContext{ + Proposal: p, + Hashes: map[common.Hash]struct{}{hash: {}}, + Root: root, + Block: block, + Parent: parentProposal, + } + + db.proposalMap[root] = append(db.proposalMap[root], pCtx) + parentProposal.Children = append(parentProposal.Children, pCtx) + return nil + } + + // Since we were unable to find a parent proposal with the given parent hash, + // we must create a new proposal from the database root. + // We must avoid the case in which we are reexecuting blocks upon startup, and haven't yet stored the parent block. + if _, exists := db.proposalTree.Hashes[parentHash]; db.proposalTree.Block != 0 && !exists { + return fmt.Errorf("firewood: parent hash %s not found for block %s at height %d", parentHash.Hex(), hash.Hex(), block) + } else if db.proposalTree.Root != parentRoot { + return fmt.Errorf("firewood: parent root %s does not match proposal tree root %s for root %s at height %d", parentRoot.Hex(), db.proposalTree.Root.Hex(), root.Hex(), block) + } + + log.Debug("firewood: proposing from database root", "root", root.Hex(), "height", block) + p, err := db.createProposal(db.fwDisk, root, keys, values) + if err != nil { + return err + } + pCtx := &ProposalContext{ + Proposal: p, + Hashes: map[common.Hash]struct{}{hash: {}}, // This may be common.Hash{} for genesis blocks. + Root: root, + Block: block, + Parent: db.proposalTree, + } + db.proposalMap[root] = append(db.proposalMap[root], pCtx) + db.proposalTree.Children = append(db.proposalTree.Children, pCtx) + + return nil +} + +// Commit persists a proposal as a revision to the database. +// +// Any time this is called, we expect either: +// 1. The root is the same as the current root of the database (empty block during bootstrapping) +// 2. We have created a valid propsal with that root, and it is of height +1 above the proposal tree root. +// Additionally, this should be unique. +// +// Afterward, we know that no other proposal at this height can be committed, so we can dereference all +// children in the the other branches of the proposal tree. +func (db *Database) Commit(root common.Hash, report bool) (err error) { + // We need to lock the proposal tree to prevent concurrent writes. + var pCtx *ProposalContext + db.proposalLock.Lock() + defer db.proposalLock.Unlock() + + // On success, we should persist the genesis root as necessary, and dereference all children + // of the committed proposal. + defer func() { + // If we attempted to commit a proposal, but it failed, we must dereference its children. + if pCtx != nil { + db.cleanupCommittedProposal(pCtx) + } + }() + + // Find the proposal with the given root. + for _, possible := range db.proposalMap[root] { + if possible.Parent.Root == db.proposalTree.Root && possible.Parent.Block == db.proposalTree.Block { + // We found the proposal with the correct parent. + if pCtx != nil { + // This should never happen, as we ensure that we don't create duplicate proposals in `propose`. + return fmt.Errorf("firewood: multiple proposals found for %s", root.Hex()) + } + pCtx = possible + } + } + if pCtx == nil { + return fmt.Errorf("firewood: committable proposal not found for %s", root.Hex()) + } + + start := time.Now() + // Commit the proposal to the database. + if commitErr := pCtx.Proposal.Commit(); commitErr != nil { + return fmt.Errorf("firewood: error committing proposal %s: %w", root.Hex(), commitErr) + } + ffiCommitCount.Inc(1) + ffiCommitTimer.Inc(time.Since(start).Milliseconds()) + ffiOutstandingProposals.Dec(1) + + // Assert that the root of the database matches the committed proposal root. + currentRootBytes, err := db.fwDisk.Root() + if err != nil { + return fmt.Errorf("firewood: error getting current root after commit: %w", err) + } + currentRoot := common.BytesToHash(currentRootBytes) + if currentRoot != root { + return fmt.Errorf("firewood: current root %s does not match expected root %s", currentRoot.Hex(), root.Hex()) + } + + if report { + log.Info("Persisted proposal to firewood database", "root", root) + } else { + log.Debug("Persisted proposal to firewood database", "root", root) + } + return nil +} + +// Size returns the storage size of diff layer nodes above the persistent disk +// layer and the dirty nodes buffered within the disk layer +// Only used for metrics and Commit intervals in APIs. +// This will be implemented in the firewood database eventually. +// Currently, Firewood stores all revisions in disk and proposals in memory. +func (db *Database) Size() (common.StorageSize, common.StorageSize) { + return 0, 0 +} + +// This isn't called anywhere in coreth +func (db *Database) Reference(_ common.Hash, _ common.Hash) { + log.Error("firewood: Reference not implemented") +} + +// Dereference drops a proposal from the database. +// This function is no-op because unused proposals are dereferenced when no longer valid. +// We cannot dereference at this call. Consider the following case: +// Chain 1 has root A and root C +// Chain 2 has root B and root C +// We commit root A, and immediately dereference root B and its child. +// Root C is Rejected, (which is intended to be 2C) but there's now only one record of root C in the proposal map. +// Thus, we recognize the single root C as the only proposal, and dereference it. +func (db *Database) Dereference(root common.Hash) { +} + +// Firewood does not support this. +func (db *Database) Cap(limit common.StorageSize) error { + return nil +} + +func (db *Database) Close() error { + db.proposalLock.Lock() + defer db.proposalLock.Unlock() + + // We don't need to explicitly dereference the proposals, since they will be cleaned up + // within the firewood close method. + db.proposalMap = nil + db.proposalTree.Children = nil + // Close the database + return db.fwDisk.Close() +} + +// createProposal creates a new proposal from the given layer +// If there are no changes, it will return nil. +func (db *Database) createProposal(layer proposable, root common.Hash, keys, values [][]byte) (p *ffi.Proposal, err error) { + // If there's an error after creating the proposal, we must drop it. + defer func() { + if err != nil && p != nil { + if dropErr := p.Drop(); dropErr != nil { + // We should still return the original error. + log.Error("firewood: error dropping proposal after error", "root", root.Hex(), "error", dropErr) + } + p = nil + } + }() + + if len(keys) != len(values) { + return nil, fmt.Errorf("firewood: keys and values must have the same length, got %d keys and %d values", len(keys), len(values)) + } + + start := time.Now() + p, err = layer.Propose(keys, values) + if err != nil { + return nil, fmt.Errorf("firewood: unable to create proposal for root %s: %w", root.Hex(), err) + } + ffiProposeCount.Inc(1) + ffiProposeTimer.Inc(time.Since(start).Milliseconds()) + ffiOutstandingProposals.Inc(1) + + currentRootBytes, err := p.Root() + if err != nil { + return nil, fmt.Errorf("firewood: error getting root of proposal %s: %w", root, err) + } + currentRoot := common.BytesToHash(currentRootBytes) + if root != currentRoot { + return nil, fmt.Errorf("firewood: proposed root %s does not match expected root %s", currentRoot.Hex(), root.Hex()) + } + + // Store the proposal context. + return p, nil +} + +// cleanupCommittedProposal dereferences the proposal and removes it from the proposal map. +// It also recursively dereferences all children of the proposal. +func (db *Database) cleanupCommittedProposal(pCtx *ProposalContext) { + start := time.Now() + oldChildren := db.proposalTree.Children + db.proposalTree = pCtx + db.proposalTree.Parent = nil + + db.removeProposalFromMap(pCtx) + + for _, childCtx := range oldChildren { + // Don't dereference the recently commit proposal. + if childCtx != pCtx { + db.dereference(childCtx) + } + } + ffiCleanupTimer.Inc(time.Since(start).Milliseconds()) +} + +// Internally removes all references of the proposal from the database. +// Should only be accessed with the proposal lock held. +// Consumer must not be iterating the proposal map at this root. +func (db *Database) dereference(pCtx *ProposalContext) { + // Base case: if there are children, we need to dereference them as well. + for _, child := range pCtx.Children { + db.dereference(child) + } + pCtx.Children = nil + + // Remove the proposal from the map. + db.removeProposalFromMap(pCtx) + + // Drop the proposal in the backend. + if err := pCtx.Proposal.Drop(); err != nil { + log.Error("firewood: error dropping proposal", "root", pCtx.Root.Hex(), "error", err) + } + ffiOutstandingProposals.Dec(1) +} + +// removeProposalFromMap removes the proposal from the proposal map. +// The proposal lock must be held when calling this function. +func (db *Database) removeProposalFromMap(pCtx *ProposalContext) { + rootList := db.proposalMap[pCtx.Root] + for i, p := range rootList { + if p == pCtx { // pointer comparison - guaranteed to be unique + rootList[i] = rootList[len(rootList)-1] + rootList[len(rootList)-1] = nil + rootList = rootList[:len(rootList)-1] + break + } + } + if len(rootList) == 0 { + delete(db.proposalMap, pCtx.Root) + } else { + db.proposalMap[pCtx.Root] = rootList + } +} + +// Reader retrieves a node reader belonging to the given state root. +// An error will be returned if the requested state is not available. +func (db *Database) Reader(root common.Hash) (database.Reader, error) { + if _, err := db.fwDisk.GetFromRoot(root.Bytes(), []byte{}); err != nil { + return nil, fmt.Errorf("firewood: unable to retrieve from root %s: %w", root.Hex(), err) + } + return &reader{db: db, root: root}, nil +} + +// reader is a state reader of Database which implements the Reader interface. +type reader struct { + db *Database + root common.Hash // The root of the state this reader is reading. +} + +// Node retrieves the trie node with the given node hash. No error will be +// returned if the node is not found. +func (reader *reader) Node(_ common.Hash, path []byte, _ common.Hash) ([]byte, error) { + // This function relies on Firewood's internal locking to ensure concurrent reads are safe. + // This is safe even if a proposal is being committed concurrently. + start := time.Now() + result, err := reader.db.fwDisk.GetFromRoot(reader.root.Bytes(), path) + if metrics.EnabledExpensive { + ffiReadCount.Inc(1) + ffiReadTimer.Inc(time.Since(start).Milliseconds()) + } + return result, err +} + +// getProposalHash calculates the hash if the set of keys and values are +// proposed from the given parent root. +func (db *Database) getProposalHash(parentRoot common.Hash, keys, values [][]byte) (common.Hash, error) { + // This function only reads from existing tracked proposals, so we can use a read lock. + db.proposalLock.RLock() + defer db.proposalLock.RUnlock() + + var ( + p *ffi.Proposal + err error + ) + start := time.Now() + if db.proposalTree.Root == parentRoot { + // Propose from the database root. + p, err = db.fwDisk.Propose(keys, values) + if err != nil { + return common.Hash{}, fmt.Errorf("firewood: error proposing from root %s: %v", parentRoot.Hex(), err) + } + } else { + // Find any proposal with the given parent root. + // Since we are only using the proposal to find the root hash, + // we can use the first proposal found. + proposals, ok := db.proposalMap[parentRoot] + if !ok || len(proposals) == 0 { + return common.Hash{}, fmt.Errorf("firewood: no proposal found for parent root %s", parentRoot.Hex()) + } + rootProposal := proposals[0].Proposal + + p, err = rootProposal.Propose(keys, values) + if err != nil { + return common.Hash{}, fmt.Errorf("firewood: error proposing from parent proposal %s: %v", parentRoot.Hex(), err) + } + } + ffiHashCount.Inc(1) + ffiHashTimer.Inc(time.Since(start).Milliseconds()) + + // We succesffuly created a proposal, so we must drop it after use. + defer p.Drop() + + rootBytes, err := p.Root() + if err != nil { + return common.Hash{}, err + } + return common.BytesToHash(rootBytes), nil +} + +func arrangeKeyValuePairs(nodes *trienode.MergedNodeSet) ([][]byte, [][]byte) { + if nodes == nil { + return nil, nil // No changes to propose + } + // Create key-value pairs for the nodes in bytes. + var ( + acctKeys [][]byte + acctValues [][]byte + storageKeys [][]byte + storageValues [][]byte + ) + + flattenedNodes := nodes.Flatten() + + for _, nodeset := range flattenedNodes { + for str, node := range nodeset { + if len(str) == common.HashLength { + // This is an account node. + acctKeys = append(acctKeys, []byte(str)) + acctValues = append(acctValues, node.Blob) + } else { + storageKeys = append(storageKeys, []byte(str)) + storageValues = append(storageValues, node.Blob) + } + } + } + + // We need to do all storage operations first, so prefix-deletion works for accounts. + keys := append(storageKeys, acctKeys...) + values := append(storageValues, acctValues...) + return keys, values +} diff --git a/triedb/firewood/storage_trie.go b/triedb/firewood/storage_trie.go new file mode 100644 index 0000000000..9b66fadf79 --- /dev/null +++ b/triedb/firewood/storage_trie.go @@ -0,0 +1,41 @@ +// Copyright (C) 2019-2025, Ava Labs, Inc. All rights reserved. +// See the file LICENSE for licensing terms. + +package firewood + +import ( + "github.com/ava-labs/libevm/common" + "github.com/ava-labs/libevm/trie/trienode" +) + +type StorageTrie struct { + *AccountTrie + storageRoot common.Hash +} + +// `NewStorageTrie` returns a wrapper around an `AccountTrie` since Firewood +// does not require a separate storage trie. All changes are managed by the account trie. +func NewStorageTrie(accountTrie *AccountTrie, storageRoot common.Hash) (*StorageTrie, error) { + return &StorageTrie{ + AccountTrie: accountTrie, + storageRoot: storageRoot, + }, nil +} + +// Actual commit is handled by the account trie. +// Return the old storage root as if there was no change - we don't want to use the +// actual account trie hash and nodeset here. +func (s *StorageTrie) Commit(collectLeaf bool) (common.Hash, *trienode.NodeSet, error) { + return s.storageRoot, nil, nil +} + +// Firewood doesn't require tracking storage roots inside of an account. +func (s *StorageTrie) Hash() common.Hash { + return s.storageRoot // only used in statedb to populate a `StateAccount` +} + +// Copy should never be called on a storage trie, as it is just a wrapper around the account trie. +// Each storage trie should be re-opened with the account trie separately. +func (s *StorageTrie) Copy() *StorageTrie { + return nil +}