diff --git a/x/firewood/proof.go b/x/firewood/proof.go new file mode 100644 index 000000000000..254426e6672b --- /dev/null +++ b/x/firewood/proof.go @@ -0,0 +1,144 @@ +// Copyright (C) 2019-2025, Ava Labs, Inc. All rights reserved. +// See the file LICENSE for licensing terms. + +package firewood + +import ( + "runtime" + + "github.com/ava-labs/firewood-go-ethhash/ffi" + + "github.com/ava-labs/avalanchego/ids" + "github.com/ava-labs/avalanchego/utils/maybe" + + xsync "github.com/ava-labs/avalanchego/x/sync" +) + +var ( + _ xsync.Marshaler[*RangeProof] = RangeProofMarshaler{} + _ xsync.Marshaler[*ChangeProof] = ChangeProofMarshaler{} +) + +type RangeProofMarshaler struct{} + +func (RangeProofMarshaler) Marshal(r *RangeProof) ([]byte, error) { + if r == nil { + return nil, errNilProof + } + if r.proof == nil { + return nil, nil + } + + data, err := r.proof.MarshalBinary() + return data, err +} + +func (RangeProofMarshaler) Unmarshal(data []byte) (*RangeProof, error) { + proof := new(ffi.RangeProof) + if err := proof.UnmarshalBinary(data); err != nil { + return nil, err + } + return newRangeProof(proof), nil +} + +type RangeProof struct { + proof *ffi.RangeProof + root ids.ID + maxLength int +} + +// Wrap the ffi proof in our proof type. +func newRangeProof(proof *ffi.RangeProof) *RangeProof { + rangeProof := &RangeProof{ + proof: proof, + } + // Once this struct is out of scope, free the underlying proof. + runtime.AddCleanup(rangeProof, func(ffiProof *ffi.RangeProof) { + if ffiProof != nil { + _ = ffiProof.Free() + } + }, rangeProof.proof) + return rangeProof +} + +func (r *RangeProof) FindNextKey() (maybe.Maybe[[]byte], error) { + // We can now get the FindNextKey iterator. + nextKeyRange, err := r.proof.FindNextKey() + if err != nil { + return maybe.Nothing[[]byte](), err + } + + // TODO: this panics + startKey := maybe.Some(nextKeyRange.StartKey()) + + // Done using nextKeyRange + if err := nextKeyRange.Free(); err != nil { + return maybe.Nothing[[]byte](), err + } + + return startKey, nil +} + +type ChangeProofMarshaler struct{} + +func (ChangeProofMarshaler) Marshal(r *ChangeProof) ([]byte, error) { + if r == nil { + return nil, errNilProof + } + if r.proof == nil { + return nil, nil + } + + data, err := r.proof.MarshalBinary() + return data, err +} + +func (ChangeProofMarshaler) Unmarshal(data []byte) (*ChangeProof, error) { + proof := new(ffi.ChangeProof) + if err := proof.UnmarshalBinary(data); err != nil { + return nil, err + } + return newChangeProof(proof), nil +} + +type ChangeProof struct { + proof *ffi.ChangeProof + startRoot ids.ID + endRoot ids.ID + startKey maybe.Maybe[[]byte] + maxLength int +} + +// Wrap the ffi proof in our proof type. +func newChangeProof(proof *ffi.ChangeProof) *ChangeProof { + changeProof := &ChangeProof{ + proof: proof, + } + + // Once this struct is out of scope, free the underlying proof. + runtime.AddCleanup(changeProof, func(ffiProof *ffi.ChangeProof) { + if ffiProof != nil { + _ = ffiProof.Free() + } + }, changeProof.proof) + + return changeProof +} + +func (c *ChangeProof) FindNextKey() (maybe.Maybe[[]byte], error) { + // We can now get the FindNextKey iterator. + nextKeyRange, err := c.proof.FindNextKey() + if err != nil { + return maybe.Nothing[[]byte](), err + } + + // TODO: this panics + startKey := maybe.Some(nextKeyRange.StartKey()) + + // Done using nextKeyRange + if err := nextKeyRange.Free(); err != nil { + return maybe.Nothing[[]byte](), err + } + + return startKey, nil +} diff --git a/x/firewood/sync_db.go b/x/firewood/sync_db.go new file mode 100644 index 000000000000..51c84fb19bc5 --- /dev/null +++ b/x/firewood/sync_db.go @@ -0,0 +1,170 @@ +// Copyright (C) 2019-2025, Ava Labs, Inc. All rights reserved. +// See the file LICENSE for licensing terms. + +package firewood + +import ( + "bytes" + "context" + "errors" + "sync" + + "github.com/ava-labs/firewood-go-ethhash/ffi" + + "github.com/ava-labs/avalanchego/ids" + "github.com/ava-labs/avalanchego/utils/maybe" + + xsync "github.com/ava-labs/avalanchego/x/sync" +) + +var ( + _ xsync.DB[*RangeProof, *ChangeProof] = (*syncDB)(nil) + + errNilProof = errors.New("nil proof") +) + +type syncDB struct { + fw *ffi.Database + lock sync.Mutex +} + +func New(db *ffi.Database) *syncDB { + return &syncDB{fw: db} +} + +func (db *syncDB) GetMerkleRoot(context.Context) (ids.ID, error) { + root, err := db.fw.Root() + if err != nil { + return ids.ID{}, err + } + return ids.ID(root), nil +} + +// TODO: implement +func (db *syncDB) CommitChangeProof(_ context.Context, end maybe.Maybe[[]byte], proof *ChangeProof) (nextKey maybe.Maybe[[]byte], err error) { + // Set up cleanup. + var nextKeyRange *ffi.NextKeyRange + defer func() { + // If we got a nextKeyRange, free it too. + if nextKeyRange != nil { + err = errors.Join(err, nextKeyRange.Free()) + } + }() + + // TODO: Remove copy. Currently necessary to avoid passing pointer to a stack variable? + startRootBytes := make([]byte, ids.IDLen) + copy(startRootBytes, proof.startRoot[:]) + endRootBytes := make([]byte, ids.IDLen) + copy(endRootBytes, proof.endRoot[:]) + + // Verify and commit the proof in a single step (TODO: separate these steps). + // CommitRangeProof will verify the proof as part of committing it. + db.lock.Lock() + defer db.lock.Unlock() + newRoot, err := db.fw.VerifyAndCommitChangeProof(proof.proof, startRootBytes, endRootBytes, proof.startKey, end, uint32(proof.maxLength)) + if err != nil { + return maybe.Nothing[[]byte](), err + } + + // TODO: This case should be handled by `FindNextKey`. + if bytes.Equal(newRoot, endRootBytes) { + return maybe.Nothing[[]byte](), nil + } + + return proof.FindNextKey() +} + +// Commit the range proof to the database. +// TODO: This should only commit the range proof, not verify it. +// This will be resolved once the Firewood supports that. +// This is the last call to the proof, so it and any resources should be freed. +func (db *syncDB) CommitRangeProof(_ context.Context, start, end maybe.Maybe[[]byte], proof *RangeProof) (nextKey maybe.Maybe[[]byte], err error) { + // Set up cleanup. + // TODO: Remove copy. Currently necessary to avoid passing pointer to a stack variable? + rootBytes := make([]byte, ids.IDLen) + copy(rootBytes, proof.root[:]) + + // Verify and commit the proof in a single step (TODO: separate these steps). + // CommitRangeProof will verify the proof as part of committing it. + db.lock.Lock() + defer db.lock.Unlock() + newRoot, err := db.fw.VerifyAndCommitRangeProof(proof.proof, start, end, rootBytes, uint32(proof.maxLength)) + if err != nil { + return maybe.Nothing[[]byte](), err + } + + // TODO: This case should be handled by `FindNextKey`. + if bytes.Equal(newRoot, rootBytes) { + return maybe.Nothing[[]byte](), nil + } + + return proof.FindNextKey() +} + +// TODO: implement +func (db *syncDB) GetChangeProof(_ context.Context, startRootID ids.ID, endRootID ids.ID, start maybe.Maybe[[]byte], end maybe.Maybe[[]byte], maxLength int) (*ChangeProof, error) { + proof, err := db.fw.ChangeProof(startRootID[:], endRootID[:], start, end, uint32(maxLength)) + if err != nil { + return nil, err + } + + return newChangeProof(proof), nil +} + +// Get the range proof between [start, end]. +// The returned proof must be freed when no longer needed. +// Since this method is only called prior to marshalling the proof for sending over the +// network, the proof will be freed when marshalled. +func (db *syncDB) GetRangeProofAtRoot(_ context.Context, rootID ids.ID, start maybe.Maybe[[]byte], end maybe.Maybe[[]byte], maxLength int) (*RangeProof, error) { + proof, err := db.fw.RangeProof(maybe.Some(rootID[:]), start, end, uint32(maxLength)) + if err != nil { + return nil, err + } + + return newRangeProof(proof), nil +} + +// TODO: implement +// Right now, we verify the proof as part of committing it, making this function a no-op. +// We must only pass the necessary data to CommitChangeProof so it can verify the proof. +// +//nolint:revive +func (db *syncDB) VerifyChangeProof(_ context.Context, proof *ChangeProof, start maybe.Maybe[[]byte], end maybe.Maybe[[]byte], expectedEndRootID ids.ID, maxLength int) error { + if proof.proof == nil { + return errNilProof + } + + // TODO: once firewood can verify separately from committing, do that here. + // For now, pass any necessary data to be done in CommitChangeProof. + // Namely, the start root, end root, and max length. + proof.startRoot = expectedEndRootID + proof.endRoot = expectedEndRootID + proof.maxLength = maxLength + return nil +} + +// TODO: implement +// Right now, we verify the proof as part of committing it, making this function a no-op. +// We must only pass the necessary data to CommitRangeProof so it can verify the proof. +// +//nolint:revive +func (db *syncDB) VerifyRangeProof(_ context.Context, proof *RangeProof, start maybe.Maybe[[]byte], end maybe.Maybe[[]byte], expectedEndRootID ids.ID, maxLength int) error { + if proof.proof == nil { + return errNilProof + } + + // TODO: once firewood can verify separately from committing, do that here. + // For now, pass any necessary data to be done in CommitRangeProof. + // Namely, the max length and root. + proof.root = expectedEndRootID + proof.maxLength = maxLength + return nil +} + +// TODO: implement +// No error is returned to ensure some tests pass. +// +//nolint:revive +func (db *syncDB) Clear() error { + return nil +} diff --git a/x/firewood/sync_test.go b/x/firewood/sync_test.go new file mode 100644 index 000000000000..c81c6ccb2f5b --- /dev/null +++ b/x/firewood/sync_test.go @@ -0,0 +1,115 @@ +// Copyright (C) 2019-2025, Ava Labs, Inc. All rights reserved. +// See the file LICENSE for licensing terms. + +package firewood + +import ( + "context" + "fmt" + "math/rand" + "testing" + "time" + + "github.com/ava-labs/firewood-go-ethhash/ffi" + "github.com/prometheus/client_golang/prometheus" + "github.com/stretchr/testify/require" + + "github.com/ava-labs/avalanchego/ids" + "github.com/ava-labs/avalanchego/network/p2p/p2ptest" + "github.com/ava-labs/avalanchego/utils/logging" + + xsync "github.com/ava-labs/avalanchego/x/sync" +) + +var ( + rangeProofMarshaler = RangeProofMarshaler{} + changeProofMarshaler = ChangeProofMarshaler{} +) + +func Test_Firewood_Sync(t *testing.T) { + tests := []int{0, 1, 1_000, 10_000, 100_000, 1_000_000} + for _, numKeys := range tests { + t.Run(fmt.Sprintf("numKeys=%d", numKeys), func(t *testing.T) { + require := require.New(t) + now := time.Now() + fullDB := generateDB(t, numKeys, now.UnixNano()) + db := generateDB(t, 0, 0) // empty DB + + root, err := fullDB.GetMerkleRoot(context.Background()) + require.NoError(err) + + ctx := context.Background() + syncer, err := xsync.NewManager( + db, + xsync.ManagerConfig[*RangeProof, *ChangeProof]{ + RangeProofMarshaler: rangeProofMarshaler, + ChangeProofMarshaler: changeProofMarshaler, + // Since we're syncing with ourselves, it doesn't matter what node ID we use. + RangeProofClient: p2ptest.NewSelfClient(t, ctx, ids.EmptyNodeID, xsync.NewGetRangeProofHandler(fullDB, rangeProofMarshaler)), + ChangeProofClient: p2ptest.NewSelfClient(t, ctx, ids.EmptyNodeID, xsync.NewGetChangeProofHandler(fullDB, rangeProofMarshaler, changeProofMarshaler)), + SimultaneousWorkLimit: 5, + Log: logging.NoLog{}, + TargetRoot: root, + }, + prometheus.NewRegistry(), + ) + require.NoError(err) + require.NotNil(syncer) + + // Add logging for actual time syncing + now = time.Now() + require.NoError(syncer.Start(ctx)) + require.NoError(syncer.Wait(ctx)) + t.Logf("synced %d keys in %s", numKeys, time.Since(now)) + }) + } +} + +func generateDB(t *testing.T, numKeys int, seed int64) *syncDB { + t.Helper() + folder := t.TempDir() + path := folder + "/firewood.db" + + fw, err := ffi.New(path, ffi.DefaultConfig()) + require.NoError(t, err) + require.NotNil(t, fw) + t.Cleanup(func() { + require.NoError(t, fw.Close()) + }) + + db := New(fw) + if numKeys == 0 { + return db + } + + var ( + r = rand.New(rand.NewSource(seed)) // #nosec G404 + keys = make([][]byte, numKeys) + vals = make([][]byte, numKeys) + minLength = 0 + maxLength = 64 + ) + t.Logf("generating %d random keys/values with seed %d", numKeys, seed) + for i := 0; i < numKeys; i++ { + // Random length between minLength and maxLength inclusive + keyLen := r.Intn(maxLength-minLength+1) + minLength + valLen := r.Intn(maxLength-minLength+1) + minLength + + key := make([]byte, keyLen) + val := make([]byte, valLen) + + // Fill with random bytes + _, err := r.Read(key) + require.NoError(t, err, "read never errors") + _, err = r.Read(val) + require.NoError(t, err, "read never errors") + + keys[i] = key + vals[i] = val + } + + _, err = fw.Update(keys, vals) + require.NoError(t, err) + + return db +}