diff --git a/firewood/firewood.go b/firewood/firewood.go new file mode 100644 index 000000000000..adf771f57c09 --- /dev/null +++ b/firewood/firewood.go @@ -0,0 +1,191 @@ +// Copyright (C) 2019-2025, Ava Labs, Inc. All rights reserved. +// See the file LICENSE for licensing terms. + +package firewood + +import ( + "context" + "fmt" + + "github.com/ava-labs/firewood-go-ethhash/ffi" + + "github.com/ava-labs/avalanchego/database" + "github.com/ava-labs/avalanchego/ids" +) + +const PrefixDelimiter = '/' + +var ( + reservedPrefix = []byte("reserved") + heightKey = []byte("height") + appPrefix = []byte("app") +) + +type changes struct { + Keys [][]byte + Vals [][]byte + + kv map[string][]byte +} + +func (c *changes) Put(key []byte, val []byte) { + c.put(key, val, false) +} + +func (c *changes) Delete(key []byte) { + c.put(key, nil, true) +} + +// del is true if this is a deletion +func (c *changes) put(key []byte, val []byte, del bool) { + if val == nil && !del { + // Firewood treats nil values as deletions, so we use a workaround to use + // an empty slice until firewood supports this. + val = []byte{} + } + + c.Keys = append(c.Keys, key) + c.Vals = append(c.Vals, val) + + if c.kv == nil { + c.kv = make(map[string][]byte) + } + + c.kv[string(key)] = val +} + +func (c *changes) Get(key []byte) ([]byte, bool) { + v, ok := c.kv[string(key)] + return v, ok +} + +type DB struct { + db *ffi.Database + height uint64 + heightInitialized bool + heightKey []byte + pending changes +} + +func New(path string) (*DB, error) { + db, err := ffi.New(path, ffi.DefaultConfig()) + if err != nil { + return nil, fmt.Errorf("opening firewood db: %w", err) + } + + heightKey := Prefix(reservedPrefix, heightKey) + heightBytes, err := db.Get(heightKey) + if err != nil { + return nil, fmt.Errorf("getting height: %w", err) + } + + var height uint64 + if heightBytes != nil { + height, err = database.ParseUInt64(heightBytes) + if err != nil { + return nil, fmt.Errorf("parsing height: %w", err) + } + } + + return &DB{ + db: db, + height: height, + heightInitialized: heightBytes != nil, + heightKey: heightKey, + }, nil +} + +// Get returns a key value pair or [database.ErrNotFound] if `key` is not in the +// [DB]. +func (db *DB) Get(key []byte) ([]byte, error) { + key = Prefix(appPrefix, key) + + if val, ok := db.pending.Get(key); ok { + if val == nil { + return nil, database.ErrNotFound + } + + return val, nil + } + + val, err := db.db.Get(key) + if val == nil && err == nil { + return nil, database.ErrNotFound + } + + return val, err +} + +// Put inserts a key value pair into [DB]. +func (db *DB) Put(key []byte, val []byte) { + db.pending.Put(Prefix(appPrefix, key), val) +} + +func (db *DB) Delete(key []byte) { + db.pending.Delete(Prefix(appPrefix, key)) +} + +// Height returns the last height of [DB] written to by [DB.Flush]. +// +// If this returns false, the height has not been initialized yet. +func (db *DB) Height() (uint64, bool) { + if !db.heightInitialized { + return 0, false + } + + return db.height, true +} + +// Root returns the merkle root of the state on disk ignoring pending writes. +func (db *DB) Root() (ids.ID, error) { + root, err := db.db.Root() + if err != nil { + return ids.ID{}, err + } + + return ids.ID(root[:]), nil +} + +// Abort cancels all pending writes. +func (db *DB) Abort() { + db.pending = changes{} +} + +// Flush flushes pending writes to disk and increments [DB.Height]. +func (db *DB) Flush() error { + if !db.heightInitialized { + db.heightInitialized = true + } else { + db.height++ + } + + db.pending.Put(db.heightKey, database.PackUInt64(db.height)) + + p, err := db.db.Propose(db.pending.Keys, db.pending.Vals) + if err != nil { + return fmt.Errorf("proposing changes: %w", err) + } + + if err := p.Commit(); err != nil { + return fmt.Errorf("committing changes: %w", err) + } + + db.pending = changes{} + + return nil +} + +func (db *DB) Close(ctx context.Context) error { + return db.db.Close(ctx) +} + +// Prefix prefixes `key` with `prefix` + [PrefixDelimiter]. +func Prefix(prefix []byte, key []byte) []byte { + k := make([]byte, len(prefix)+1+len(key)) + + copy(k, prefix) + k[len(prefix)] = PrefixDelimiter + copy(k[len(prefix)+1:], key) + + return k +} diff --git a/firewood/firewood_test.go b/firewood/firewood_test.go new file mode 100644 index 000000000000..637eb421dde2 --- /dev/null +++ b/firewood/firewood_test.go @@ -0,0 +1,314 @@ +// Copyright (C) 2019-2025, Ava Labs, Inc. All rights reserved. +// See the file LICENSE for licensing terms. + +package firewood + +import ( + "path/filepath" + "testing" + + "github.com/stretchr/testify/require" + + "github.com/ava-labs/avalanchego/database" +) + +func TestDBPut(t *testing.T) { + tests := []struct { + name string + key []byte + val []byte + want []byte + }{ + { + name: "nil key", + key: nil, + val: []byte("foo"), + want: []byte("foo"), + }, + { + name: "empty key", + key: []byte{}, + val: []byte{}, + want: []byte{}, + }, + { + name: "empty val", + key: []byte("foo"), + val: []byte{}, + want: []byte{}, + }, + { + name: "nil val", + key: []byte("foo"), + val: nil, + want: []byte{}, + }, + { + name: "non-empty val", + key: []byte("foo"), + val: []byte("bar"), + want: []byte("bar"), + }, + { + name: "write to reserved key", + key: Prefix(reservedPrefix, heightKey), + val: []byte("foo"), + want: []byte("foo"), + }, + { + name: "key is prefix delimiter", + key: []byte{PrefixDelimiter}, + val: []byte("foo"), + want: []byte("foo"), + }, + { + name: "key has prefix delimiter", + key: []byte("foo" + string(PrefixDelimiter)), + val: []byte("foo"), + want: []byte("foo"), + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + db, err := New(filepath.Join(t.TempDir(), "firewood.test")) + require.NoError(t, err) + + db.Put(tt.key, tt.val) + + got, err := db.Get(tt.key) + require.NoError(t, err) + require.Equal(t, tt.want, got) + + require.NoError(t, db.Flush()) + + got, err = db.Get(tt.key) + require.NoError(t, err) + require.Equal(t, tt.want, got) + }) + } +} + +func TestDBDelete(t *testing.T) { + db, err := New(filepath.Join(t.TempDir(), "firewood.test")) + require.NoError(t, err) + + key := []byte("foo") + db.Put(key, []byte("bar")) + db.Delete([]byte("foo")) + + _, err = db.Get(key) + require.ErrorIs(t, err, database.ErrNotFound) + + require.NoError(t, db.Flush()) + _, err = db.Get(key) + require.ErrorIs(t, err, database.ErrNotFound) +} + +func TestDBHeight(t *testing.T) { + db, err := New(filepath.Join(t.TempDir(), "firewood.test")) + require.NoError(t, err) + + _, ok := db.Height() + require.False(t, ok) + + for i := range 5 { + db.Put([]byte("foo"), []byte("bar")) + require.NoError(t, db.Flush()) + + height, ok := db.Height() + require.True(t, ok) + require.Equal(t, uint64(i), height) + } +} + +func TestDBPersistence(t *testing.T) { + dir := t.TempDir() + + db, err := New(filepath.Join(dir, "firewood.test")) + require.NoError(t, err) + + key := []byte("foo") + val := []byte("bar") + db.Put(key, val) + + require.NoError(t, db.Flush()) + + height, ok := db.Height() + require.True(t, ok) + require.Zero(t, height) + + got, err := db.Get(key) + require.NoError(t, err) + require.Equal(t, val, got) + + require.NoError(t, db.Close(t.Context())) + + db, err = New(filepath.Join(dir, "firewood.test")) + require.NoError(t, err) + + height, ok = db.Height() + require.True(t, ok) + require.Zero(t, height) + + got, err = db.Get(key) + require.NoError(t, err) + require.Equal(t, val, got) +} + +func TestPrefix(t *testing.T) { + tests := []struct { + name string + prefix []byte + key []byte + want []byte + }{ + { + name: "nil prefix", + prefix: nil, + key: []byte("foo"), + want: []byte("/foo"), + }, + { + name: "empty prefix", + prefix: []byte{}, + key: []byte("foo"), + want: []byte("/foo"), + }, + { + name: "non-empty prefix", + prefix: []byte("foo"), + key: []byte("bar"), + want: []byte("foo/bar"), + }, + { + name: "prefix is delimiter", + prefix: []byte{PrefixDelimiter}, + key: []byte("bar"), + want: []byte("//bar"), + }, + { + name: "prefix contains delimiter", + prefix: []byte("foo" + string(PrefixDelimiter)), + key: []byte("bar"), + want: []byte("foo//bar"), + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + require.Equal(t, tt.want, Prefix(tt.prefix, tt.key)) + }) + } +} + +func TestDBAbort_Put(t *testing.T) { + type put struct { + key []byte + val []byte + } + + tests := []struct { + name string + puts []put + abortedPut put + wantVal []byte + wantErr error + }{ + { + name: "abort key create", + abortedPut: put{key: []byte("foo"), val: []byte("bar")}, + wantErr: database.ErrNotFound, + }, + { + name: "abort key update", + puts: []put{ + {key: []byte("foo"), val: []byte("bar")}, + }, + abortedPut: put{key: []byte("foo"), val: []byte("bar")}, + wantVal: []byte("bar"), + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + db, err := New(filepath.Join(t.TempDir(), "firewood.test")) + require.NoError(t, err) + + for _, p := range tt.puts { + db.Put(p.key, p.val) + } + + require.NoError(t, db.Flush()) + + db.Put(tt.abortedPut.key, tt.abortedPut.val) + db.Abort() + + gotVal, err := db.Get(tt.abortedPut.key) + require.ErrorIs(t, err, tt.wantErr) + require.Equal(t, tt.wantVal, gotVal) + }) + } +} + +func TestDBAbort_Delete(t *testing.T) { + db, err := New(filepath.Join(t.TempDir(), "firewood.test")) + require.NoError(t, err) + + key := []byte("foo") + val := []byte("bar") + + db.Put(key, val) + + require.NoError(t, db.Flush()) + + db.Delete(key) + db.Abort() + + gotVal, err := db.Get(key) + require.NoError(t, err) + require.Equal(t, val, gotVal) +} + +func TestDBRoot(t *testing.T) { + type put struct { + key []byte + val []byte + } + + tests := []struct { + name string + puts []put + }{ + { + name: "no puts", + }, + { + name: "single put", + puts: []put{ + {key: []byte("foo"), val: []byte("bar")}, + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + db, err := New(filepath.Join(t.TempDir(), "firewood.test")) + require.NoError(t, err) + + prevRoot, err := db.Root() + require.NoError(t, err) + + for _, p := range tt.puts { + db.Put(p.key, p.val) + } + + require.NoError(t, db.Flush()) + + updatedRoot, err := db.Root() + require.NoError(t, err) + + require.NotEqual(t, prevRoot, updatedRoot) + }) + } +} diff --git a/go.mod b/go.mod index 1ae9b6401d26..169ed851a066 100644 --- a/go.mod +++ b/go.mod @@ -22,6 +22,7 @@ require ( github.com/StephenButtolph/canoto v0.17.3 github.com/antithesishq/antithesis-sdk-go v0.3.8 github.com/ava-labs/avalanchego/graft/coreth v0.16.0-rc.0 + github.com/ava-labs/firewood-go-ethhash/ffi v0.0.15 github.com/ava-labs/libevm v1.13.15-0.20251016142715-1bccf4f2ddb2 github.com/ava-labs/subnet-evm v0.8.1-0.20251124174652-9114d48a927d github.com/btcsuite/btcd/btcutil v1.1.3 @@ -89,7 +90,6 @@ require ( require ( github.com/Microsoft/go-winio v0.6.1 // indirect github.com/VictoriaMetrics/fastcache v1.12.1 // indirect - github.com/ava-labs/firewood-go-ethhash/ffi v0.0.15 // indirect github.com/ava-labs/simplex v0.0.0-20250919142550-9cdfff10fd19 github.com/beorn7/perks v1.0.1 // indirect github.com/bits-and-blooms/bitset v1.20.0 // indirect