fix: remove ava-labs dependencies and integrate with luxfi packages

This commit is contained in:
2025-09-15 04:16:54 +00:00
parent e18df02227
commit 0eb48776fc
19 changed files with 3723 additions and 0 deletions
+237
View File
@@ -0,0 +1,237 @@
# https://golangci-lint.run/usage/configuration/
version: "2"
run:
# If set we pass it to "go list -mod={option}". From "go help modules":
# If invoked with -mod=readonly, the go command is disallowed from the implicit
# automatic updating of go.mod described above. Instead, it fails when any changes
# to go.mod are needed. This setting is most useful to check that go.mod does
# not need updates, such as in a continuous integration and testing system.
# If invoked with -mod=vendor, the go command assumes that the vendor
# directory holds the correct copies of dependencies and ignores
# the dependency descriptions in go.mod.
#
# Allowed values: readonly|vendor|mod
# By default, it isn't set.
modules-download-mode: readonly
# Include non-test files tagged as test-only.
# Context: https://github.com/ava-labs/avalanchego/pull/3173
build-tags:
- test
issues:
# Make issues output unique by line.
uniq-by-line: false
# Maximum issues count per one linter.
max-issues-per-linter: 0
# Maximum count of issues with the same text.
max-same-issues: 0
formatters:
enable:
- gci
- gofmt
- gofumpt
settings:
gci:
sections:
- standard
- default
- blank
- prefix(github.com/ava-labs/subnet-evm)
- alias
- dot
custom-order: true
exclusions:
generated: lax
# Avalanche linters should be added incrementally
linters:
default: none
enable:
- asciicheck
- bodyclose
- copyloopvar
# - depguard
# - errcheck
- errorlint
# - forbidigo
- goconst
# - gocritic
- goprintffuncname
# - gosec
- govet
- importas
- ineffassign
- misspell
- nakedret
# - nilerr
- noctx
- nolintlint
- perfsprint
# - prealloc
# - predeclared
- revive
- spancheck
# - staticcheck
- tagalign
# - testifylint
- unconvert
- unparam
- unused
- usestdlibvars
- whitespace
settings:
depguard:
rules:
packages:
deny:
- pkg: container/list
desc: github.com/ava-labs/avalanchego/utils/linked should be used instead.
- pkg: github.com/golang/mock/gomock
desc: go.uber.org/mock/gomock should be used instead.
- pkg: github.com/stretchr/testify/assert
desc: github.com/stretchr/testify/require should be used instead.
- pkg: io/ioutil
desc: io/ioutil is deprecated. Use package io or os instead.
errorlint:
# Check for plain type assertions and type switches.
asserts: false
# Check for plain error comparisons.
comparison: false
forbidigo:
# Forbid the following identifiers (list of regexp).
forbid:
- pattern: require\.Error$(# ErrorIs should be used instead)?
- pattern: require\.ErrorContains$(# ErrorIs should be used instead)?
- pattern: require\.EqualValues$(# Equal should be used instead)?
- pattern: require\.NotEqualValues$(# NotEqual should be used instead)?
- pattern: ^(t|b|tb|f)\.(Fatal|Fatalf|Error|Errorf)$(# the require library should be used instead)?
- pattern: ^sort\.(Slice|Strings)$(# the slices package should be used instead)?
# Exclude godoc examples from forbidigo checks.
exclude-godoc-examples: false
gosec:
excludes:
- G107 # Url provided to HTTP request as taint input https://securego.io/docs/rules/g107
- G115 # TODO(marun) Enable this ruleset in a follow-up PR
importas:
# Do not allow unaliased imports of aliased packages.
no-unaliased: false
# Do not allow non-required aliases.
no-extra-aliases: false
# List of aliases
alias:
- pkg: github.com/ava-labs/avalanchego/utils/math
alias: safemath
- pkg: github.com/ava-labs/avalanchego/utils/json
alias: avajson
revive:
rules:
# https://github.com/mgechev/revive/blob/master/RULES_DESCRIPTIONS.md#bool-literal-in-expr
- name: bool-literal-in-expr
disabled: false
# https://github.com/mgechev/revive/blob/master/RULES_DESCRIPTIONS.md#early-return
- name: early-return
disabled: false
# https://github.com/mgechev/revive/blob/master/RULES_DESCRIPTIONS.md#empty-lines
- name: empty-lines
disabled: false
# https://github.com/mgechev/revive/blob/master/RULES_DESCRIPTIONS.md#redundant-import-alias
- name: redundant-import-alias
disabled: false
# https://github.com/mgechev/revive/blob/master/RULES_DESCRIPTIONS.md#string-format
- name: string-format
disabled: false
arguments:
- - b.Logf[0]
- /.*%.*/
- no format directive, use b.Log instead
- - fmt.Errorf[0]
- /.*%.*/
- no format directive, use errors.New instead
- - fmt.Fprintf[1]
- /.*%.*/
- no format directive, use fmt.Fprint instead
- - fmt.Printf[0]
- /.*%.*/
- no format directive, use fmt.Print instead
- - fmt.Sprintf[0]
- /.*%.*/
- no format directive, use fmt.Sprint instead
- - log.Fatalf[0]
- /.*%.*/
- no format directive, use log.Fatal instead
- - log.Printf[0]
- /.*%.*/
- no format directive, use log.Print instead
- - t.Logf[0]
- /.*%.*/
- no format directive, use t.Log instead
# https://github.com/mgechev/revive/blob/master/RULES_DESCRIPTIONS.md#struct-tag
- name: struct-tag
disabled: false
# https://github.com/mgechev/revive/blob/master/RULES_DESCRIPTIONS.md#unexported-naming
- name: unexported-naming
disabled: false
# https://github.com/mgechev/revive/blob/master/RULES_DESCRIPTIONS.md#unhandled-error
- name: unhandled-error
# prefer the errcheck linter since it can be disabled directly with nolint directive
# but revive's disable directive (e.g. //revive:disable:unhandled-error) is not
# supported when run under golangci_lint
disabled: true
# https://github.com/mgechev/revive/blob/master/RULES_DESCRIPTIONS.md#unused-parameter
- name: unused-parameter
disabled: false
# https://github.com/mgechev/revive/blob/master/RULES_DESCRIPTIONS.md#unused-receiver
- name: unused-receiver
disabled: false
# https://github.com/mgechev/revive/blob/master/RULES_DESCRIPTIONS.md#useless-break
- name: useless-break
disabled: false
spancheck:
# https://github.com/jjti/go-spancheck#checks
checks:
- end
staticcheck:
# https://staticcheck.io/docs/options#checks
checks:
- all
- -SA6002A # Storing non-pointer values in sync.Pool allocates memory
- -SA1019 # Using a deprecated function, variable, constant or field
- -QF1008 # Unnecessary embedded expressions
tagalign:
align: true
sort: true
order:
- serialize
strict: true
testifylint:
# Enable all checkers (https://github.com/Antonboom/testifylint#checkers).
# Default: false
enable-all: true
# Disable checkers by name
# (in addition to default
# suite-thelper
# ).
disable:
- go-require
- float-compare
unused:
# Mark all struct fields that have been written to as used.
# Default: true
field-writes-are-uses: false
# Treat IncDec statement (e.g. `i++` or `i--`) as both read and write operation instead of just write.
# Default: false
post-statements-are-reads: true
# Mark all local variables as used.
# default: true
local-variables-are-used: false
exclusions:
generated: lax
presets:
- comments
- common-false-positives
- legacy
- std-error-handling
@@ -0,0 +1,32 @@
name: Publish Antithesis Images
on:
workflow_dispatch:
push:
branches:
- master
env:
REGISTRY: us-central1-docker.pkg.dev
REPOSITORY: molten-verve-216720/avalanche-repository
jobs:
antithesis:
runs-on: ubuntu-latest
steps:
- name: Checkout Repository
uses: actions/checkout@v4
- name: Login to GAR
uses: docker/login-action@74a5d142397b4f367a81961eba4e8cd7edddf772 #v3.4.0
with:
registry: ${{ env.REGISTRY }}
username: _json_key
password: ${{ secrets.ANTITHESIS_GAR_JSON_KEY }}
- name: Build and publish images
run: ./scripts/run_task.sh build-antithesis-images
env:
IMAGE_PREFIX: ${{ env.REGISTRY }}/${{ env.REPOSITORY }}
IMAGE_TAG: latest
+46
View File
@@ -0,0 +1,46 @@
name: Trigger Antithesis
on:
schedule:
# When adjusting the schedule and duration of execution, make sure
# to check the schedule of antithesis jobs scheduled by other
# repos (avalanchego and hypersdk). Ideally the schedules for
# different test setups should not overlap to avoid unintended
# resource contention.
- cron: '0 14 * * *' # Every day at 2PM UTC
workflow_dispatch:
inputs:
duration:
description: 'The duration (in hours) to run the test for'
default: '0.5'
required: true
type: string
recipients:
description: 'Comma-seperated email addresses to send the test report to'
required: true
type: string
image_tag:
description: 'The image tag to target'
default: latest
required: true
type: string
jobs:
antithesis:
name: Run Antithesis
runs-on: ubuntu-latest
steps:
- uses: antithesishq/antithesis-trigger-action@6c0a27302c0a3cd97d87d40bd6704e673abed4bb #v0.9
with:
notebook_name: avalanche
tenant: avalanche
username: ${{ secrets.ANTITHESIS_USERNAME }}
password: ${{ secrets.ANTITHESIS_PASSWORD }}
github_token: ${{ secrets.ANTITHESIS_GH_PAT }}
config_image: antithesis-subnet-evm-config:${{ github.event.inputs.image_tag || 'latest' }}
images: antithesis-subnet-evm-workload:${{ github.event.inputs.image_tag || 'latest' }};antithesis-subnet-evm-node:${{ github.event.inputs.image_tag || 'latest' }}
email_recipients: ${{ github.event.inputs.recipients || secrets.ANTITHESIS_RECIPIENTS }}
# Duration is in hours
additional_parameters: |-
custom.duration=${{ github.event.inputs.duration || '7.5' }}
custom.workload=subnet-evm
+33
View File
@@ -0,0 +1,33 @@
// Copyright (C) 2019-2025, Ava Labs, Inc. All rights reserved.
// See the file LICENSE for licensing terms.
package extstate
import (
"github.com/ava-labs/libevm/core/state"
"github.com/ava-labs/libevm/ethdb"
"github.com/ava-labs/libevm/triedb"
"github.com/ava-labs/subnet-evm/triedb/firewood"
)
func NewDatabaseWithConfig(db ethdb.Database, config *triedb.Config) state.Database {
coredb := state.NewDatabaseWithConfig(db, config)
return wrapIfFirewood(coredb)
}
func NewDatabaseWithNodeDB(db ethdb.Database, triedb *triedb.Database) state.Database {
coredb := state.NewDatabaseWithNodeDB(db, triedb)
return wrapIfFirewood(coredb)
}
func wrapIfFirewood(db state.Database) state.Database {
fw, ok := db.TrieDB().Backend().(*firewood.Database)
if !ok {
return db
}
return &firewoodAccessorDb{
Database: db,
fw: fw,
}
}
+368
View File
@@ -0,0 +1,368 @@
// Copyright (C) 2019-2025, Ava Labs, Inc. All rights reserved.
// See the file LICENSE for licensing terms.
package extstate
import (
"encoding/binary"
"math/rand"
"path/filepath"
"slices"
"testing"
"github.com/ava-labs/libevm/common"
"github.com/ava-labs/libevm/core/rawdb"
"github.com/ava-labs/libevm/core/state"
"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"
"github.com/ava-labs/subnet-evm/triedb/firewood"
"github.com/ava-labs/subnet-evm/triedb/hashdb"
)
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 state.Database
accountTrie state.Trie
openStorageTries map[common.Address]state.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{
{
name: "hash",
ethDatabase: hashState,
accountTrie: hashTr,
openStorageTries: make(map[common.Address]state.Trie),
lastRoot: ethRoot,
},
{
name: "firewood",
ethDatabase: firewoodState,
accountTrie: fwTr,
openStorageTries: make(map[common.Address]state.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]state.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) state.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)
}
}
})
}
+51
View File
@@ -0,0 +1,51 @@
// Copyright (C) 2019-2025, Ava Labs, Inc. All rights reserved.
// See the file LICENSE for licensing terms.
package extstate
import (
"fmt"
"github.com/ava-labs/libevm/common"
"github.com/ava-labs/libevm/core/state"
"github.com/ava-labs/subnet-evm/triedb/firewood"
)
var (
_ state.Database = (*firewoodAccessorDb)(nil)
_ state.Trie = (*firewood.AccountTrie)(nil)
_ state.Trie = (*firewood.StorageTrie)(nil)
)
type firewoodAccessorDb struct {
state.Database
fw *firewood.Database
}
// OpenTrie opens the main account trie.
func (db *firewoodAccessorDb) OpenTrie(root common.Hash) (state.Trie, error) {
return firewood.NewAccountTrie(root, db.fw)
}
// OpenStorageTrie opens a wrapped version of the account trie.
func (*firewoodAccessorDb) OpenStorageTrie(_ common.Hash, _ common.Address, accountRoot common.Hash, self state.Trie) (state.Trie, error) {
accountTrie, ok := self.(*firewood.AccountTrie)
if !ok {
return nil, fmt.Errorf("Invalid account trie type: %T", self)
}
return firewood.NewStorageTrie(accountTrie, accountRoot)
}
// CopyTrie returns a deep copy of the given trie.
// It can be altered by the caller.
func (*firewoodAccessorDb) CopyTrie(t state.Trie) state.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))
}
}
+27
View File
@@ -0,0 +1,27 @@
// Copyright (C) 2019-2025, Lux Industries, Inc. All rights reserved.
// See the file LICENSE for licensing terms.
package extstate
import (
"github.com/luxfi/geth/core/state"
"github.com/luxfi/evm/utils"
)
type workerPool struct {
*utils.BoundedWorkers
}
func (wp *workerPool) Done() {
// Done is guaranteed to only be called after all work is already complete,
// so we call Wait for goroutines to finish before returning.
wp.BoundedWorkers.Wait()
}
func WithConcurrentWorkers(prefetchers int) state.PrefetcherOption {
pool := &workerPool{
BoundedWorkers: utils.NewBoundedWorkers(prefetchers),
}
return state.WithWorkerPools(func() state.WorkerPool { return pool })
}
+391
View File
@@ -0,0 +1,391 @@
// Copyright (C) 2019-2025, Ava Labs, Inc. All rights reserved.
// See the file LICENSE for licensing terms.
//
// This file is a derived work, based on the go-ethereum library whose original
// notices appear below.
//
// It is distributed under a license compatible with the licensing terms of the
// original code from which it is derived.
//
// Much love to the original authors for their work.
// **********
package log
import (
"bytes"
"fmt"
"math/big"
"reflect"
"strconv"
"time"
"unicode/utf8"
"github.com/holiman/uint256"
"golang.org/x/exp/slog"
)
const (
timeFormat = "2006-01-02T15:04:05-0700"
floatFormat = 'f'
termMsgJust = 40
termCtxMaxPadding = 40
)
// 40 spaces
var spaces = []byte(" ")
// TerminalStringer is an analogous interface to the stdlib stringer, allowing
// own types to have custom shortened serialization formats when printed to the
// screen.
type TerminalStringer interface {
TerminalString() string
}
func (h *TerminalHandler) format(buf []byte, r slog.Record, usecolor bool) []byte {
msg := escapeMessage(r.Message)
var color = ""
if usecolor {
switch r.Level {
case LevelCrit:
color = "\x1b[35m"
case slog.LevelError:
color = "\x1b[31m"
case slog.LevelWarn:
color = "\x1b[33m"
case slog.LevelInfo:
color = "\x1b[32m"
case slog.LevelDebug:
color = "\x1b[36m"
case LevelTrace:
color = "\x1b[34m"
}
}
if buf == nil {
buf = make([]byte, 0, 30+termMsgJust)
}
b := bytes.NewBuffer(buf)
// Note the timestamp is moved before the log level compared to upstream
b.WriteString("[")
writeTimeTermFormat(b, r.Time)
b.WriteString("] ")
if color != "" { // Start color
b.WriteString(color)
b.WriteString(LevelAlignedString(r.Level))
b.WriteString("\x1b[0m")
} else {
b.WriteString(LevelAlignedString(r.Level))
}
b.WriteByte(' ')
// Prefix is added compared to upstream
if h.Prefix != nil {
b.WriteString(h.Prefix(r))
}
b.WriteString(msg)
// try to justify the log output for short messages
//length := utf8.RuneCountInString(msg)
length := len(msg)
if (r.NumAttrs()+len(h.attrs)) > 0 && length < termMsgJust {
b.Write(spaces[:termMsgJust-length])
}
// print the attributes
h.formatAttributes(b, r, color)
return b.Bytes()
}
func (h *TerminalHandler) formatAttributes(buf *bytes.Buffer, r slog.Record, color string) {
// tmp is a temporary buffer we use, until bytes.Buffer.AvailableBuffer() (1.21)
// can be used.
var tmp = make([]byte, 40)
writeAttr := func(attr slog.Attr, _, last bool) {
buf.WriteByte(' ')
if color != "" {
buf.WriteString(color)
//buf.Write(appendEscapeString(buf.AvailableBuffer(), attr.Key))
buf.Write(appendEscapeString(tmp[:0], attr.Key))
buf.WriteString("\x1b[0m=")
} else {
//buf.Write(appendEscapeString(buf.AvailableBuffer(), attr.Key))
buf.Write(appendEscapeString(tmp[:0], attr.Key))
buf.WriteByte('=')
}
//val := FormatSlogValue(attr.Value, true, buf.AvailableBuffer())
val := FormatSlogValue(attr.Value, tmp[:0])
padding := h.fieldPadding[attr.Key]
length := utf8.RuneCount(val)
if padding < length && length <= termCtxMaxPadding {
padding = length
h.fieldPadding[attr.Key] = padding
}
buf.Write(val)
if !last && padding > length {
buf.Write(spaces[:padding-length])
}
}
var n = 0
var nAttrs = len(h.attrs) + r.NumAttrs()
for _, attr := range h.attrs {
writeAttr(attr, n == 0, n == nAttrs-1)
n++
}
r.Attrs(func(attr slog.Attr) bool {
writeAttr(attr, n == 0, n == nAttrs-1)
n++
return true
})
buf.WriteByte('\n')
}
// FormatSlogValue formats a slog.Value for serialization to terminal.
func FormatSlogValue(v slog.Value, tmp []byte) (result []byte) {
var value any
defer func() {
if err := recover(); err != nil {
if v := reflect.ValueOf(value); v.Kind() == reflect.Ptr && v.IsNil() {
result = []byte("<nil>")
} else {
panic(err)
}
}
}()
switch v.Kind() {
case slog.KindString:
return appendEscapeString(tmp, v.String())
case slog.KindInt64: // All int-types (int8, int16 etc) wind up here
return appendInt64(tmp, v.Int64())
case slog.KindUint64: // All uint-types (uint8, uint16 etc) wind up here
return appendUint64(tmp, v.Uint64(), false)
case slog.KindFloat64:
return strconv.AppendFloat(tmp, v.Float64(), floatFormat, 3, 64)
case slog.KindBool:
return strconv.AppendBool(tmp, v.Bool())
case slog.KindDuration:
value = v.Duration()
case slog.KindTime:
// Performance optimization: No need for escaping since the provided
// timeFormat doesn't have any escape characters, and escaping is
// expensive.
return v.Time().AppendFormat(tmp, timeFormat)
default:
value = v.Any()
}
if value == nil {
return []byte("<nil>")
}
switch v := value.(type) {
case *big.Int: // Need to be before fmt.Stringer-clause
return appendBigInt(tmp, v)
case *uint256.Int: // Need to be before fmt.Stringer-clause
return appendU256(tmp, v)
case error:
return appendEscapeString(tmp, v.Error())
case TerminalStringer:
return appendEscapeString(tmp, v.TerminalString())
case fmt.Stringer:
return appendEscapeString(tmp, v.String())
}
// We can use the 'tmp' as a scratch-buffer, to first format the
// value, and in a second step do escaping.
internal := fmt.Appendf(tmp, "%+v", value)
return appendEscapeString(tmp, string(internal))
}
// appendInt64 formats n with thousand separators and writes into buffer dst.
func appendInt64(dst []byte, n int64) []byte {
if n < 0 {
return appendUint64(dst, uint64(-n), true)
}
return appendUint64(dst, uint64(n), false)
}
// appendUint64 formats n with thousand separators and writes into buffer dst.
func appendUint64(dst []byte, n uint64, neg bool) []byte {
// Small numbers are fine as is
if n < 100000 {
if neg {
return strconv.AppendInt(dst, -int64(n), 10)
} else {
return strconv.AppendInt(dst, int64(n), 10)
}
}
// Large numbers should be split
const maxLength = 26
var (
out = make([]byte, maxLength)
i = maxLength - 1
comma = 0
)
for ; n > 0; i-- {
if comma == 3 {
comma = 0
out[i] = ','
} else {
comma++
out[i] = '0' + byte(n%10)
n /= 10
}
}
if neg {
out[i] = '-'
i--
}
return append(dst, out[i+1:]...)
}
// FormatLogfmtUint64 formats n with thousand separators.
func FormatLogfmtUint64(n uint64) string {
return string(appendUint64(nil, n, false))
}
// appendBigInt formats n with thousand separators and writes to dst.
func appendBigInt(dst []byte, n *big.Int) []byte {
if n.IsUint64() {
return appendUint64(dst, n.Uint64(), false)
}
if n.IsInt64() {
return appendInt64(dst, n.Int64())
}
var (
text = n.String()
buf = make([]byte, len(text)+len(text)/3)
comma = 0
i = len(buf) - 1
)
for j := len(text) - 1; j >= 0; j, i = j-1, i-1 {
c := text[j]
switch {
case c == '-':
buf[i] = c
case comma == 3:
buf[i] = ','
i--
comma = 0
fallthrough
default:
buf[i] = c
comma++
}
}
return append(dst, buf[i+1:]...)
}
// appendU256 formats n with thousand separators.
func appendU256(dst []byte, n *uint256.Int) []byte {
if n.IsUint64() {
return appendUint64(dst, n.Uint64(), false)
}
res := []byte(n.PrettyDec(','))
return append(dst, res...)
}
// appendEscapeString writes the string s to the given writer, with
// escaping/quoting if needed.
func appendEscapeString(dst []byte, s string) []byte {
needsQuoting := false
needsEscaping := false
for _, r := range s {
// If it contains spaces or equal-sign, we need to quote it.
if r == ' ' || r == '=' {
needsQuoting = true
continue
}
// We need to escape it, if it contains
// - character " (0x22) and lower (except space)
// - characters above ~ (0x7E), plus equal-sign
if r <= '"' || r > '~' {
needsEscaping = true
break
}
}
if needsEscaping {
return strconv.AppendQuote(dst, s)
}
// No escaping needed, but we might have to place within quote-marks, in case
// it contained a space
if needsQuoting {
dst = append(dst, '"')
dst = append(dst, []byte(s)...)
return append(dst, '"')
}
return append(dst, []byte(s)...)
}
// escapeMessage checks if the provided string needs escaping/quoting, similarly
// to escapeString. The difference is that this method is more lenient: it allows
// for spaces and linebreaks to occur without needing quoting.
func escapeMessage(s string) string {
needsQuoting := false
for _, r := range s {
// Allow CR/LF/TAB. This is to make multi-line messages work.
if r == '\r' || r == '\n' || r == '\t' {
continue
}
// We quote everything below <space> (0x20) and above~ (0x7E),
// plus equal-sign
if r < ' ' || r > '~' || r == '=' {
needsQuoting = true
break
}
}
if !needsQuoting {
return s
}
return strconv.Quote(s)
}
// writeTimeTermFormat writes on the format "01-02|15:04:05.000"
func writeTimeTermFormat(buf *bytes.Buffer, t time.Time) {
_, month, day := t.Date()
writePosIntWidth(buf, int(month), 2)
buf.WriteByte('-')
writePosIntWidth(buf, day, 2)
buf.WriteByte('|')
hour, min, sec := t.Clock()
writePosIntWidth(buf, hour, 2)
buf.WriteByte(':')
writePosIntWidth(buf, min, 2)
buf.WriteByte(':')
writePosIntWidth(buf, sec, 2)
ns := t.Nanosecond()
buf.WriteByte('.')
writePosIntWidth(buf, ns/1e6, 3)
}
// writePosIntWidth writes non-negative integer i to the buffer, padded on the left
// by zeroes to the given width. Use a width of 0 to omit padding.
// Adapted from golang.org/x/exp/slog/internal/buffer/buffer.go
func writePosIntWidth(b *bytes.Buffer, i, width int) {
// Cheap integer to fixed-width decimal ASCII.
// Copied from log/log.go.
if i < 0 {
panic("negative int")
}
// Assemble decimal in reverse order.
var bb [20]byte
bp := len(bb) - 1
for i >= 10 || width > 1 {
width--
q := i / 10
bb[bp] = byte('0' + i - q*10)
bp--
i = q
}
// i < 10
bb[bp] = byte('0' + i)
b.Write(bb[bp:])
}
+208
View File
@@ -0,0 +1,208 @@
// Copyright (C) 2019-2025, Ava Labs, Inc. All rights reserved.
// See the file LICENSE for licensing terms.
//
// This file is a derived work, based on the go-ethereum library whose original
// notices appear below.
//
// It is distributed under a license compatible with the licensing terms of the
// original code from which it is derived.
//
// Much love to the original authors for their work.
// **********
package log
import (
"context"
"fmt"
"io"
"math/big"
"reflect"
"sync"
"time"
"github.com/holiman/uint256"
"golang.org/x/exp/slog"
)
type discardHandler struct{}
// DiscardHandler returns a no-op handler
func DiscardHandler() slog.Handler {
return &discardHandler{}
}
func (h *discardHandler) Handle(_ context.Context, r slog.Record) error {
return nil
}
func (h *discardHandler) Enabled(_ context.Context, level slog.Level) bool {
return false
}
func (h *discardHandler) WithGroup(name string) slog.Handler {
panic("not implemented")
}
func (h *discardHandler) WithAttrs(attrs []slog.Attr) slog.Handler {
return &discardHandler{}
}
type TerminalHandler struct {
mu sync.Mutex
wr io.Writer
lvl slog.Leveler
useColor bool
attrs []slog.Attr
// fieldPadding is a map with maximum field value lengths seen until now
// to allow padding log contexts in a bit smarter way.
fieldPadding map[string]int
buf []byte
// Prefix returns a string that is output before each log message.
Prefix func(r slog.Record) string
}
// NewTerminalHandler returns a handler which formats log records at all levels optimized for human readability on
// a terminal with color-coded level output and terser human friendly timestamp.
// This format should only be used for interactive programs or while developing.
//
// [LEVEL] [TIME] MESSAGE key=value key=value ...
//
// Example:
//
// [DBUG] [May 16 20:58:45] remove route ns=haproxy addr=127.0.0.1:50002
func NewTerminalHandler(wr io.Writer, useColor bool) *TerminalHandler {
return NewTerminalHandlerWithLevel(wr, levelMaxVerbosity, useColor)
}
// NewTerminalHandlerWithLevel returns the same handler as NewTerminalHandler but only outputs
// records which are less than or equal to the specified verbosity level.
func NewTerminalHandlerWithLevel(wr io.Writer, lvl slog.Leveler, useColor bool) *TerminalHandler {
return &TerminalHandler{
wr: wr,
lvl: lvl,
useColor: useColor,
fieldPadding: make(map[string]int),
}
}
func (h *TerminalHandler) Handle(_ context.Context, r slog.Record) error {
h.mu.Lock()
defer h.mu.Unlock()
buf := h.format(h.buf, r, h.useColor)
h.wr.Write(buf)
h.buf = buf[:0]
return nil
}
func (h *TerminalHandler) Enabled(_ context.Context, level slog.Level) bool {
return level >= h.lvl.Level()
}
func (h *TerminalHandler) WithGroup(name string) slog.Handler {
panic("not implemented")
}
func (h *TerminalHandler) WithAttrs(attrs []slog.Attr) slog.Handler {
return &TerminalHandler{
wr: h.wr,
lvl: h.lvl,
useColor: h.useColor,
attrs: append(h.attrs, attrs...),
fieldPadding: make(map[string]int),
}
}
// ResetFieldPadding zeroes the field-padding for all attribute pairs.
func (t *TerminalHandler) ResetFieldPadding() {
t.mu.Lock()
t.fieldPadding = make(map[string]int)
t.mu.Unlock()
}
// JSONHandler returns a handler which prints records in JSON format.
func JSONHandler(wr io.Writer) slog.Handler {
return slog.NewJSONHandler(wr, &slog.HandlerOptions{
ReplaceAttr: builtinReplaceJSON,
})
}
func JSONHandlerWithLevel(wr io.Writer, level slog.Leveler) slog.Handler {
return slog.NewJSONHandler(wr, &slog.HandlerOptions{
ReplaceAttr: builtinReplaceJSON,
Level: level,
})
}
// LogfmtHandler returns a handler which prints records in logfmt format, an easy machine-parseable but human-readable
// format for key/value pairs.
//
// For more details see: http://godoc.org/github.com/kr/logfmt
func LogfmtHandler(wr io.Writer) slog.Handler {
return slog.NewTextHandler(wr, &slog.HandlerOptions{
ReplaceAttr: builtinReplaceLogfmt,
})
}
// LogfmtHandlerWithLevel returns the same handler as LogfmtHandler but it only outputs
// records which are less than or equal to the specified verbosity level.
func LogfmtHandlerWithLevel(wr io.Writer, level slog.Leveler) slog.Handler {
return slog.NewTextHandler(wr, &slog.HandlerOptions{
ReplaceAttr: builtinReplaceLogfmt,
Level: level,
})
}
func builtinReplaceLogfmt(_ []string, attr slog.Attr) slog.Attr {
return builtinReplace(nil, attr, true)
}
func builtinReplaceJSON(_ []string, attr slog.Attr) slog.Attr {
return builtinReplace(nil, attr, false)
}
func builtinReplace(_ []string, attr slog.Attr, logfmt bool) slog.Attr {
switch attr.Key {
case slog.TimeKey:
if attr.Value.Kind() == slog.KindTime {
if logfmt {
return slog.String("timestamp", attr.Value.Time().Format(timeFormat))
} else {
return slog.Attr{Key: "timestamp", Value: attr.Value}
}
}
case slog.LevelKey:
if l, ok := attr.Value.Any().(slog.Level); ok {
attr = slog.Any("level", LevelString(l))
return attr
}
}
switch v := attr.Value.Any().(type) {
case time.Time:
if logfmt {
attr = slog.String(attr.Key, v.Format(timeFormat))
}
case *big.Int:
if v == nil {
attr.Value = slog.StringValue("<nil>")
} else {
attr.Value = slog.StringValue(v.String())
}
case *uint256.Int:
if v == nil {
attr.Value = slog.StringValue("<nil>")
} else {
attr.Value = slog.StringValue(v.Dec())
}
case fmt.Stringer:
if v == nil || (reflect.ValueOf(v).Kind() == reflect.Pointer && reflect.ValueOf(v).IsNil()) {
attr.Value = slog.StringValue("<nil>")
} else {
attr.Value = slog.StringValue(v.String())
}
}
return attr
}
+245
View File
@@ -0,0 +1,245 @@
// Copyright (C) 2019-2025, Ava Labs, Inc. All rights reserved.
// See the file LICENSE for licensing terms.
//
// This file is a derived work, based on the go-ethereum library whose original
// notices appear below.
//
// It is distributed under a license compatible with the licensing terms of the
// original code from which it is derived.
//
// Much love to the original authors for their work.
// **********
package log
import (
"context"
"fmt"
"math"
"os"
"runtime"
"strings"
"time"
"golang.org/x/exp/slog"
)
const errorKey = "LOG_ERROR"
const (
legacyLevelCrit = iota
legacyLevelError
legacyLevelWarn
legacyLevelInfo
legacyLevelDebug
legacyLevelTrace
)
const (
levelMaxVerbosity slog.Level = math.MinInt
LevelTrace slog.Level = -8
LevelDebug = slog.LevelDebug
LevelInfo = slog.LevelInfo
LevelWarn = slog.LevelWarn
LevelError = slog.LevelError
LevelCrit slog.Level = 12
// for backward-compatibility
LvlTrace = LevelTrace
LvlInfo = LevelInfo
LvlDebug = LevelDebug
)
// LvlFromString returns the appropriate Lvl from a string name.
// Useful for parsing command line args and configuration files.
func LvlFromString(lvlString string) (slog.Level, error) {
switch strings.ToLower(lvlString) {
case "trace", "trce":
return LevelTrace, nil
case "debug", "dbug":
return LevelDebug, nil
case "info":
return LevelInfo, nil
case "warn":
return LevelWarn, nil
case "error", "eror":
return LevelError, nil
case "crit":
return LevelCrit, nil
default:
return LvlDebug, fmt.Errorf("unknown level: %v", lvlString)
}
}
// convert from old Geth verbosity level constants
// to levels defined by slog
func FromLegacyLevel(lvl int) slog.Level {
switch lvl {
case legacyLevelCrit:
return LevelCrit
case legacyLevelError:
return slog.LevelError
case legacyLevelWarn:
return slog.LevelWarn
case legacyLevelInfo:
return slog.LevelInfo
case legacyLevelDebug:
return slog.LevelDebug
case legacyLevelTrace:
return LevelTrace
default:
break
}
// TODO: should we allow use of custom levels or force them to match existing max/min if they fall outside the range as I am doing here?
if lvl > legacyLevelTrace {
return LevelTrace
}
return LevelCrit
}
// LevelAlignedString returns a 5-character string containing the name of a Lvl.
func LevelAlignedString(l slog.Level) string {
switch l {
case LevelTrace:
return "TRACE"
case slog.LevelDebug:
return "DEBUG"
case slog.LevelInfo:
return "INFO"
case slog.LevelWarn:
return "WARN"
case slog.LevelError:
return "ERROR"
case LevelCrit:
return "CRIT"
default:
return "unknown level"
}
}
// LevelString returns a string containing the name of a Lvl.
func LevelString(l slog.Level) string {
switch l {
case LevelTrace:
return "trace"
case slog.LevelDebug:
return "debug"
case slog.LevelInfo:
return "info"
case slog.LevelWarn:
return "warn"
case slog.LevelError:
return "error"
case LevelCrit:
return "crit"
default:
return "unknown"
}
}
// A Logger writes key/value pairs to a Handler
type Logger interface {
// With returns a new Logger that has this logger's attributes plus the given attributes
With(ctx ...interface{}) Logger
// With returns a new Logger that has this logger's attributes plus the given attributes. Identical to 'With'.
New(ctx ...interface{}) Logger
// Log logs a message at the specified level with context key/value pairs
Log(level slog.Level, msg string, ctx ...interface{})
// Trace log a message at the trace level with context key/value pairs
Trace(msg string, ctx ...interface{})
// Debug logs a message at the debug level with context key/value pairs
Debug(msg string, ctx ...interface{})
// Info logs a message at the info level with context key/value pairs
Info(msg string, ctx ...interface{})
// Warn logs a message at the warn level with context key/value pairs
Warn(msg string, ctx ...interface{})
// Error logs a message at the error level with context key/value pairs
Error(msg string, ctx ...interface{})
// Crit logs a message at the crit level with context key/value pairs, and exits
Crit(msg string, ctx ...interface{})
// Write logs a message at the specified level
Write(level slog.Level, msg string, attrs ...any)
// Enabled reports whether l emits log records at the given context and level.
Enabled(ctx context.Context, level slog.Level) bool
}
type logger struct {
inner *slog.Logger
}
// NewLogger returns a logger with the specified handler set
func NewLogger(h slog.Handler) Logger {
return &logger{
slog.New(h),
}
}
// write logs a message at the specified level:
func (l *logger) Write(level slog.Level, msg string, attrs ...any) {
if !l.inner.Enabled(context.Background(), level) {
return
}
var pcs [1]uintptr
runtime.Callers(3, pcs[:])
if len(attrs)%2 != 0 {
attrs = append(attrs, nil, errorKey, "Normalized odd number of arguments by adding nil")
}
r := slog.NewRecord(time.Now(), level, msg, pcs[0])
r.Add(attrs...)
l.inner.Handler().Handle(context.Background(), r)
}
func (l *logger) Log(level slog.Level, msg string, attrs ...any) {
l.Write(level, msg, attrs...)
}
func (l *logger) With(ctx ...interface{}) Logger {
return &logger{l.inner.With(ctx...)}
}
func (l *logger) New(ctx ...interface{}) Logger {
return l.With(ctx...)
}
// Enabled reports whether l emits log records at the given context and level.
func (l *logger) Enabled(ctx context.Context, level slog.Level) bool {
return l.inner.Enabled(ctx, level)
}
func (l *logger) Trace(msg string, ctx ...interface{}) {
l.Write(LevelTrace, msg, ctx...)
}
func (l *logger) Debug(msg string, ctx ...interface{}) {
l.Write(slog.LevelDebug, msg, ctx...)
}
func (l *logger) Info(msg string, ctx ...interface{}) {
l.Write(slog.LevelInfo, msg, ctx...)
}
func (l *logger) Warn(msg string, ctx ...any) {
l.Write(slog.LevelWarn, msg, ctx...)
}
func (l *logger) Error(msg string, ctx ...interface{}) {
l.Write(slog.LevelError, msg, ctx...)
}
func (l *logger) Crit(msg string, ctx ...interface{}) {
l.Write(LevelCrit, msg, ctx...)
os.Exit(1)
}
+20
View File
@@ -0,0 +1,20 @@
// Copyright (C) 2019-2025, Ava Labs, Inc. All rights reserved.
// See the file LICENSE for licensing terms.
package paramstest
import (
"github.com/ava-labs/avalanchego/upgrade/upgradetest"
"github.com/ava-labs/subnet-evm/params"
)
// ForkToChainConfig maps a fork to a chain config
var ForkToChainConfig = map[upgradetest.Fork]*params.ChainConfig{
upgradetest.ApricotPhase5: params.TestPreSubnetEVMChainConfig,
upgradetest.ApricotPhase6: params.TestSubnetEVMChainConfig,
upgradetest.Durango: params.TestDurangoChainConfig,
upgradetest.Etna: params.TestEtnaChainConfig,
upgradetest.Fortuna: params.TestFortunaChainConfig,
upgradetest.Granite: params.TestGraniteChainConfig,
}
+104
View File
@@ -0,0 +1,104 @@
// Copyright (C) 2019-2025, Lux Industries, Inc. All rights reserved.
// See the file LICENSE for licensing terms.
package config
import (
"time"
"github.com/luxfi/node/database/pebbledb"
"github.com/luxfi/geth/common"
)
const defaultCommitInterval = 4096
func NewDefaultConfig() Config {
return Config{
AllowUnprotectedTxHashes: []common.Hash{
common.HexToHash("0xfefb2da535e927b85fe68eb81cb2e4a5827c905f78381a01ef2322aa9b0aee8e"), // EIP-1820: https://eips.ethereum.org/EIPS/eip-1820
},
EnabledEthAPIs: []string{
"eth",
"eth-filter",
"net",
"web3",
"internal-eth",
"internal-blockchain",
"internal-transaction",
},
// Provides 2 minutes of buffer (2s block target) for a commit delay
AcceptorQueueLimit: 64,
Pruning: true,
CommitInterval: defaultCommitInterval,
TrieCleanCache: 512,
TrieDirtyCache: 512,
TrieDirtyCommitTarget: 20,
TriePrefetcherParallelism: 16,
SnapshotCache: 256,
StateSyncCommitInterval: defaultCommitInterval * 4,
SnapshotWait: false,
RPCGasCap: 50_000_000, // 50M Gas Limit
RPCTxFeeCap: 100, // 100 AVAX
MetricsExpensiveEnabled: true,
// Default to no maximum API call duration
APIMaxDuration: timeToDuration(0),
// Default to no maximum WS CPU usage
WSCPURefillRate: timeToDuration(0),
// Default to no maximum WS CPU usage
WSCPUMaxStored: timeToDuration(0),
// Default to no maximum on the number of blocks per getLogs request
MaxBlocksPerRequest: 0,
ContinuousProfilerFrequency: timeToDuration(15 * time.Minute),
ContinuousProfilerMaxFiles: 5,
PushGossipPercentStake: .9,
PushGossipNumValidators: 100,
PushGossipNumPeers: 0,
PushRegossipNumValidators: 10,
PushRegossipNumPeers: 0,
PushGossipFrequency: timeToDuration(100 * time.Millisecond),
PullGossipFrequency: timeToDuration(1 * time.Second),
RegossipFrequency: timeToDuration(30 * time.Second),
// Default size (MB) for the offline pruner to use
OfflinePruningBloomFilterSize: uint64(512),
LogLevel: "info",
LogJSONFormat: false,
MaxOutboundActiveRequests: 16,
PopulateMissingTriesParallelism: 1024,
StateSyncServerTrieCache: 64, // MB
AcceptedCacheSize: 32, // blocks
// StateSyncMinBlocks is the minimum number of blocks the blockchain
// should be ahead of local last accepted to perform state sync.
// This constant is chosen so normal bootstrapping is preferred when it would
// be faster than state sync.
// time assumptions:
// - normal bootstrap processing time: ~14 blocks / second
// - state sync time: ~6 hrs.
StateSyncMinBlocks: 300_000,
// the number of key/values to ask peers for per request
StateSyncRequestSize: 1024,
StateHistory: uint64(32),
// Estimated block count in 24 hours with 2s block accept period
HistoricalProofQueryWindow: uint64(24 * time.Hour / (2 * time.Second)),
// Mempool settings
TxPoolPriceLimit: 1,
TxPoolPriceBump: 10,
TxPoolAccountSlots: 16,
TxPoolGlobalSlots: 4096 + 1024, // urgent + floating queue capacity with 4:1 ratio,
TxPoolAccountQueue: 64,
TxPoolGlobalQueue: 1024,
TxPoolLifetime: timeToDuration(10 * time.Minute),
// RPC settings
BatchRequestLimit: 1000,
BatchResponseMaxSize: 25 * 1000 * 1000, // 25MB
// Subnet EVM API settings
ValidatorsAPIEnabled: true,
// Database settings
DatabaseType: pebbledb.Name,
// Additional settings with sensible defaults
AllowUnprotectedTxs: false,
}
}
func timeToDuration(t time.Duration) Duration {
return Duration{t}
}
+240
View File
@@ -0,0 +1,240 @@
// Copyright (C) 2019-2025, Lux Industries, Inc. All rights reserved.
// See the file LICENSE for licensing terms.
// TODO: move to network
package evm
import (
"context"
"fmt"
"sync"
"sync/atomic"
"github.com/prometheus/client_golang/prometheus"
consensusCore "github.com/luxfi/node/consensus/engine/core"
"github.com/luxfi/ids"
"github.com/luxfi/log"
"github.com/luxfi/node/network/p2p"
"github.com/luxfi/node/network/p2p/gossip"
"github.com/luxfi/evm/core"
"github.com/luxfi/evm/core/txpool"
"github.com/luxfi/evm/eth"
"github.com/luxfi/evm/plugin/evm/config"
"github.com/luxfi/geth/common"
"github.com/luxfi/geth/core/types"
)
const pendingTxsBuffer = 10
var (
_ gossip.Gossipable = (*GossipEthTx)(nil)
_ gossip.Marshaller[*GossipEthTx] = (*GossipEthTxMarshaller)(nil)
_ gossip.Set[*GossipEthTx] = (*GossipEthTxPool)(nil)
_ eth.PushGossiper = (*EthPushGossiper)(nil)
)
<<<<<<< HEAD:plugin/evm/gossip.go
func newTxGossipHandler[T gossip.Gossipable](
log log.Logger,
marshaller gossip.Marshaller[T],
mempool gossip.Set[T],
metrics gossip.Metrics,
maxMessageSize int,
throttlingPeriod time.Duration,
throttlingLimit int,
validators p2p.ValidatorSet,
) txGossipHandler {
// push gossip messages can be handled from any peer
handler := gossip.NewHandler(
log,
marshaller,
mempool,
metrics,
maxMessageSize,
)
// pull gossip requests are filtered by validators and are throttled
// to prevent spamming
validatorHandler := p2p.NewValidatorHandler(
p2p.NewThrottlerHandler(
handler,
p2p.NewSlidingWindowThrottler(throttlingPeriod, throttlingLimit),
log,
),
validators,
log,
)
return txGossipHandler{
appGossipHandler: handler,
appRequestHandler: validatorHandler,
}
}
type txGossipHandler struct {
appGossipHandler p2p.Handler
appRequestHandler p2p.Handler
}
func (t txGossipHandler) AppGossip(ctx context.Context, nodeID ids.NodeID, gossipBytes []byte) {
t.appGossipHandler.AppGossip(ctx, nodeID, gossipBytes)
}
func (t txGossipHandler) AppRequest(ctx context.Context, nodeID ids.NodeID, deadline time.Time, requestBytes []byte) ([]byte, *consensusCore.AppError) {
return t.appRequestHandler.AppRequest(ctx, nodeID, deadline, requestBytes)
}
func (t txGossipHandler) CrossChainAppRequest(ctx context.Context, chainID ids.ID, deadline time.Time, requestBytes []byte) ([]byte, error) {
// Cross-chain requests not supported for tx gossip
return nil, fmt.Errorf("cross-chain requests not supported")
}
func NewGossipEthTxPool(mempool *txpool.TxPool, registerer prometheus.Registerer) (*GossipEthTxPool, error) {
bloom, err := gossip.NewBloomFilter(registerer, "eth_tx_bloom_filter", config.TxGossipBloomMinTargetElements, config.TxGossipBloomTargetFalsePositiveRate, config.TxGossipBloomResetFalsePositiveRate)
=======
func NewGossipEthTxPool(mempool *txpool.TxPool, registerer prometheus.Registerer) (*GossipEthTxPool, error) {
bloom, err := gossip.NewBloomFilter(
registerer,
"eth_tx_bloom_filter",
config.TxGossipBloomMinTargetElements,
config.TxGossipBloomTargetFalsePositiveRate,
config.TxGossipBloomResetFalsePositiveRate,
)
>>>>>>> upstream/master:plugin/evm/eth_gossiper.go
if err != nil {
return nil, fmt.Errorf("failed to initialize bloom filter: %w", err)
}
return &GossipEthTxPool{
mempool: mempool,
pendingTxs: make(chan core.NewTxsEvent, pendingTxsBuffer),
bloom: bloom,
}, nil
}
type GossipEthTxPool struct {
mempool *txpool.TxPool
pendingTxs chan core.NewTxsEvent
bloom *gossip.BloomFilter
lock sync.RWMutex
// subscribed is set to true when the gossip subscription is active
// mostly used for testing
subscribed atomic.Bool
}
// IsSubscribed returns whether or not the gossip subscription is active.
func (g *GossipEthTxPool) IsSubscribed() bool {
return g.subscribed.Load()
}
func (g *GossipEthTxPool) Subscribe(ctx context.Context) {
sub := g.mempool.SubscribeTransactions(g.pendingTxs, false)
if sub == nil {
log.Warn("failed to subscribe to new txs event")
return
}
g.subscribed.CompareAndSwap(false, true)
defer func() {
sub.Unsubscribe()
g.subscribed.CompareAndSwap(true, false)
}()
for {
select {
case <-ctx.Done():
log.Debug("shutting down subscription")
return
case pendingTxs := <-g.pendingTxs:
g.lock.Lock()
optimalElements := (g.mempool.PendingSize(txpool.PendingFilter{}) + len(pendingTxs.Txs)) * config.TxGossipBloomChurnMultiplier
for _, pendingTx := range pendingTxs.Txs {
tx := &GossipEthTx{Tx: pendingTx}
g.bloom.Add(tx)
reset, err := gossip.ResetBloomFilterIfNeeded(g.bloom, optimalElements)
if err != nil {
log.Error("failed to reset bloom filter", "err", err)
continue
}
if reset {
log.Debug("resetting bloom filter", "reason", "reached max filled ratio")
g.mempool.IteratePending(func(tx *types.Transaction) bool {
g.bloom.Add(&GossipEthTx{Tx: tx})
return true
})
}
}
g.lock.Unlock()
}
}
}
// Add enqueues the transaction to the mempool. Subscribe should be called
// to receive an event if tx is actually added to the mempool or not.
func (g *GossipEthTxPool) Add(tx *GossipEthTx) error {
return g.mempool.Add([]*types.Transaction{tx.Tx}, false, false)[0]
}
// Has should just return whether or not the [txID] is still in the mempool,
// not whether it is in the mempool AND pending.
func (g *GossipEthTxPool) Has(txID ids.ID) bool {
return g.mempool.Has(common.Hash(txID))
}
func (g *GossipEthTxPool) Iterate(f func(tx *GossipEthTx) bool) {
g.mempool.IteratePending(func(tx *types.Transaction) bool {
return f(&GossipEthTx{Tx: tx})
})
}
func (g *GossipEthTxPool) GetFilter() ([]byte, []byte) {
g.lock.RLock()
defer g.lock.RUnlock()
return g.bloom.Marshal()
}
type GossipEthTxMarshaller struct{}
func (GossipEthTxMarshaller) MarshalGossip(tx *GossipEthTx) ([]byte, error) {
return tx.Tx.MarshalBinary()
}
func (GossipEthTxMarshaller) UnmarshalGossip(bytes []byte) (*GossipEthTx, error) {
tx := &GossipEthTx{
Tx: &types.Transaction{},
}
return tx, tx.Tx.UnmarshalBinary(bytes)
}
type GossipEthTx struct {
Tx *types.Transaction
}
func (tx *GossipEthTx) GossipID() ids.ID {
return ids.ID(tx.Tx.Hash())
}
// EthPushGossiper is used by the ETH backend to push transactions issued over
// the RPC and added to the mempool to peers.
type EthPushGossiper struct {
vm *VM
}
func (e *EthPushGossiper) Add(tx *types.Transaction) {
// eth.Backend is initialized before the [ethTxPushGossiper] is created, so
// we just ignore any gossip requests until it is set.
ethTxPushGossiper := e.vm.ethTxPushGossiper.Get()
if ethTxPushGossiper == nil {
return
}
ethTxPushGossiper.Add(&GossipEthTx{tx})
}
+80
View File
@@ -0,0 +1,80 @@
// Copyright (C) 2019-2025, Ava Labs, Inc. All rights reserved.
// See the file LICENSE for licensing terms.
package gossip
import (
"context"
"fmt"
"time"
"github.com/ava-labs/avalanchego/ids"
"github.com/ava-labs/avalanchego/network/p2p"
"github.com/ava-labs/avalanchego/network/p2p/gossip"
"github.com/ava-labs/avalanchego/snow/engine/common"
"github.com/ava-labs/avalanchego/utils/logging"
"github.com/prometheus/client_golang/prometheus"
)
var _ p2p.Handler = (*txGossipHandler)(nil)
func NewTxGossipHandler[T gossip.Gossipable](
log logging.Logger,
marshaller gossip.Marshaller[T],
mempool gossip.Set[T],
metrics gossip.Metrics,
maxMessageSize int,
throttlingPeriod time.Duration,
requestsPerPeer float64,
validators p2p.ValidatorSet,
registerer prometheus.Registerer,
namespace string,
) (*txGossipHandler, error) {
// push gossip messages can be handled from any peer
handler := gossip.NewHandler(
log,
marshaller,
mempool,
metrics,
maxMessageSize,
)
throttledHandler, err := p2p.NewDynamicThrottlerHandler(
log,
handler,
validators,
throttlingPeriod,
requestsPerPeer,
registerer,
namespace,
)
if err != nil {
return nil, fmt.Errorf("failed to initialize throttler handler: %w", err)
}
// pull gossip requests are filtered by validators and are throttled
// to prevent spamming
validatorHandler := p2p.NewValidatorHandler(
throttledHandler,
validators,
log,
)
return &txGossipHandler{
appGossipHandler: handler,
appRequestHandler: validatorHandler,
}, nil
}
type txGossipHandler struct {
appGossipHandler p2p.Handler
appRequestHandler p2p.Handler
}
func (t *txGossipHandler) AppGossip(ctx context.Context, nodeID ids.NodeID, gossipBytes []byte) {
t.appGossipHandler.AppGossip(ctx, nodeID, gossipBytes)
}
func (t *txGossipHandler) AppRequest(ctx context.Context, nodeID ids.NodeID, deadline time.Time, requestBytes []byte) ([]byte, *common.AppError) {
return t.appRequestHandler.AppRequest(ctx, nodeID, deadline, requestBytes)
}
@@ -0,0 +1,690 @@
// Copyright (C) 2019-2025, Ava Labs, Inc. All rights reserved.
// See the file LICENSE for licensing terms.
package warp
import (
"context"
"math"
"testing"
"github.com/ava-labs/avalanchego/ids"
"github.com/ava-labs/avalanchego/snow/validators"
"github.com/ava-labs/avalanchego/snow/validators/validatorsmock"
"github.com/ava-labs/avalanchego/utils/constants"
"github.com/ava-labs/avalanchego/utils/crypto/bls"
"github.com/ava-labs/avalanchego/utils/set"
"github.com/stretchr/testify/require"
"go.uber.org/mock/gomock"
avalancheWarp "github.com/ava-labs/avalanchego/vms/platformvm/warp"
)
type signatureTest struct {
name string
stateF func(*gomock.Controller) validators.State
quorumNum uint64
quorumDen uint64
msgF func(*require.Assertions) *avalancheWarp.Message
verifyErr error
canonicalErr error
}
// This test copies the test coverage from https://github.com/ava-labs/avalanchego/blob/0117ab96/vms/platformvm/warp/signature_test.go#L137.
// These tests are only expected to fail if there is a breaking change in AvalancheGo that unexpectedly changes behavior.
func TestSignatureVerification(t *testing.T) {
tests := []signatureTest{
{
name: "can't get subnetID",
stateF: func(ctrl *gomock.Controller) validators.State {
state := validatorsmock.NewState(ctrl)
state.EXPECT().GetSubnetID(gomock.Any(), sourceChainID).Return(sourceSubnetID, errTest)
return state
},
quorumNum: 1,
quorumDen: 2,
msgF: func(require *require.Assertions) *avalancheWarp.Message {
unsignedMsg, err := avalancheWarp.NewUnsignedMessage(
constants.UnitTestID,
sourceChainID,
addressedPayloadBytes,
)
require.NoError(err)
msg, err := avalancheWarp.NewMessage(
unsignedMsg,
&avalancheWarp.BitSetSignature{},
)
require.NoError(err)
return msg
},
canonicalErr: errTest,
},
{
name: "can't get validator set",
stateF: func(ctrl *gomock.Controller) validators.State {
state := validatorsmock.NewState(ctrl)
state.EXPECT().GetSubnetID(gomock.Any(), sourceChainID).Return(sourceSubnetID, nil)
state.EXPECT().GetValidatorSet(gomock.Any(), pChainHeight, sourceSubnetID).Return(nil, errTest)
return state
},
quorumNum: 1,
quorumDen: 2,
msgF: func(require *require.Assertions) *avalancheWarp.Message {
unsignedMsg, err := avalancheWarp.NewUnsignedMessage(
constants.UnitTestID,
sourceChainID,
addressedPayloadBytes,
)
require.NoError(err)
msg, err := avalancheWarp.NewMessage(
unsignedMsg,
&avalancheWarp.BitSetSignature{},
)
require.NoError(err)
return msg
},
canonicalErr: errTest,
},
{
name: "weight overflow",
stateF: func(ctrl *gomock.Controller) validators.State {
state := validatorsmock.NewState(ctrl)
state.EXPECT().GetSubnetID(gomock.Any(), sourceChainID).Return(sourceSubnetID, nil)
state.EXPECT().GetValidatorSet(gomock.Any(), pChainHeight, sourceSubnetID).Return(map[ids.NodeID]*validators.GetValidatorOutput{
testVdrs[0].nodeID: {
NodeID: testVdrs[0].nodeID,
PublicKey: testVdrs[0].vdr.PublicKey,
Weight: math.MaxUint64,
},
testVdrs[1].nodeID: {
NodeID: testVdrs[1].nodeID,
PublicKey: testVdrs[1].vdr.PublicKey,
Weight: math.MaxUint64,
},
}, nil)
return state
},
quorumNum: 1,
quorumDen: 2,
msgF: func(require *require.Assertions) *avalancheWarp.Message {
unsignedMsg, err := avalancheWarp.NewUnsignedMessage(
constants.UnitTestID,
sourceChainID,
addressedPayloadBytes,
)
require.NoError(err)
msg, err := avalancheWarp.NewMessage(
unsignedMsg,
&avalancheWarp.BitSetSignature{
Signers: make([]byte, 8),
},
)
require.NoError(err)
return msg
},
canonicalErr: avalancheWarp.ErrWeightOverflow,
},
{
name: "invalid bit set index",
stateF: func(ctrl *gomock.Controller) validators.State {
state := validatorsmock.NewState(ctrl)
state.EXPECT().GetSubnetID(gomock.Any(), sourceChainID).Return(sourceSubnetID, nil)
state.EXPECT().GetValidatorSet(gomock.Any(), pChainHeight, sourceSubnetID).Return(vdrs, nil)
return state
},
quorumNum: 1,
quorumDen: 2,
msgF: func(require *require.Assertions) *avalancheWarp.Message {
unsignedMsg, err := avalancheWarp.NewUnsignedMessage(
constants.UnitTestID,
sourceChainID,
addressedPayloadBytes,
)
require.NoError(err)
msg, err := avalancheWarp.NewMessage(
unsignedMsg,
&avalancheWarp.BitSetSignature{
Signers: make([]byte, 1),
Signature: [bls.SignatureLen]byte{},
},
)
require.NoError(err)
return msg
},
verifyErr: avalancheWarp.ErrInvalidBitSet,
},
{
name: "unknown index",
stateF: func(ctrl *gomock.Controller) validators.State {
state := validatorsmock.NewState(ctrl)
state.EXPECT().GetSubnetID(gomock.Any(), sourceChainID).Return(sourceSubnetID, nil)
state.EXPECT().GetValidatorSet(gomock.Any(), pChainHeight, sourceSubnetID).Return(vdrs, nil)
return state
},
quorumNum: 1,
quorumDen: 2,
msgF: func(require *require.Assertions) *avalancheWarp.Message {
unsignedMsg, err := avalancheWarp.NewUnsignedMessage(
constants.UnitTestID,
sourceChainID,
addressedPayloadBytes,
)
require.NoError(err)
signers := set.NewBits()
signers.Add(3) // vdr oob
msg, err := avalancheWarp.NewMessage(
unsignedMsg,
&avalancheWarp.BitSetSignature{
Signers: signers.Bytes(),
Signature: [bls.SignatureLen]byte{},
},
)
require.NoError(err)
return msg
},
verifyErr: avalancheWarp.ErrUnknownValidator,
},
{
name: "insufficient weight",
stateF: func(ctrl *gomock.Controller) validators.State {
state := validatorsmock.NewState(ctrl)
state.EXPECT().GetSubnetID(gomock.Any(), sourceChainID).Return(sourceSubnetID, nil)
state.EXPECT().GetValidatorSet(gomock.Any(), pChainHeight, sourceSubnetID).Return(vdrs, nil)
return state
},
quorumNum: 1,
quorumDen: 1,
msgF: func(require *require.Assertions) *avalancheWarp.Message {
unsignedMsg, err := avalancheWarp.NewUnsignedMessage(
constants.UnitTestID,
sourceChainID,
addressedPayloadBytes,
)
require.NoError(err)
// [signers] has weight from [vdr[0], vdr[1]],
// which is 6, which is less than 9
signers := set.NewBits()
signers.Add(0)
signers.Add(1)
unsignedBytes := unsignedMsg.Bytes()
vdr0Sig, err := testVdrs[0].sk.Sign(unsignedBytes)
require.NoError(err)
vdr1Sig, err := testVdrs[1].sk.Sign(unsignedBytes)
require.NoError(err)
aggSig, err := bls.AggregateSignatures([]*bls.Signature{vdr0Sig, vdr1Sig})
require.NoError(err)
aggSigBytes := [bls.SignatureLen]byte{}
copy(aggSigBytes[:], bls.SignatureToBytes(aggSig))
msg, err := avalancheWarp.NewMessage(
unsignedMsg,
&avalancheWarp.BitSetSignature{
Signers: signers.Bytes(),
Signature: aggSigBytes,
},
)
require.NoError(err)
return msg
},
verifyErr: avalancheWarp.ErrInsufficientWeight,
},
{
name: "can't parse sig",
stateF: func(ctrl *gomock.Controller) validators.State {
state := validatorsmock.NewState(ctrl)
state.EXPECT().GetSubnetID(gomock.Any(), sourceChainID).Return(sourceSubnetID, nil)
state.EXPECT().GetValidatorSet(gomock.Any(), pChainHeight, sourceSubnetID).Return(vdrs, nil)
return state
},
quorumNum: 1,
quorumDen: 2,
msgF: func(require *require.Assertions) *avalancheWarp.Message {
unsignedMsg, err := avalancheWarp.NewUnsignedMessage(
constants.UnitTestID,
sourceChainID,
addressedPayloadBytes,
)
require.NoError(err)
signers := set.NewBits()
signers.Add(0)
signers.Add(1)
msg, err := avalancheWarp.NewMessage(
unsignedMsg,
&avalancheWarp.BitSetSignature{
Signers: signers.Bytes(),
Signature: [bls.SignatureLen]byte{},
},
)
require.NoError(err)
return msg
},
verifyErr: avalancheWarp.ErrParseSignature,
},
{
name: "no validators",
stateF: func(ctrl *gomock.Controller) validators.State {
state := validatorsmock.NewState(ctrl)
state.EXPECT().GetSubnetID(gomock.Any(), sourceChainID).Return(sourceSubnetID, nil)
state.EXPECT().GetValidatorSet(gomock.Any(), pChainHeight, sourceSubnetID).Return(nil, nil)
return state
},
quorumNum: 1,
quorumDen: 2,
msgF: func(require *require.Assertions) *avalancheWarp.Message {
unsignedMsg, err := avalancheWarp.NewUnsignedMessage(
constants.UnitTestID,
sourceChainID,
addressedPayloadBytes,
)
require.NoError(err)
unsignedBytes := unsignedMsg.Bytes()
vdr0Sig, err := testVdrs[0].sk.Sign(unsignedBytes)
require.NoError(err)
aggSigBytes := [bls.SignatureLen]byte{}
copy(aggSigBytes[:], bls.SignatureToBytes(vdr0Sig))
msg, err := avalancheWarp.NewMessage(
unsignedMsg,
&avalancheWarp.BitSetSignature{
Signers: nil,
Signature: aggSigBytes,
},
)
require.NoError(err)
return msg
},
verifyErr: bls.ErrNoPublicKeys,
},
{
name: "invalid signature (substitute)",
stateF: func(ctrl *gomock.Controller) validators.State {
state := validatorsmock.NewState(ctrl)
state.EXPECT().GetSubnetID(gomock.Any(), sourceChainID).Return(sourceSubnetID, nil)
state.EXPECT().GetValidatorSet(gomock.Any(), pChainHeight, sourceSubnetID).Return(vdrs, nil)
return state
},
quorumNum: 3,
quorumDen: 5,
msgF: func(require *require.Assertions) *avalancheWarp.Message {
unsignedMsg, err := avalancheWarp.NewUnsignedMessage(
constants.UnitTestID,
sourceChainID,
addressedPayloadBytes,
)
require.NoError(err)
signers := set.NewBits()
signers.Add(0)
signers.Add(1)
unsignedBytes := unsignedMsg.Bytes()
vdr0Sig, err := testVdrs[0].sk.Sign(unsignedBytes)
require.NoError(err)
// Give sig from vdr[2] even though the bit vector says it
// should be from vdr[1]
vdr2Sig, err := testVdrs[2].sk.Sign(unsignedBytes)
require.NoError(err)
aggSig, err := bls.AggregateSignatures([]*bls.Signature{vdr0Sig, vdr2Sig})
require.NoError(err)
aggSigBytes := [bls.SignatureLen]byte{}
copy(aggSigBytes[:], bls.SignatureToBytes(aggSig))
msg, err := avalancheWarp.NewMessage(
unsignedMsg,
&avalancheWarp.BitSetSignature{
Signers: signers.Bytes(),
Signature: aggSigBytes,
},
)
require.NoError(err)
return msg
},
verifyErr: avalancheWarp.ErrInvalidSignature,
},
{
name: "invalid signature (missing one)",
stateF: func(ctrl *gomock.Controller) validators.State {
state := validatorsmock.NewState(ctrl)
state.EXPECT().GetSubnetID(gomock.Any(), sourceChainID).Return(sourceSubnetID, nil)
state.EXPECT().GetValidatorSet(gomock.Any(), pChainHeight, sourceSubnetID).Return(vdrs, nil)
return state
},
quorumNum: 3,
quorumDen: 5,
msgF: func(require *require.Assertions) *avalancheWarp.Message {
unsignedMsg, err := avalancheWarp.NewUnsignedMessage(
constants.UnitTestID,
sourceChainID,
addressedPayloadBytes,
)
require.NoError(err)
signers := set.NewBits()
signers.Add(0)
signers.Add(1)
unsignedBytes := unsignedMsg.Bytes()
vdr0Sig, err := testVdrs[0].sk.Sign(unsignedBytes)
require.NoError(err)
// Don't give the sig from vdr[1]
aggSigBytes := [bls.SignatureLen]byte{}
copy(aggSigBytes[:], bls.SignatureToBytes(vdr0Sig))
msg, err := avalancheWarp.NewMessage(
unsignedMsg,
&avalancheWarp.BitSetSignature{
Signers: signers.Bytes(),
Signature: aggSigBytes,
},
)
require.NoError(err)
return msg
},
verifyErr: avalancheWarp.ErrInvalidSignature,
},
{
name: "invalid signature (extra one)",
stateF: func(ctrl *gomock.Controller) validators.State {
state := validatorsmock.NewState(ctrl)
state.EXPECT().GetSubnetID(gomock.Any(), sourceChainID).Return(sourceSubnetID, nil)
state.EXPECT().GetValidatorSet(gomock.Any(), pChainHeight, sourceSubnetID).Return(vdrs, nil)
return state
},
quorumNum: 3,
quorumDen: 5,
msgF: func(require *require.Assertions) *avalancheWarp.Message {
unsignedMsg, err := avalancheWarp.NewUnsignedMessage(
constants.UnitTestID,
sourceChainID,
addressedPayloadBytes,
)
require.NoError(err)
signers := set.NewBits()
signers.Add(0)
signers.Add(1)
unsignedBytes := unsignedMsg.Bytes()
vdr0Sig, err := testVdrs[0].sk.Sign(unsignedBytes)
require.NoError(err)
vdr1Sig, err := testVdrs[1].sk.Sign(unsignedBytes)
require.NoError(err)
// Give sig from vdr[2] even though the bit vector doesn't have
// it
vdr2Sig, err := testVdrs[2].sk.Sign(unsignedBytes)
require.NoError(err)
aggSig, err := bls.AggregateSignatures([]*bls.Signature{vdr0Sig, vdr1Sig, vdr2Sig})
require.NoError(err)
aggSigBytes := [bls.SignatureLen]byte{}
copy(aggSigBytes[:], bls.SignatureToBytes(aggSig))
msg, err := avalancheWarp.NewMessage(
unsignedMsg,
&avalancheWarp.BitSetSignature{
Signers: signers.Bytes(),
Signature: aggSigBytes,
},
)
require.NoError(err)
return msg
},
verifyErr: avalancheWarp.ErrInvalidSignature,
},
{
name: "valid signature",
stateF: func(ctrl *gomock.Controller) validators.State {
state := validatorsmock.NewState(ctrl)
state.EXPECT().GetSubnetID(gomock.Any(), sourceChainID).Return(sourceSubnetID, nil)
state.EXPECT().GetValidatorSet(gomock.Any(), pChainHeight, sourceSubnetID).Return(vdrs, nil)
return state
},
quorumNum: 1,
quorumDen: 2,
msgF: func(require *require.Assertions) *avalancheWarp.Message {
unsignedMsg, err := avalancheWarp.NewUnsignedMessage(
constants.UnitTestID,
sourceChainID,
addressedPayloadBytes,
)
require.NoError(err)
// [signers] has weight from [vdr[1], vdr[2]],
// which is 6, which is greater than 4.5
signers := set.NewBits()
signers.Add(1)
signers.Add(2)
unsignedBytes := unsignedMsg.Bytes()
vdr1Sig, err := testVdrs[1].sk.Sign(unsignedBytes)
require.NoError(err)
vdr2Sig, err := testVdrs[2].sk.Sign(unsignedBytes)
require.NoError(err)
aggSig, err := bls.AggregateSignatures([]*bls.Signature{vdr1Sig, vdr2Sig})
require.NoError(err)
aggSigBytes := [bls.SignatureLen]byte{}
copy(aggSigBytes[:], bls.SignatureToBytes(aggSig))
msg, err := avalancheWarp.NewMessage(
unsignedMsg,
&avalancheWarp.BitSetSignature{
Signers: signers.Bytes(),
Signature: aggSigBytes,
},
)
require.NoError(err)
return msg
},
verifyErr: nil,
},
{
name: "valid signature (boundary)",
stateF: func(ctrl *gomock.Controller) validators.State {
state := validatorsmock.NewState(ctrl)
state.EXPECT().GetSubnetID(gomock.Any(), sourceChainID).Return(sourceSubnetID, nil)
state.EXPECT().GetValidatorSet(gomock.Any(), pChainHeight, sourceSubnetID).Return(vdrs, nil)
return state
},
quorumNum: 2,
quorumDen: 3,
msgF: func(require *require.Assertions) *avalancheWarp.Message {
unsignedMsg, err := avalancheWarp.NewUnsignedMessage(
constants.UnitTestID,
sourceChainID,
addressedPayloadBytes,
)
require.NoError(err)
// [signers] has weight from [vdr[1], vdr[2]],
// which is 6, which meets the minimum 6
signers := set.NewBits()
signers.Add(1)
signers.Add(2)
unsignedBytes := unsignedMsg.Bytes()
vdr1Sig, err := testVdrs[1].sk.Sign(unsignedBytes)
require.NoError(err)
vdr2Sig, err := testVdrs[2].sk.Sign(unsignedBytes)
require.NoError(err)
aggSig, err := bls.AggregateSignatures([]*bls.Signature{vdr1Sig, vdr2Sig})
require.NoError(err)
aggSigBytes := [bls.SignatureLen]byte{}
copy(aggSigBytes[:], bls.SignatureToBytes(aggSig))
msg, err := avalancheWarp.NewMessage(
unsignedMsg,
&avalancheWarp.BitSetSignature{
Signers: signers.Bytes(),
Signature: aggSigBytes,
},
)
require.NoError(err)
return msg
},
verifyErr: nil,
},
{
name: "valid signature (missing key)",
stateF: func(ctrl *gomock.Controller) validators.State {
state := validatorsmock.NewState(ctrl)
state.EXPECT().GetSubnetID(gomock.Any(), sourceChainID).Return(sourceSubnetID, nil)
state.EXPECT().GetValidatorSet(gomock.Any(), pChainHeight, sourceSubnetID).Return(map[ids.NodeID]*validators.GetValidatorOutput{
testVdrs[0].nodeID: {
NodeID: testVdrs[0].nodeID,
PublicKey: nil,
Weight: testVdrs[0].vdr.Weight,
},
testVdrs[1].nodeID: {
NodeID: testVdrs[1].nodeID,
PublicKey: testVdrs[1].vdr.PublicKey,
Weight: testVdrs[1].vdr.Weight,
},
testVdrs[2].nodeID: {
NodeID: testVdrs[2].nodeID,
PublicKey: testVdrs[2].vdr.PublicKey,
Weight: testVdrs[2].vdr.Weight,
},
}, nil)
return state
},
quorumNum: 1,
quorumDen: 3,
msgF: func(require *require.Assertions) *avalancheWarp.Message {
unsignedMsg, err := avalancheWarp.NewUnsignedMessage(
constants.UnitTestID,
sourceChainID,
addressedPayloadBytes,
)
require.NoError(err)
// [signers] has weight from [vdr2, vdr3],
// which is 6, which is greater than 3
signers := set.NewBits()
// Note: the bits are shifted because vdr[0]'s key was zeroed
signers.Add(0) // vdr[1]
signers.Add(1) // vdr[2]
unsignedBytes := unsignedMsg.Bytes()
vdr1Sig, err := testVdrs[1].sk.Sign(unsignedBytes)
require.NoError(err)
vdr2Sig, err := testVdrs[2].sk.Sign(unsignedBytes)
require.NoError(err)
aggSig, err := bls.AggregateSignatures([]*bls.Signature{vdr1Sig, vdr2Sig})
require.NoError(err)
aggSigBytes := [bls.SignatureLen]byte{}
copy(aggSigBytes[:], bls.SignatureToBytes(aggSig))
msg, err := avalancheWarp.NewMessage(
unsignedMsg,
&avalancheWarp.BitSetSignature{
Signers: signers.Bytes(),
Signature: aggSigBytes,
},
)
require.NoError(err)
return msg
},
verifyErr: nil,
},
{
name: "valid signature (duplicate key)",
stateF: func(ctrl *gomock.Controller) validators.State {
state := validatorsmock.NewState(ctrl)
state.EXPECT().GetSubnetID(gomock.Any(), sourceChainID).Return(sourceSubnetID, nil)
state.EXPECT().GetValidatorSet(gomock.Any(), pChainHeight, sourceSubnetID).Return(map[ids.NodeID]*validators.GetValidatorOutput{
testVdrs[0].nodeID: {
NodeID: testVdrs[0].nodeID,
PublicKey: nil,
Weight: testVdrs[0].vdr.Weight,
},
testVdrs[1].nodeID: {
NodeID: testVdrs[1].nodeID,
PublicKey: testVdrs[2].vdr.PublicKey,
Weight: testVdrs[1].vdr.Weight,
},
testVdrs[2].nodeID: {
NodeID: testVdrs[2].nodeID,
PublicKey: testVdrs[2].vdr.PublicKey,
Weight: testVdrs[2].vdr.Weight,
},
}, nil)
return state
},
quorumNum: 2,
quorumDen: 3,
msgF: func(require *require.Assertions) *avalancheWarp.Message {
unsignedMsg, err := avalancheWarp.NewUnsignedMessage(
constants.UnitTestID,
sourceChainID,
addressedPayloadBytes,
)
require.NoError(err)
// [signers] has weight from [vdr2, vdr3],
// which is 6, which meets the minimum 6
signers := set.NewBits()
// Note: the bits are shifted because vdr[0]'s key was zeroed
// Note: vdr[1] and vdr[2] were combined because of a shared pk
signers.Add(0) // vdr[1] + vdr[2]
unsignedBytes := unsignedMsg.Bytes()
// Because vdr[1] and vdr[2] share a key, only one of them sign.
vdr2Sig, err := testVdrs[2].sk.Sign(unsignedBytes)
require.NoError(err)
aggSigBytes := [bls.SignatureLen]byte{}
copy(aggSigBytes[:], bls.SignatureToBytes(vdr2Sig))
msg, err := avalancheWarp.NewMessage(
unsignedMsg,
&avalancheWarp.BitSetSignature{
Signers: signers.Bytes(),
Signature: aggSigBytes,
},
)
require.NoError(err)
return msg
},
verifyErr: nil,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
require := require.New(t)
ctrl := gomock.NewController(t)
defer ctrl.Finish()
msg := tt.msgF(require)
pChainState := tt.stateF(ctrl)
validatorSet, err := avalancheWarp.GetCanonicalValidatorSetFromChainID(
context.Background(),
pChainState,
pChainHeight,
msg.UnsignedMessage.SourceChainID,
)
require.ErrorIs(err, tt.canonicalErr)
if err != nil {
return
}
err = msg.Signature.Verify(
&msg.UnsignedMessage,
constants.UnitTestID,
validatorSet,
tt.quorumNum,
tt.quorumDen,
)
require.ErrorIs(err, tt.verifyErr)
})
}
}
@@ -0,0 +1,44 @@
// Copyright (C) 2019-2025, Lux Industries, Inc. All rights reserved.
// See the file LICENSE for licensing terms.
package testutils
import (
"github.com/luxfi/geth/common"
"github.com/luxfi/geth/core/tracing"
"github.com/holiman/uint256"
"github.com/luxfi/evm/core/extstate"
"github.com/luxfi/evm/precompile/contract"
)
// StateDBAdapter wraps *extstate.StateDB to implement contract.StateDB
type StateDBAdapter struct {
*extstate.StateDB
}
// AddBalance adapts the AddBalance method to match the contract.StateDB interface
func (s *StateDBAdapter) AddBalance(address common.Address, amount *uint256.Int) {
// Call the underlying StateDB.AddBalance with a default BalanceChangeReason
s.StateDB.AddBalance(address, amount, tracing.BalanceChangeUnspecified)
}
// SetNonce implements the contract.StateDB interface
func (s *StateDBAdapter) SetNonce(address common.Address, nonce uint64) {
s.StateDB.SetNonce(address, nonce, tracing.NonceChangeUnspecified)
}
// SetState implements the contract.StateDB interface
func (s *StateDBAdapter) SetState(address common.Address, key, value common.Hash) {
// The underlying SetState returns the old value, but contract.StateDB expects no return
_ = s.StateDB.SetState(address, key, value)
}
// GetPredicateStorageSlots implements the contract.StateDB interface
func (s *StateDBAdapter) GetPredicateStorageSlots(address common.Address, index int) ([]byte, bool) {
return s.StateDB.GetPredicateStorageSlots(address, index)
}
// WrapStateDB wraps an *extstate.StateDB to implement contract.StateDB
func WrapStateDB(state *extstate.StateDB) contract.StateDB {
return &StateDBAdapter{StateDB: state}
}
+273
View File
@@ -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(_ 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 (*AccountTrie) UpdateContractCode(_ common.Address, _ common.Hash, _ []byte) error {
return nil
}
// GetKey implements state.Trie.
func (*AccountTrie) GetKey(_ []byte) []byte {
return nil // Not implemented, as this is only used in APIs
}
// NodeIterator implements state.Trie.
func (*AccountTrie) NodeIterator(_ []byte) (trie.NodeIterator, error) {
return nil, errors.New("NodeIterator not implemented for Firewood")
}
// Prove implements state.Trie.
func (*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
}
+593
View File
@@ -0,0 +1,593 @@
// Copyright (C) 2019-2025, Ava Labs, Inc. All rights reserved.
// See the file LICENSE for licensing terms.
package firewood
import (
"errors"
"fmt"
"os"
"path/filepath"
"sync"
"time"
"github.com/ava-labs/firewood-go-ethhash/ffi"
"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"
)
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 {
log.Crit("firewood: error validating config", "error", err)
}
fw, err := ffi.New(config.FilePath, fwConfig)
if err != nil {
log.Crit("firewood: error creating firewood database", "error", err)
}
currentRoot, err := fw.Root()
if err != nil {
log.Crit("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, errors.New("firewood database file path must be set")
}
// Check that the directory exists
dir := filepath.Dir(trieConfig.FilePath)
_, err := os.Stat(dir)
if err != nil {
if !os.IsNotExist(err) {
return nil, fmt.Errorf("error checking database directory: %w", err)
}
log.Info("Database directory not found, creating", "path", dir)
if err := os.MkdirAll(dir, 0o755); err != nil {
return nil, fmt.Errorf("error creating database directory: %w", err)
}
}
// Create the Firewood config from the provided config.
config := &ffi.Config{
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 (*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 := 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 := 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 (*Database) Size() (common.StorageSize, common.StorageSize) {
return 0, 0
}
// This isn't called anywhere in subnet-evm
func (*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 (*Database) Dereference(common.Hash) {
}
// Firewood does not support this.
func (*Database) Cap(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 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: %w", 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: %w", 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
}
+41
View File
@@ -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(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 (*StorageTrie) Copy() *StorageTrie {
return nil
}