From 9148af60a8d47762a52a6f7207feafa640b62a57 Mon Sep 17 00:00:00 2001 From: Ron Kuris Date: Sat, 9 May 2026 08:35:31 -0700 Subject: [PATCH] feat(firewood/syncer): implement change proof support MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Why this should be merged The firewood syncer backend previously stubbed out the change-proof side of the `sync.DB` interface (placeholder type `struct{}`, marshaler/methods returned `"not implemented"`). Range proofs were the only working path, so the syncer fell back to full range proofs whenever a change proof would have been more efficient. Firewood v0.5.0 exposes the full change-proof API (`(*Database).ChangeProof`, `.VerifyChangeProof` → `*Proposal`, `(*Proposal).CommitWithRebase`, `(*ChangeProof).FindNextKey`), so this change lights up the real implementation end-to-end. ## How this works - `proof.go`: add a `ChangeProof` wrapper around `*ffi.ChangeProof` that holds the `*ffi.Proposal` produced by `VerifyChangeProof` until commit time; replace the stub marshaler with the real one. - `syncer.go`: switch the type parameter from `struct{}` to `*ChangeProof` on `*database` and the `New`/`newWithDB` constructors; implement `GetChangeProof` (server side), `VerifyChangeProof` (client side, returns a Proposal), and `CommitChangeProof` (`Proposal.CommitWithRebase` + `FindNextKey` for the next-key continuation, matching the existing range-proof pattern). - `evm_syncer.go`: implement `(*evmDB).CommitChangeProof` to enqueue code hashes from the proof before committing, mirroring the range-proof flow. `(*ffi.ChangeProof).CodeHashes()` is currently a no-op in firewood; the iterator loop is kept so we pick up real hashes automatically once it is implemented. - `handler.go`: change-proof handler type params. - External callers (`graft/evm/sync/evmstate/firewood_syncer.go`, `syncer_test.go`) updated to the new `*ChangeProof` type parameter. Firewood version bumped to v0.5.0 in \`go.mod\`. v0.5.0 contains several correctness fixes that this code depends on (no-op proposal handling, \`FindNextKey\` returning nil at trie right edge, range-proof boundary checks). ## How this was tested Ran the firewood syncer test suite against a local firewood build at the v0.5.0 source. All 11 sub-tests pass: - \`Test_Firewood_Sync\` (7 cases — range-proof sync from empty/non-empty client to various server sizes up to 100k keys) - \`Test_Firewood_Sync_UpdateSyncTarget\` (4 cases — sync target update mid-flight, exercising the real change-proof path including \`partial_sync_*_then_update\` scenarios) CI will fail until firewood v0.5.0 is published; reviewers can validate locally by pointing the firewood dependency at a v0.5.0 build via \`go mod edit -replace\`. ## Need to be documented in RELEASES.md? No. --- database/merkle/firewood/syncer/evm_syncer.go | 30 ++++++--- database/merkle/firewood/syncer/handler.go | 2 +- database/merkle/firewood/syncer/proof.go | 27 +++++--- database/merkle/firewood/syncer/syncer.go | 63 ++++++++++++++----- .../merkle/firewood/syncer/syncer_test.go | 2 +- go.work.sum | 1 + graft/evm/sync/evmstate/firewood_syncer.go | 2 +- 7 files changed, 92 insertions(+), 35 deletions(-) diff --git a/database/merkle/firewood/syncer/evm_syncer.go b/database/merkle/firewood/syncer/evm_syncer.go index fe65ae67b2dc..5c0659ce8c52 100644 --- a/database/merkle/firewood/syncer/evm_syncer.go +++ b/database/merkle/firewood/syncer/evm_syncer.go @@ -5,7 +5,6 @@ package syncer import ( "context" - "errors" "github.com/ava-labs/firewood-go-ethhash/ffi" "github.com/ava-labs/libevm/common" @@ -16,7 +15,7 @@ import ( "github.com/ava-labs/avalanchego/utils/maybe" ) -var _ sync.DB[*RangeProof, struct{}] = (*evmDB)(nil) +var _ sync.DB[*RangeProof, *ChangeProof] = (*evmDB)(nil) type CodeQueue interface { AddCode(context.Context, []common.Hash) error @@ -33,11 +32,11 @@ func NewEVM( codeQueue CodeQueue, targetRoot ids.ID, proofClient *p2p.Client, -) (*sync.Syncer[*RangeProof, struct{}], error) { +) (*sync.Syncer[*RangeProof, *ChangeProof], error) { return newWithDB( config, &evmDB{ - db: &database{db}, + db: &database{db: db}, codeQueue: codeQueue, }, targetRoot, @@ -66,8 +65,23 @@ func (e *evmDB) CommitRangeProof(ctx context.Context, start, end maybe.Maybe[[]b return nextKey, nil } -func (*evmDB) CommitChangeProof(context.Context, maybe.Maybe[[]byte], struct{}) (maybe.Maybe[[]byte], error) { - return maybe.Nothing[[]byte](), errors.New("change proof code hashes not implemented") +func (e *evmDB) CommitChangeProof(ctx context.Context, end maybe.Maybe[[]byte], proof *ChangeProof) (maybe.Maybe[[]byte], error) { + // First enqueue any code hashes found in the proof. + // If an error occurs here, we don't want to commit the proof to the database, because the + // code hashes will never be requested. + // Note: ffi.ChangeProof.CodeHashes() is currently a no-op in firewood; the loop is + // kept so we pick up real code hashes automatically once firewood implements it. + var codeHashes []common.Hash + for h, err := range proof.cp.CodeHashes() { + if err != nil { + return maybe.Nothing[[]byte](), err + } + codeHashes = append(codeHashes, common.Hash(h)) + } + if err := e.codeQueue.AddCode(ctx, codeHashes); err != nil { + return maybe.Nothing[[]byte](), err + } + return e.db.CommitChangeProof(ctx, end, proof) } func (e *evmDB) GetMerkleRoot(ctx context.Context) (ids.ID, error) { @@ -78,7 +92,7 @@ func (e *evmDB) Clear() error { return e.db.Clear() } -func (e *evmDB) GetChangeProof(ctx context.Context, startRootID ids.ID, endRootID ids.ID, start maybe.Maybe[[]byte], end maybe.Maybe[[]byte], maxLength int) (struct{}, error) { +func (e *evmDB) GetChangeProof(ctx context.Context, startRootID ids.ID, endRootID ids.ID, start maybe.Maybe[[]byte], end maybe.Maybe[[]byte], maxLength int) (*ChangeProof, error) { return e.db.GetChangeProof(ctx, startRootID, endRootID, start, end, maxLength) } @@ -86,7 +100,7 @@ func (e *evmDB) GetRangeProofAtRoot(ctx context.Context, rootID ids.ID, start ma return e.db.GetRangeProofAtRoot(ctx, rootID, start, end, maxLength) } -func (e *evmDB) VerifyChangeProof(ctx context.Context, proof struct{}, start maybe.Maybe[[]byte], end maybe.Maybe[[]byte], expectedEndRootID ids.ID, maxLength int) error { +func (e *evmDB) VerifyChangeProof(ctx context.Context, proof *ChangeProof, start maybe.Maybe[[]byte], end maybe.Maybe[[]byte], expectedEndRootID ids.ID, maxLength int) error { return e.db.VerifyChangeProof(ctx, proof, start, end, expectedEndRootID, maxLength) } diff --git a/database/merkle/firewood/syncer/handler.go b/database/merkle/firewood/syncer/handler.go index a98a23b253dd..b70a0f0d06c5 100644 --- a/database/merkle/firewood/syncer/handler.go +++ b/database/merkle/firewood/syncer/handler.go @@ -11,6 +11,6 @@ import ( // NewGetProofHandler returns a handler that services proof requests // using the provided Firewood database for p2p connections. -func NewGetProofHandler(db *ffi.Database) *sync.ProofHandler[*RangeProof, struct{}] { +func NewGetProofHandler(db *ffi.Database) *sync.ProofHandler[*RangeProof, *ChangeProof] { return sync.NewProofHandler(&database{db: db}, rangeProofMarshaler{}, changeProofMarshaler{}) } diff --git a/database/merkle/firewood/syncer/proof.go b/database/merkle/firewood/syncer/proof.go index 1f2065d61067..4d99fea2545d 100644 --- a/database/merkle/firewood/syncer/proof.go +++ b/database/merkle/firewood/syncer/proof.go @@ -4,8 +4,6 @@ package syncer import ( - "errors" - "github.com/ava-labs/firewood-go-ethhash/ffi" "github.com/ava-labs/avalanchego/database/merkle/sync" @@ -13,8 +11,8 @@ import ( ) var ( - _ sync.Marshaler[*RangeProof] = rangeProofMarshaler{} - _ sync.Marshaler[struct{}] = changeProofMarshaler{} + _ sync.Marshaler[*RangeProof] = rangeProofMarshaler{} + _ sync.Marshaler[*ChangeProof] = changeProofMarshaler{} ) type rangeProofMarshaler struct{} @@ -39,13 +37,24 @@ type RangeProof struct { maxLength int } -// TODO: implement an actual ChangeProof marshaler. type changeProofMarshaler struct{} -func (changeProofMarshaler) Marshal(struct{}) ([]byte, error) { - return nil, errors.New("not implemented") +func (changeProofMarshaler) Marshal(p *ChangeProof) ([]byte, error) { + return p.cp.MarshalBinary() +} + +func (changeProofMarshaler) Unmarshal(data []byte) (*ChangeProof, error) { + proof := new(ffi.ChangeProof) + if err := proof.UnmarshalBinary(data); err != nil { + return nil, err + } + return &ChangeProof{cp: proof}, nil } -func (changeProofMarshaler) Unmarshal([]byte) (struct{}, error) { - return struct{}{}, errors.New("not implemented") +type ChangeProof struct { + cp *ffi.ChangeProof + // proposal is populated by VerifyChangeProof on the client side and + // consumed by CommitChangeProof. It is nil for proofs returned from + // GetChangeProof on the server side. + proposal *ffi.Proposal } diff --git a/database/merkle/firewood/syncer/syncer.go b/database/merkle/firewood/syncer/syncer.go index c8d499c0455a..9b9759f7f95f 100644 --- a/database/merkle/firewood/syncer/syncer.go +++ b/database/merkle/firewood/syncer/syncer.go @@ -20,7 +20,7 @@ import ( ) var ( - _ sync.DB[*RangeProof, struct{}] = (*database)(nil) + _ sync.DB[*RangeProof, *ChangeProof] = (*database)(nil) defaultSimultaneousWorkLimit = 8 ) @@ -37,16 +37,16 @@ type Config struct { Registerer prometheus.Registerer } -func New(config Config, db *ffi.Database, targetRoot ids.ID, proofClient *p2p.Client) (*sync.Syncer[*RangeProof, struct{}], error) { +func New(config Config, db *ffi.Database, targetRoot ids.ID, proofClient *p2p.Client) (*sync.Syncer[*RangeProof, *ChangeProof], error) { return newWithDB( config, - &database{db}, + &database{db: db}, targetRoot, proofClient, ) } -func newWithDB(config Config, db sync.DB[*RangeProof, struct{}], targetRoot ids.ID, proofClient *p2p.Client) (*sync.Syncer[*RangeProof, struct{}], error) { +func newWithDB(config Config, db sync.DB[*RangeProof, *ChangeProof], targetRoot ids.ID, proofClient *p2p.Client) (*sync.Syncer[*RangeProof, *ChangeProof], error) { if config.Registerer == nil { config.Registerer = prometheus.NewRegistry() } @@ -58,7 +58,7 @@ func newWithDB(config Config, db sync.DB[*RangeProof, struct{}], targetRoot ids. } return sync.NewSyncer( db, - sync.Config[*RangeProof, struct{}]{ + sync.Config[*RangeProof, *ChangeProof]{ RangeProofMarshaler: rangeProofMarshaler{}, ChangeProofMarshaler: changeProofMarshaler{}, EmptyRoot: ids.ID(types.EmptyRootHash), @@ -124,20 +124,53 @@ func (db *database) CommitRangeProof(_ context.Context, start, end maybe.Maybe[[ return maybe.Some(nextKey), nil } -// TODO: Use change proofs to optimize syncing. -// Returning the sentinel error suggests to the server handler to serve a full range proof instead. -func (*database) GetChangeProof(context.Context, ids.ID, ids.ID, maybe.Maybe[[]byte], maybe.Maybe[[]byte], int) (struct{}, error) { - return struct{}{}, sync.ErrInsufficientHistory +func (db *database) GetChangeProof(_ context.Context, startRootID ids.ID, endRootID ids.ID, start maybe.Maybe[[]byte], end maybe.Maybe[[]byte], maxLength int) (*ChangeProof, error) { + proof, err := db.db.ChangeProof(ffi.Hash(startRootID), ffi.Hash(endRootID), start, end, uint32(maxLength)) + switch { + case errors.Is(err, ffi.ErrStartRevisionNotFound): + return nil, sync.ErrInsufficientHistory + case errors.Is(err, ffi.ErrEndRevisionNotFound): + return nil, sync.ErrNoEndRoot + case err != nil: + return nil, err + } + return &ChangeProof{cp: proof}, nil } -// TODO: implement this method. -func (*database) VerifyChangeProof(context.Context, struct{}, maybe.Maybe[[]byte], maybe.Maybe[[]byte], ids.ID, int) error { - return errors.New("change proofs are not implemented") +func (db *database) VerifyChangeProof(_ context.Context, proof *ChangeProof, start maybe.Maybe[[]byte], end maybe.Maybe[[]byte], expectedEndRootID ids.ID, maxLength int) error { + proposal, err := db.db.VerifyChangeProof(proof.cp, ffi.Hash(expectedEndRootID), start, end, uint32(maxLength)) + if err != nil { + return err + } + proof.proposal = proposal + return nil } -// TODO: implement this method. -func (*database) CommitChangeProof(context.Context, maybe.Maybe[[]byte], struct{}) (maybe.Maybe[[]byte], error) { - return maybe.Nothing[[]byte](), errors.New("change proofs are not implemented") +func (*database) CommitChangeProof(_ context.Context, end maybe.Maybe[[]byte], proof *ChangeProof) (maybe.Maybe[[]byte], error) { + if proof.proposal == nil { + return maybe.Nothing[[]byte](), errors.New("change proof was not verified before commit") + } + if _, err := proof.proposal.CommitWithRebase(); err != nil { + return maybe.Nothing[[]byte](), err + } + + nextKeyRange, err := proof.cp.FindNextKey(end) + if err != nil { + return maybe.Nothing[[]byte](), err + } + if nextKeyRange == nil { + return maybe.Nothing[[]byte](), nil + } + + nextKey := nextKeyRange.StartKey() + if err := nextKeyRange.Free(); err != nil { + return maybe.Nothing[[]byte](), err + } + + if end.HasValue() && bytes.Compare(nextKey, end.Value()) > 0 { + return maybe.Nothing[[]byte](), nil + } + return maybe.Some(nextKey), nil } func (db *database) Clear() error { diff --git a/database/merkle/firewood/syncer/syncer_test.go b/database/merkle/firewood/syncer/syncer_test.go index b2276fa55860..62261fbe3f03 100644 --- a/database/merkle/firewood/syncer/syncer_test.go +++ b/database/merkle/firewood/syncer/syncer_test.go @@ -151,7 +151,7 @@ func testSyncWithUpdate(t *testing.T, clientKeys int, serverKeys int, numRequest }() wantRoot := fillDB(t, r, serverDB, serverKeys) - var syncer *sync.Syncer[*RangeProof, struct{}] + var syncer *sync.Syncer[*RangeProof, *ChangeProof] ctx, cancel := context.WithCancelCause(t.Context()) defer cancel(nil) diff --git a/go.work.sum b/go.work.sum index c1cb374e0c0c..e2a7077c5fe1 100644 --- a/go.work.sum +++ b/go.work.sum @@ -338,6 +338,7 @@ github.com/armon/go-radix v0.0.0-20180808171621-7fddfc383310 h1:BUAU3CGlLvorLI26 github.com/asaskevich/govalidator v0.0.0-20190424111038-f61b66f89f4a h1:idn718Q4B6AGu/h5Sxe66HYVdqdGu2l9Iebqhi/AEoA= github.com/asaskevich/govalidator v0.0.0-20190424111038-f61b66f89f4a/go.mod h1:lB+ZfQJz7igIIfQNfa7Ml4HSf2uFQQRzpGGRXenZAgY= github.com/ava-labs/firewood-go-ethhash/ffi v0.3.0/go.mod h1:71C76bo47zlDX9gWnn3p/0QZQXkTQ/GNqUNI6fchvjs= +github.com/ava-labs/firewood-go-ethhash/ffi v0.5.0/go.mod h1:MoUHYlFrwaflfLpHt8nmQUHoLy2CCHaFNH/n7vazUuI= github.com/ava-labs/simplex v0.0.0-20260320130759-afe09323fdd1 h1:Y9UdRQj28/t+GNzVSlP6nvoNLtzNRo3fNXLUc/NscVM= github.com/ava-labs/simplex v0.0.0-20260320130759-afe09323fdd1/go.mod h1:GVzumIo3zR23/qGRN2AdnVkIPHcKMq/D89EGWZfMGQ0= github.com/aws/aws-sdk-go-v2 v1.21.2 h1:+LXZ0sgo8quN9UOKXXzAWRT3FWd4NxeXWOZom9pE7GA= diff --git a/graft/evm/sync/evmstate/firewood_syncer.go b/graft/evm/sync/evmstate/firewood_syncer.go index 1671c3b39776..e06f2207b245 100644 --- a/graft/evm/sync/evmstate/firewood_syncer.go +++ b/graft/evm/sync/evmstate/firewood_syncer.go @@ -26,7 +26,7 @@ var ( ) type FirewoodSyncer struct { - s *merklesync.Syncer[*syncer.RangeProof, struct{}] + s *merklesync.Syncer[*syncer.RangeProof, *syncer.ChangeProof] cancel context.CancelFunc codeQueue *code.Queue // finalizeOnce is initialized in the constructor to make Finalize idempotent.