From 574f08571561a06fb002ce5a5fa917a4130a782d Mon Sep 17 00:00:00 2001 From: Zsolt Felfoldi Date: Mon, 24 Jul 2023 16:47:32 +0200 Subject: [PATCH] beacon/light: add CommitteeChain --- beacon/light/checkpoint.go | 110 ++++++ beacon/light/committee_chain.go | 521 +++++++++++++++++++++++++++ beacon/light/committee_chain_test.go | 356 ++++++++++++++++++ beacon/light/range.go | 52 +++ beacon/light/test_helpers.go | 151 ++++++++ 5 files changed, 1190 insertions(+) create mode 100644 beacon/light/checkpoint.go create mode 100644 beacon/light/committee_chain.go create mode 100644 beacon/light/committee_chain_test.go create mode 100644 beacon/light/range.go create mode 100644 beacon/light/test_helpers.go diff --git a/beacon/light/checkpoint.go b/beacon/light/checkpoint.go new file mode 100644 index 0000000000..c9d4956dc5 --- /dev/null +++ b/beacon/light/checkpoint.go @@ -0,0 +1,110 @@ +// Copyright 2023 The go-ethereum Authors +// This file is part of the go-ethereum library. +// +// The go-ethereum library is free software: you can redistribute it and/or modify +// it under the terms of the GNU Lesser General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// The go-ethereum library is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Lesser General Public License for more details. +// +// You should have received a copy of the GNU Lesser General Public License +// along with the go-ethereum library. If not, see . + +package light + +import ( + "errors" + + "github.com/ethereum/go-ethereum/beacon/merkle" + "github.com/ethereum/go-ethereum/beacon/params" + "github.com/ethereum/go-ethereum/beacon/types" + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/ethdb" + "github.com/ethereum/go-ethereum/log" + "github.com/ethereum/go-ethereum/rlp" +) + +var checkpointKey = []byte("checkpoint-") // block root -> RLP(CheckpointData) + +type CheckpointData struct { + Header types.Header + CommitteeRoot common.Hash + Committee *types.SerializedSyncCommittee `rlp:"-"` + CommitteeBranch merkle.Values +} + +func (c *CheckpointData) Validate() error { + if c.CommitteeRoot != c.Committee.Root() { + return errors.New("wrong committee root") + } + return merkle.VerifyProof(c.Header.StateRoot, params.StateIndexSyncCommittee, c.CommitteeBranch, merkle.Value(c.CommitteeRoot)) +} + +// expected to be validated already +func (c *CheckpointData) InitChain(chain *CommitteeChain) { + must := func(err error) { + if err != nil { + log.Crit("Error initializing committee chain with checkpoint", "error", err) + } + } + period := c.Header.SyncPeriod() + must(chain.DeleteFixedRootsFrom(period + 2)) + if chain.AddFixedRoot(period, c.CommitteeRoot) != nil { + chain.Reset() + must(chain.AddFixedRoot(period, c.CommitteeRoot)) + } + must(chain.AddFixedRoot(period+1, common.Hash(c.CommitteeBranch[0]))) + must(chain.AddCommittee(period, c.Committee)) +} + +type CheckpointStore struct { + chain *CommitteeChain + db ethdb.KeyValueStore +} + +func NewCheckpointStore(db ethdb.KeyValueStore, chain *CommitteeChain) *CheckpointStore { + return &CheckpointStore{ + db: db, + chain: chain, + } +} + +func getCheckpointKey(checkpoint common.Hash) []byte { + var ( + kl = len(checkpointKey) + key = make([]byte, kl+32) + ) + copy(key[:kl], checkpointKey) + copy(key[kl:], checkpoint[:]) + return key +} + +func (cs *CheckpointStore) Get(checkpoint common.Hash) *CheckpointData { + if enc, err := cs.db.Get(getCheckpointKey(checkpoint)); err == nil { + c := new(CheckpointData) + if err := rlp.DecodeBytes(enc, c); err != nil { + log.Error("Error decoding stored checkpoint", "error", err) + return nil + } + if committee := cs.chain.committees.get(c.Header.SyncPeriod()); committee != nil && committee.Root() == c.CommitteeRoot { + c.Committee = committee + return c + } + log.Error("Missing committee for stored checkpoint", "period", c.Header.SyncPeriod()) + } + return nil +} + +func (cs *CheckpointStore) Store(c *CheckpointData) { + enc, err := rlp.EncodeToBytes(c) + if err != nil { + log.Error("Error encoding checkpoint for storage", "error", err) + } + if err := cs.db.Put(getCheckpointKey(c.Header.Hash()), enc); err != nil { + log.Error("Error storing checkpoint in database", "error", err) + } +} diff --git a/beacon/light/committee_chain.go b/beacon/light/committee_chain.go new file mode 100644 index 0000000000..1aec863e42 --- /dev/null +++ b/beacon/light/committee_chain.go @@ -0,0 +1,521 @@ +// Copyright 2023 The go-ethereum Authors +// This file is part of the go-ethereum library. +// +// The go-ethereum library is free software: you can redistribute it and/or modify +// it under the terms of the GNU Lesser General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// The go-ethereum library is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Lesser General Public License for more details. +// +// You should have received a copy of the GNU Lesser General Public License +// along with the go-ethereum library. If not, see . + +package light + +import ( + "encoding/binary" + "errors" + "sync" + "time" + + "github.com/ethereum/go-ethereum/beacon/params" + "github.com/ethereum/go-ethereum/beacon/types" + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/common/lru" + "github.com/ethereum/go-ethereum/common/mclock" + "github.com/ethereum/go-ethereum/ethdb" + "github.com/ethereum/go-ethereum/log" + "github.com/ethereum/go-ethereum/rlp" +) + +var ( + ErrNotInitialized = errors.New("sync committee chain not initialized") + ErrNeedCommittee = errors.New("sync committee required") + ErrInvalidUpdate = errors.New("invalid committee update") + ErrInvalidPeriod = errors.New("invalid update period") + ErrWrongCommitteeRoot = errors.New("wrong committee root") + ErrCannotReorg = errors.New("can not reorg committee chain") +) + +var ( + bestUpdateKey = []byte("update-") // bigEndian64(syncPeriod) -> RLP(types.LightClientUpdate) (nextCommittee only referenced by root hash) + fixedRootKey = []byte("fixedRoot-") // bigEndian64(syncPeriod) -> committee root hash + syncCommitteeKey = []byte("committee-") // bigEndian64(syncPeriod) -> serialized committee +) + +// CommitteeChain maintains a chain of sync committee updates and a small +// set of best known signed heads. It is used in all client configurations +// operating on a beacon chain. It can sync its update chain and receive signed +// heads from either an ODR or beacon node API backend and propagate/serve this +// data to subscribed peers. Received signed heads are validated based on the +// known sync committee chain and added to the local set if valid or placed in a +// deferred queue if the committees are not synced up to the period of the new +// head yet. +// Sync committee chain is either initialized from a weak subjectivity checkpoint +// or controlled by a BeaconChain that is driven by a trusted source (beacon node API). +type CommitteeChain struct { + lock sync.RWMutex + db ethdb.KeyValueStore + sigVerifier committeeSigVerifier + clock mclock.Clock + updates *canonicalStore[*types.LightClientUpdate] + committees *canonicalStore[*types.SerializedSyncCommittee] + fixedRoots *canonicalStore[common.Hash] + syncCommitteeCache *lru.Cache[uint64, syncCommittee] // cache deserialized committees + unixNano func() int64 + + config *types.ChainConfig + signerThreshold int + minimumUpdateScore types.UpdateScore + enforceTime bool +} + +// NewCommitteeChain creates a new CommitteeChain +func NewCommitteeChain(db ethdb.KeyValueStore, config *types.ChainConfig, signerThreshold int, enforceTime bool, sigVerifier committeeSigVerifier, clock mclock.Clock, unixNano func() int64) *CommitteeChain { + s := &CommitteeChain{ + fixedRoots: newCanonicalStore[common.Hash](db, fixedRootKey, func(root common.Hash) ([]byte, error) { + return root[:], nil + }, func(enc []byte) (root common.Hash, err error) { + if len(enc) == len(root) { + copy(root[:], enc) + } else { + err = errors.New("Incorrect length for committee root entry in the database") + } + return + }), + committees: newCanonicalStore[*types.SerializedSyncCommittee](db, syncCommitteeKey, func(committee *types.SerializedSyncCommittee) ([]byte, error) { + return committee[:], nil + }, func(enc []byte) (*types.SerializedSyncCommittee, error) { + if len(enc) == types.SerializedSyncCommitteeSize { + committee := new(types.SerializedSyncCommittee) + copy(committee[:], enc) + return committee, nil + } + return nil, errors.New("Incorrect length for serialized committee entry in the database") + }), + updates: newCanonicalStore[*types.LightClientUpdate](db, bestUpdateKey, func(update *types.LightClientUpdate) ([]byte, error) { + return rlp.EncodeToBytes(update) + }, func(enc []byte) (*types.LightClientUpdate, error) { + update := new(types.LightClientUpdate) + if err := rlp.DecodeBytes(enc, update); err != nil { + return nil, err + } + return update, nil + }), + syncCommitteeCache: lru.NewCache[uint64, syncCommittee](10), + db: db, + sigVerifier: sigVerifier, + clock: clock, + unixNano: unixNano, + config: config, + signerThreshold: signerThreshold, + enforceTime: enforceTime, + minimumUpdateScore: types.UpdateScore{ + SignerCount: uint32(signerThreshold), + SubPeriodIndex: params.SyncPeriodLength / 16, + }, + } + + // check validity constraints + if !s.updates.IsEmpty() { + if s.fixedRoots.IsEmpty() || s.updates.First < s.fixedRoots.First || + s.updates.First >= s.fixedRoots.AfterLast { + log.Crit("Inconsistent database error: first update is not in the fixed roots range") + } + if s.committees.First > s.updates.First || s.committees.AfterLast <= s.updates.AfterLast { + log.Crit("Inconsistent database error: missing committees in update range") + } + } + if !s.committees.IsEmpty() { + if s.fixedRoots.IsEmpty() || s.committees.First < s.fixedRoots.First || + s.committees.First >= s.fixedRoots.AfterLast { + log.Crit("Inconsistent database error: first committee is not in the fixed roots range") + } + if s.committees.AfterLast > s.fixedRoots.AfterLast && s.committees.AfterLast > s.updates.AfterLast+1 { + log.Crit("Inconsistent database error: last committee is neither in the fixed roots range nor proven by updates") + } + log.Trace("Sync committee chain loaded", "first period", s.committees.First, "last period", s.committees.AfterLast-1) + } + // roll back invalid updates (might be necessary if forks have been changed since last time) + var batch ethdb.Batch + for !s.updates.IsEmpty() { + if update := s.updates.get(s.updates.AfterLast - 1); update == nil || s.verifyUpdate(update) { + if update == nil { + log.Crit("Sync committee update missing", "period", s.updates.AfterLast-1) + } + break + } + if batch == nil { + batch = s.db.NewBatch() + } + s.rollback(batch, s.updates.AfterLast) + } + if batch != nil { + if err := batch.Write(); err != nil { + log.Error("Error writing batch into chain database", "error", err) + } + } + return s +} + +func (s *CommitteeChain) Reset() { + s.lock.Lock() + defer s.lock.Unlock() + + batch := s.db.NewBatch() + s.rollback(batch, 0) + if err := batch.Write(); err != nil { + log.Error("Error writing batch into chain database", "error", err) + } +} + +func (s *CommitteeChain) AddFixedRoot(period uint64, root common.Hash) error { + s.lock.Lock() + defer s.lock.Unlock() + + batch := s.db.NewBatch() + oldRoot := s.getCommitteeRoot(period) + if !s.fixedRoots.CanExpand(period) { + if root != oldRoot { + return ErrInvalidPeriod + } + for p := s.fixedRoots.AfterLast; p <= period; p++ { + s.fixedRoots.add(batch, p, s.getCommitteeRoot(p)) + } + } + if oldRoot != (common.Hash{}) && (oldRoot != root) { + // existing old root was different, we have to reorg the chain + s.rollback(batch, period) + } + s.fixedRoots.add(batch, period, root) + if err := batch.Write(); err != nil { + log.Error("Error writing batch into chain database", "error", err) + return err + } + return nil +} + +func (s *CommitteeChain) DeleteFixedRootsFrom(period uint64) error { + s.lock.Lock() + defer s.lock.Unlock() + + if period >= s.fixedRoots.AfterLast { + return nil + } + batch := s.db.NewBatch() + s.fixedRoots.deleteFrom(batch, period) + if s.updates.IsEmpty() || period <= s.updates.First { + s.updates.deleteFrom(batch, period) + s.deleteCommitteesFrom(batch, period) + } else { + fromPeriod := s.updates.AfterLast + 1 + if period > fromPeriod { + fromPeriod = period + } + s.deleteCommitteesFrom(batch, fromPeriod) + } + if err := batch.Write(); err != nil { + log.Error("Error writing batch into chain database", "error", err) + return err + } + return nil +} + +func (s *CommitteeChain) deleteCommitteesFrom(batch ethdb.Batch, period uint64) { + deleted := s.committees.deleteFrom(batch, period) + for period := deleted.First; period < deleted.AfterLast; period++ { + s.syncCommitteeCache.Remove(period) + } +} + +func (s *CommitteeChain) GetCommittee(period uint64) *types.SerializedSyncCommittee { + return s.committees.get(period) +} + +func (s *CommitteeChain) AddCommittee(period uint64, committee *types.SerializedSyncCommittee) error { + s.lock.Lock() + defer s.lock.Unlock() + + if !s.committees.CanExpand(period) { + return ErrInvalidPeriod + } + root := s.getCommitteeRoot(period) + if root == (common.Hash{}) { + return ErrInvalidPeriod + } + if root != committee.Root() { + return ErrWrongCommitteeRoot + } + if !s.committees.Includes(period) { + s.committees.add(nil, period, committee) + s.syncCommitteeCache.Remove(period) + } + return nil +} + +func (s *CommitteeChain) GetUpdate(period uint64) *types.LightClientUpdate { + return s.updates.get(period) +} + +func (s *CommitteeChain) InsertUpdate(update *types.LightClientUpdate, nextCommittee *types.SerializedSyncCommittee) error { + s.lock.Lock() + defer s.lock.Unlock() + + period := update.AttestedHeader.Header.SyncPeriod() + if !s.updates.CanExpand(period) || !s.committees.Includes(period) { + return ErrInvalidPeriod + } + if s.minimumUpdateScore.BetterThan(update.Score()) { + return ErrInvalidUpdate + } + oldRoot := s.getCommitteeRoot(period + 1) + reorg := oldRoot != (common.Hash{}) && oldRoot != update.NextSyncCommitteeRoot + if oldUpdate := s.updates.get(period); oldUpdate != nil && !update.Score().BetterThan(oldUpdate.Score()) { + // a better or equal update already exists; no changes, only fail if new one tried to reorg + if reorg { + return ErrCannotReorg + } + return nil + } + if s.fixedRoots.Includes(period+1) && reorg { + return ErrCannotReorg + } + if !s.verifyUpdate(update) { + return ErrInvalidUpdate + } + addCommittee := !s.committees.Includes(period+1) || reorg + if addCommittee { + if nextCommittee == nil { + return ErrNeedCommittee + } + if nextCommittee.Root() != update.NextSyncCommitteeRoot { + return ErrWrongCommitteeRoot + } + } + batch := s.db.NewBatch() + if reorg { + s.rollback(batch, period+1) + } + if addCommittee { + s.committees.add(batch, period+1, nextCommittee) + s.syncCommitteeCache.Remove(period + 1) + } + s.updates.add(batch, period, update) + if err := batch.Write(); err != nil { + log.Error("Error writing batch into chain database", "error", err) + return err + } + log.Info("Inserted new committee update", "period", period, "next committee root", update.NextSyncCommitteeRoot) + return nil +} + +func (s *CommitteeChain) NextSyncPeriod() (uint64, bool) { + s.lock.RLock() + defer s.lock.RUnlock() + + if s.committees.IsEmpty() { + return 0, false + } + if !s.updates.IsEmpty() { + return s.updates.AfterLast, true + } + return s.committees.AfterLast - 1, true +} + +func (s *CommitteeChain) rollback(batch ethdb.Batch, period uint64) { + s.deleteCommitteesFrom(batch, period) + s.fixedRoots.deleteFrom(batch, period) + if period > 0 { + period-- + } + s.updates.deleteFrom(batch, period) +} + +func (s *CommitteeChain) getCommitteeRoot(period uint64) common.Hash { + if root := s.fixedRoots.get(period); root != (common.Hash{}) || period == 0 { + return root + } + if update := s.updates.get(period - 1); update != nil { + return update.NextSyncCommitteeRoot + } + return common.Hash{} +} + +// getSyncCommittee returns the deserialized sync committee at the given period +// of the current local committee chain (tracker mutex lock expected). +func (s *CommitteeChain) getSyncCommittee(period uint64) syncCommittee { + if c, ok := s.syncCommitteeCache.Get(period); ok { + return c + } + if sc := s.committees.get(period); sc != nil { + c, err := s.sigVerifier.deserializeSyncCommittee(sc) + if err != nil { + log.Error("Sync committee deserialization error", "error", err) + return nil + } + s.syncCommitteeCache.Add(period, c) + return c + } + log.Error("Missing serialized sync committee", "period", period) + return nil +} + +// VerifySignedHeader returns true if the given signed head has a valid signature +// according to the local committee chain. The caller should ensure that the +// committees advertised by the same source where the signed head came from are +// synced before verifying the signature. +// The age of the header is also returned (the time elapsed since the beginning +// of the given slot, according to the local system clock). If enforceTime is +// true then negative age (future) headers are rejected. +func (s *CommitteeChain) VerifySignedHeader(head types.SignedHeader) (bool, time.Duration) { + s.lock.RLock() + defer s.lock.RUnlock() + + return s.verifySignedHeader(head) +} + +// (rlock required) +func (s *CommitteeChain) verifySignedHeader(head types.SignedHeader) (bool, time.Duration) { + var ( + slotTime = int64(time.Second) * int64(s.config.GenesisTime+head.Header.Slot*12) + age = time.Duration(s.unixNano() - slotTime) + ) + if s.enforceTime && age < 0 { + return false, age + } + committee := s.getSyncCommittee(types.SyncPeriod(head.SignatureSlot)) + if committee == nil { + return false, age + } + if signingRoot, err := s.config.Forks.SigningRoot(head.Header); err == nil { + return s.sigVerifier.verifySignature(committee, signingRoot, &head.Signature), age + } + return false, age +} + +// verifyUpdate checks whether the header signature is correct and the update +// fits into the specified constraints (assumes that the update has been +// successfully validated previously) +// (rlock required) +func (s *CommitteeChain) verifyUpdate(update *types.LightClientUpdate) bool { + // Note: SignatureSlot determines the sync period of the committee used for signature + // verification. Though in reality SignatureSlot is always bigger than update.Header.Slot, + // setting them as equal here enforces the rule that they have to be in the same sync + // period in order for the light client update proof to be meaningful. + ok, age := s.verifySignedHeader(update.AttestedHeader) + if age < 0 { + log.Warn("Future committee update received", "age", age) + } + return ok +} + +type canonicalStore[T any] struct { + Range + db ethdb.KeyValueStore + keyPrefix []byte + cache *lru.Cache[uint64, T] + encode func(T) ([]byte, error) + decode func([]byte) (T, error) +} + +func newCanonicalStore[T any](db ethdb.KeyValueStore, keyPrefix []byte, + encode func(T) ([]byte, error), decode func([]byte) (T, error)) *canonicalStore[T] { + cs := &canonicalStore[T]{ + db: db, + keyPrefix: keyPrefix, + encode: encode, + decode: decode, + cache: lru.NewCache[uint64, T](100), + } + var ( + iter = db.NewIterator(keyPrefix, nil) + kl = len(keyPrefix) + ) + for iter.Next() { + period := binary.BigEndian.Uint64(iter.Key()[kl : kl+8]) + if cs.First == 0 { + cs.First = period + } else if cs.AfterLast != period { + if iter.Next() { + log.Error("Gap in the canonical chain database") + } + break // continuity guaranteed + } + cs.AfterLast = period + 1 + } + iter.Release() + return cs +} + +func (cs *canonicalStore[T]) getDbKey(period uint64) []byte { + var ( + kl = len(cs.keyPrefix) + key = make([]byte, kl+8) + ) + copy(key[:kl], cs.keyPrefix) + binary.BigEndian.PutUint64(key[kl:], period) + return key +} + +func (cs *canonicalStore[T]) add(batch ethdb.Batch, period uint64, value T) { + if !cs.CanExpand(period) { + log.Error("Cannot expand canonical store", "range.first", cs.First, "range.afterLast", cs.AfterLast, "new period", period) + return + } + enc, err := cs.encode(value) + if err != nil { + log.Error("Error encoding canonical store value", "error", err) + return + } + key := cs.getDbKey(period) + if batch != nil { + err = batch.Put(key, enc) + } else { + err = cs.db.Put(key, enc) + } + if err != nil { + log.Error("Error writing into canonical store value database", "error", err) + } + cs.cache.Add(period, value) + cs.Expand(period) +} + +// should only be used in batch mode +func (cs *canonicalStore[T]) deleteFrom(batch ethdb.Batch, fromPeriod uint64) (deleted Range) { + if fromPeriod >= cs.AfterLast { + return + } + if fromPeriod < cs.First { + fromPeriod = cs.First + } + deleted = Range{First: fromPeriod, AfterLast: cs.AfterLast} + for period := fromPeriod; period < cs.AfterLast; period++ { + batch.Delete(cs.getDbKey(period)) + cs.cache.Remove(period) + } + if fromPeriod > cs.First { + cs.AfterLast = fromPeriod + } else { + cs.Range = Range{} + } + return +} + +func (cs *canonicalStore[T]) get(period uint64) T { + if value, ok := cs.cache.Get(period); ok { + return value + } + var value T + if enc, err := cs.db.Get(cs.getDbKey(period)); err == nil { + if v, err := cs.decode(enc); err == nil { + value = v + } else { + log.Error("Error decoding canonical store value", "error", err) + } + } + return value +} diff --git a/beacon/light/committee_chain_test.go b/beacon/light/committee_chain_test.go new file mode 100644 index 0000000000..ac073572e2 --- /dev/null +++ b/beacon/light/committee_chain_test.go @@ -0,0 +1,356 @@ +// Copyright 2022 The go-ethereum Authors +// This file is part of the go-ethereum library. +// +// The go-ethereum library is free software: you can redistribute it and/or modify +// it under the terms of the GNU Lesser General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// The go-ethereum library is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Lesser General Public License for more details. +// +// You should have received a copy of the GNU Lesser General Public License +// along with the go-ethereum library. If not, see . + +package light + +import ( + "math/rand" + "testing" + "time" + + "github.com/ethereum/go-ethereum/beacon/params" + "github.com/ethereum/go-ethereum/beacon/types" + "github.com/ethereum/go-ethereum/common/mclock" + "github.com/ethereum/go-ethereum/ethdb/memorydb" +) + +var ( + testGenesis = newTestGenesis() + testGenesis2 = newTestGenesis() + + tfBase = newTestForks(testGenesis, types.Forks{ + &types.Fork{Epoch: 0, Version: []byte{0}}, + }) + tfAlternative = newTestForks(testGenesis, types.Forks{ + &types.Fork{Epoch: 0, Version: []byte{0}}, + &types.Fork{Epoch: 0x700, Version: []byte{1}}, + }) + tfAnotherGenesis = newTestForks(testGenesis2, types.Forks{ + &types.Fork{Epoch: 0, Version: []byte{0}}, + }) + + tcBase = newTestCommitteeChain(nil, tfBase, true, 0, 10, 400, false) + tcBaseWithInvalidUpdates = newTestCommitteeChain(tcBase, tfBase, false, 5, 10, 200, false) // signer count too low + tcBaseWithBetterUpdates = newTestCommitteeChain(tcBase, tfBase, false, 5, 10, 440, false) + tcReorgWithWorseUpdates = newTestCommitteeChain(tcBase, tfBase, true, 5, 10, 400, false) + tcReorgWithWorseUpdates2 = newTestCommitteeChain(tcBase, tfBase, true, 5, 10, 380, false) + tcReorgWithBetterUpdates = newTestCommitteeChain(tcBase, tfBase, true, 5, 10, 420, false) + tcReorgWithFinalizedUpdates = newTestCommitteeChain(tcBase, tfBase, true, 5, 10, 400, true) + tcFork = newTestCommitteeChain(tcBase, tfAlternative, true, 7, 10, 400, false) + tcAnotherGenesis = newTestCommitteeChain(nil, tfAnotherGenesis, true, 0, 10, 400, false) +) + +func TestCommitteeChainFixedRoots(t *testing.T) { + for _, reload := range []bool{false, true} { + c := newCommitteeChainTest(t, tfBase, 300, true) + c.setClockPeriod(7) + c.addFixedRoot(tcBase, 4, nil) + c.addFixedRoot(tcBase, 5, nil) + c.addFixedRoot(tcBase, 6, nil) + c.addFixedRoot(tcBase, 8, ErrInvalidPeriod) // range has to be continuoous + c.addFixedRoot(tcBase, 3, nil) + c.addFixedRoot(tcBase, 2, nil) + if reload { + c.reloadChain() + } + c.addCommittee(tcBase, 4, nil) + c.addCommittee(tcBase, 6, ErrInvalidPeriod) // range has to be continuoous + c.addCommittee(tcBase, 5, nil) + c.addCommittee(tcBase, 6, nil) + c.addCommittee(tcAnotherGenesis, 3, ErrWrongCommitteeRoot) + c.addCommittee(tcBase, 3, nil) + if reload { + c.reloadChain() + } + c.verifyRange(tcBase, 3, 6) + } +} + +func TestCommitteeChainCheckpointSync(t *testing.T) { + for _, enforceTime := range []bool{false, true} { + for _, reload := range []bool{false, true} { + c := newCommitteeChainTest(t, tfBase, 300, enforceTime) + if enforceTime { + c.setClockPeriod(6) + } + c.insertUpdate(tcBase, 3, true, ErrInvalidPeriod) + c.addFixedRoot(tcBase, 3, nil) + c.addFixedRoot(tcBase, 4, nil) + c.insertUpdate(tcBase, 4, true, ErrInvalidPeriod) // still no committee + c.addCommittee(tcBase, 3, nil) + c.addCommittee(tcBase, 4, nil) + if reload { + c.reloadChain() + } + c.verifyRange(tcBase, 3, 4) + c.insertUpdate(tcBase, 3, false, nil) // update can be added without committee here + c.insertUpdate(tcBase, 4, false, ErrNeedCommittee) // but not here as committee 5 is not there yet + c.insertUpdate(tcBase, 4, true, nil) + c.verifyRange(tcBase, 3, 5) + c.insertUpdate(tcBaseWithInvalidUpdates, 5, true, ErrInvalidUpdate) // signer count too low + c.insertUpdate(tcBase, 5, true, nil) + if reload { + c.reloadChain() + } + if enforceTime { + c.insertUpdate(tcBase, 6, true, ErrInvalidUpdate) // future update rejected + c.setClockPeriod(7) + } + c.insertUpdate(tcBase, 6, true, nil) // when the time comes it's accepted + if reload { + c.reloadChain() + } + if enforceTime { + c.verifyRange(tcBase, 3, 6) // committee 7 is there but still in the future + c.setClockPeriod(8) + } + c.verifyRange(tcBase, 3, 7) // now period 7 can also be verified + // try reverse syncing an update + c.insertUpdate(tcBase, 2, false, ErrInvalidPeriod) // fixed committee is needed first + c.addFixedRoot(tcBase, 2, nil) + c.addCommittee(tcBase, 2, nil) + c.insertUpdate(tcBase, 2, false, nil) + c.verifyRange(tcBase, 2, 7) + } + } +} + +func TestCommitteeChainReorg(t *testing.T) { + for _, reload := range []bool{false, true} { + for _, addBetterUpdates := range []bool{false, true} { + c := newCommitteeChainTest(t, tfBase, 300, true) + c.setClockPeriod(11) + c.addFixedRoot(tcBase, 3, nil) + c.addFixedRoot(tcBase, 4, nil) + c.addCommittee(tcBase, 3, nil) + for period := uint64(3); period < 10; period++ { + c.insertUpdate(tcBase, period, true, nil) + } + if reload { + c.reloadChain() + } + c.verifyRange(tcBase, 3, 10) + c.insertUpdate(tcReorgWithWorseUpdates, 5, true, ErrCannotReorg) + c.insertUpdate(tcReorgWithWorseUpdates2, 5, true, ErrCannotReorg) + if addBetterUpdates { + // add better updates for the base chain and expect first reorg to fail + // (only add updates as committees should be the same) + for period := uint64(5); period < 10; period++ { + c.insertUpdate(tcBaseWithBetterUpdates, period, false, nil) + } + if reload { + c.reloadChain() + } + c.verifyRange(tcBase, 3, 10) // still on the same chain + c.insertUpdate(tcReorgWithBetterUpdates, 5, true, ErrCannotReorg) + } else { + // reorg with better updates + c.insertUpdate(tcReorgWithBetterUpdates, 5, false, ErrNeedCommittee) + c.verifyRange(tcBase, 3, 10) // no success yet, still on the base chain + c.verifyRange(tcReorgWithBetterUpdates, 3, 5) + c.insertUpdate(tcReorgWithBetterUpdates, 5, true, nil) + // successful reorg, base chain should only match before the reorg period + if reload { + c.reloadChain() + } + c.verifyRange(tcBase, 3, 5) + c.verifyRange(tcReorgWithBetterUpdates, 3, 6) + for period := uint64(6); period < 10; period++ { + c.insertUpdate(tcReorgWithBetterUpdates, period, true, nil) + } + c.verifyRange(tcReorgWithBetterUpdates, 3, 10) + } + // reorg with finalized updates; should succeed even if base chain updates + // have been improved because a finalized update beats everything else + c.insertUpdate(tcReorgWithFinalizedUpdates, 5, false, ErrNeedCommittee) + c.insertUpdate(tcReorgWithFinalizedUpdates, 5, true, nil) + if reload { + c.reloadChain() + } + c.verifyRange(tcReorgWithFinalizedUpdates, 3, 6) + for period := uint64(6); period < 10; period++ { + c.insertUpdate(tcReorgWithFinalizedUpdates, period, true, nil) + } + c.verifyRange(tcReorgWithFinalizedUpdates, 3, 10) + } + } +} + +func TestCommitteeChainFork(t *testing.T) { + c := newCommitteeChainTest(t, tfAlternative, 300, true) + c.setClockPeriod(11) + // trying to sync a chain on an alternative fork with the base chain data + c.addFixedRoot(tcBase, 0, nil) + c.addFixedRoot(tcBase, 1, nil) + c.addCommittee(tcBase, 0, nil) + // shared section should sync without errors + for period := uint64(0); period < 7; period++ { + c.insertUpdate(tcBase, period, true, nil) + } + c.insertUpdate(tcBase, 7, true, ErrInvalidUpdate) // wrong fork + // committee root #7 is still the same but signatures are already signed with + // a different fork id so period 7 should only verify on the alternative fork + c.verifyRange(tcBase, 0, 6) + c.verifyRange(tcFork, 0, 7) + for period := uint64(7); period < 10; period++ { + c.insertUpdate(tcFork, period, true, nil) + } + c.verifyRange(tcFork, 0, 10) + // reload the chain while switching to the base fork + c.config = tfBase + c.reloadChain() + // updates 7..9 should be rolled back now + c.verifyRange(tcFork, 0, 6) // again, period 7 only verifies on the right fork + c.verifyRange(tcBase, 0, 7) + c.insertUpdate(tcFork, 7, true, ErrInvalidUpdate) // wrong fork + for period := uint64(7); period < 10; period++ { + c.insertUpdate(tcBase, period, true, nil) + } + c.verifyRange(tcBase, 0, 10) +} + +type committeeChainTest struct { + t *testing.T + db *memorydb.Database + clock *mclock.Simulated + config types.ChainConfig + signerThreshold int + enforceTime bool + chain *CommitteeChain +} + +func newCommitteeChainTest(t *testing.T, config types.ChainConfig, signerThreshold int, enforceTime bool) *committeeChainTest { + c := &committeeChainTest{ + t: t, + db: memorydb.New(), + clock: &mclock.Simulated{}, + config: config, + signerThreshold: signerThreshold, + enforceTime: enforceTime, + } + c.chain = NewCommitteeChain(c.db, &config, signerThreshold, enforceTime, DummyVerifier{}, c.clock, func() int64 { return int64(c.clock.Now()) }) + return c +} + +func (c *committeeChainTest) reloadChain() { + c.chain = NewCommitteeChain(c.db, &c.config, c.signerThreshold, c.enforceTime, DummyVerifier{}, c.clock, func() int64 { return int64(c.clock.Now()) }) +} + +func (c *committeeChainTest) setClockPeriod(period float64) { + target := mclock.AbsTime(period * float64(time.Second*12*params.SyncPeriodLength)) + wait := time.Duration(target - c.clock.Now()) + if wait < 0 { + c.t.Fatalf("Invalid setClockPeriod") + } + c.clock.Run(wait) +} + +func (c *committeeChainTest) addFixedRoot(tc *testCommitteeChain, period uint64, expErr error) { + if err := c.chain.AddFixedRoot(period, tc.periods[period].committee.Root()); err != expErr { + c.t.Errorf("Incorrect error output from AddFixedRoot at period %d (expected %v, got %v)", period, expErr, err) + } +} + +func (c *committeeChainTest) addCommittee(tc *testCommitteeChain, period uint64, expErr error) { + if err := c.chain.AddCommittee(period, tc.periods[period].committee); err != expErr { + c.t.Errorf("Incorrect error output from AddCommittee at period %d (expected %v, got %v)", period, expErr, err) + } +} + +func (c *committeeChainTest) insertUpdate(tc *testCommitteeChain, period uint64, addCommittee bool, expErr error) { + var committee *types.SerializedSyncCommittee + if addCommittee { + committee = tc.periods[period+1].committee + } + if err := c.chain.InsertUpdate(tc.periods[period].update, committee); err != expErr { + c.t.Errorf("Incorrect error output from InsertUpdate at period %d (expected %v, got %v)", period, expErr, err) + } +} + +func (c *committeeChainTest) verifySignedHeader(tc *testCommitteeChain, period float64, expOk bool) { + slot := uint64(period * float64(params.SyncPeriodLength)) + signedHead := GenerateTestSignedHeader(types.Header{Slot: slot}, &tc.config, tc.periods[types.SyncPeriod(slot)].committee, slot+1, 400) + if ok, _ := c.chain.VerifySignedHeader(signedHead); ok != expOk { + c.t.Errorf("Incorrect output from VerifySignedHeader at period %f (expected %v, got %v)", period, expOk, ok) + } +} + +func (c *committeeChainTest) verifyRange(tc *testCommitteeChain, begin, end uint64) { + if begin > 0 { + c.verifySignedHeader(tc, float64(begin)-0.5, false) + } + for period := begin; period <= end; period++ { + c.verifySignedHeader(tc, float64(period)+0.5, true) + } + c.verifySignedHeader(tc, float64(end)+1.5, false) +} + +func newTestGenesis() types.ChainConfig { + var config types.ChainConfig + rand.Read(config.GenesisValidatorsRoot[:]) + return config +} + +func newTestForks(config types.ChainConfig, forks types.Forks) types.ChainConfig { + for _, fork := range forks { + config.AddFork(fork.Name, fork.Epoch, fork.Version) + } + return config +} + +func newTestCommitteeChain(parent *testCommitteeChain, config types.ChainConfig, newCommittees bool, begin, end int, signerCount int, finalizedHeader bool) *testCommitteeChain { + tc := &testCommitteeChain{ + config: config, + } + if parent != nil { + tc.periods = make([]testPeriod, len(parent.periods)) + copy(tc.periods, parent.periods) + } + if newCommittees { + if begin == 0 { + tc.fillCommittees(begin, end+1) + } else { + tc.fillCommittees(begin+1, end+1) + } + } + tc.fillUpdates(begin, end, signerCount, finalizedHeader) + return tc +} + +type testPeriod struct { + committee *types.SerializedSyncCommittee + update *types.LightClientUpdate +} + +type testCommitteeChain struct { + periods []testPeriod + config types.ChainConfig +} + +func (tc *testCommitteeChain) fillCommittees(begin, end int) { + if len(tc.periods) <= end { + tc.periods = append(tc.periods, make([]testPeriod, end+1-len(tc.periods))...) + } + for i := begin; i <= end; i++ { + tc.periods[i].committee = GenerateTestCommittee() + } +} + +func (tc *testCommitteeChain) fillUpdates(begin, end int, signerCount int, finalizedHeader bool) { + for i := begin; i <= end; i++ { + tc.periods[i].update = GenerateTestUpdate(&tc.config, uint64(i), tc.periods[i].committee, tc.periods[i+1].committee, signerCount, finalizedHeader) + } +} diff --git a/beacon/light/range.go b/beacon/light/range.go new file mode 100644 index 0000000000..ee01eb550a --- /dev/null +++ b/beacon/light/range.go @@ -0,0 +1,52 @@ +// Copyright 2023 The go-ethereum Authors +// This file is part of the go-ethereum library. +// +// The go-ethereum library is free software: you can redistribute it and/or modify +// it under the terms of the GNU Lesser General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// The go-ethereum library is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Lesser General Public License for more details. +// +// You should have received a copy of the GNU Lesser General Public License +// along with the go-ethereum library. If not, see . + +package light + +type Range struct { + First uint64 + AfterLast uint64 +} + +func (a Range) IsEmpty() bool { + return a.AfterLast == a.First +} + +func (a Range) Includes(period uint64) bool { + return period >= a.First && period < a.AfterLast +} + +func (a Range) CanExpand(period uint64) bool { + return a.IsEmpty() || (period+1 >= a.First && period <= a.AfterLast) +} + +func (a *Range) Expand(period uint64) { + if a.IsEmpty() { + a.First, a.AfterLast = period, period+1 + return + } + if a.Includes(period) { + return + } + if a.First == period+1 { + a.First-- + return + } + if a.AfterLast == period { + a.AfterLast++ + return + } +} diff --git a/beacon/light/test_helpers.go b/beacon/light/test_helpers.go new file mode 100644 index 0000000000..7c77d640f2 --- /dev/null +++ b/beacon/light/test_helpers.go @@ -0,0 +1,151 @@ +// Copyright 2023 The go-ethereum Authors +// This file is part of the go-ethereum library. +// +// The go-ethereum library is free software: you can redistribute it and/or modify +// it under the terms of the GNU Lesser General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// The go-ethereum library is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Lesser General Public License for more details. +// +// You should have received a copy of the GNU Lesser General Public License +// along with the go-ethereum library. If not, see . + +package light + +import ( + "crypto/sha256" + "math/rand" + + "github.com/ethereum/go-ethereum/beacon/merkle" + "github.com/ethereum/go-ethereum/beacon/params" + "github.com/ethereum/go-ethereum/beacon/types" + "github.com/ethereum/go-ethereum/common" +) + +func GenerateTestCommittee() *types.SerializedSyncCommittee { + s := new(types.SerializedSyncCommittee) + rand.Read(s[:32]) + return s +} + +func GenerateTestUpdate(config *types.ChainConfig, period uint64, committee, nextCommittee *types.SerializedSyncCommittee, signerCount int, finalizedHeader bool) *types.LightClientUpdate { + update := new(types.LightClientUpdate) + update.NextSyncCommitteeRoot = nextCommittee.Root() + var attestedHeader types.Header + if finalizedHeader { + update.FinalizedHeader = new(types.Header) + *update.FinalizedHeader, update.NextSyncCommitteeBranch = makeTestHeaderWithMerkleProof(types.SyncPeriodStart(period)+100, params.StateIndexNextSyncCommittee, merkle.Value(update.NextSyncCommitteeRoot)) + attestedHeader, update.FinalityBranch = makeTestHeaderWithMerkleProof(types.SyncPeriodStart(period)+200, params.StateIndexFinalBlock, merkle.Value(update.FinalizedHeader.Hash())) + } else { + attestedHeader, update.NextSyncCommitteeBranch = makeTestHeaderWithMerkleProof(types.SyncPeriodStart(period)+2000, params.StateIndexNextSyncCommittee, merkle.Value(update.NextSyncCommitteeRoot)) + } + update.AttestedHeader = GenerateTestSignedHeader(attestedHeader, config, committee, attestedHeader.Slot+1, signerCount) + return update +} + +func GenerateTestSignedHeader(header types.Header, config *types.ChainConfig, committee *types.SerializedSyncCommittee, signatureSlot uint64, signerCount int) types.SignedHeader { + bitmask := makeBitmask(signerCount) + signingRoot, _ := config.Forks.SigningRoot(header) + c, _ := DummyVerifier{}.deserializeSyncCommittee(committee) + return types.SignedHeader{ + Header: header, + Signature: types.SyncAggregate{ + Signers: bitmask, + Signature: makeDummySignature(c.(dummySyncCommittee), signingRoot, bitmask), + }, + SignatureSlot: signatureSlot, + } +} + +func GenerateTestCheckpoint(period uint64, committee *types.SerializedSyncCommittee) *CheckpointData { + header, branch := makeTestHeaderWithMerkleProof(types.SyncPeriodStart(period)+200, params.StateIndexSyncCommittee, merkle.Value(committee.Root())) + return &CheckpointData{ + Header: header, + Committee: committee, + CommitteeRoot: committee.Root(), + CommitteeBranch: branch, + } +} + +func makeBitmask(signerCount int) (bitmask [params.SyncCommitteeBitmaskSize]byte) { + for i := 0; i < params.SyncCommitteeSize; i++ { + if rand.Intn(params.SyncCommitteeSize-i) < signerCount { + bitmask[i/8] += byte(1) << (i & 7) + signerCount-- + } + } + return +} + +func makeTestHeaderWithMerkleProof(slot, index uint64, value merkle.Value) (types.Header, merkle.Values) { + var branch merkle.Values + hasher := sha256.New() + for index > 1 { + var proofHash merkle.Value + rand.Read(proofHash[:]) + hasher.Reset() + if index&1 == 0 { + hasher.Write(value[:]) + hasher.Write(proofHash[:]) + } else { + hasher.Write(proofHash[:]) + hasher.Write(value[:]) + } + hasher.Sum(value[:0]) + index >>= 1 + branch = append(branch, proofHash) + } + return types.Header{Slot: slot, StateRoot: common.Hash(value)}, branch +} + +// syncCommittee holds either a blsSyncCommittee or a fake dummySyncCommittee used for testing +type syncCommittee interface{} + +// committeeSigVerifier verifies sync committee signatures (either proper BLS +// signatures or fake signatures used for testing) +type committeeSigVerifier interface { + deserializeSyncCommittee(s *types.SerializedSyncCommittee) (syncCommittee, error) + verifySignature(committee syncCommittee, signedRoot common.Hash, aggregate *types.SyncAggregate) bool +} + +// BLSVerifier implements committeeSigVerifier +type BLSVerifier struct{} + +// deserializeSyncCommittee implements committeeSigVerifier +func (BLSVerifier) deserializeSyncCommittee(s *types.SerializedSyncCommittee) (syncCommittee, error) { + return s.Deserialize() +} + +// verifySignature implements committeeSigVerifier +func (BLSVerifier) verifySignature(committee syncCommittee, signingRoot common.Hash, aggregate *types.SyncAggregate) bool { + return committee.(*types.SyncCommittee).VerifySignature(signingRoot, aggregate) +} + +type dummySyncCommittee [32]byte + +// DummyVerifier implements committeeSigVerifier +type DummyVerifier struct{} + +// deserializeSyncCommittee implements committeeSigVerifier +func (DummyVerifier) deserializeSyncCommittee(s *types.SerializedSyncCommittee) (syncCommittee, error) { + var sc dummySyncCommittee + copy(sc[:], s[:32]) + return sc, nil +} + +// verifySignature implements committeeSigVerifier +func (DummyVerifier) verifySignature(committee syncCommittee, signingRoot common.Hash, aggregate *types.SyncAggregate) bool { + return aggregate.Signature == makeDummySignature(committee.(dummySyncCommittee), signingRoot, aggregate.Signers) +} + +func makeDummySignature(committee dummySyncCommittee, signingRoot common.Hash, bitmask [params.SyncCommitteeBitmaskSize]byte) (sig [params.BLSSignatureSize]byte) { + for i, b := range committee[:] { + sig[i] = b ^ signingRoot[i] + } + copy(sig[32:], bitmask[:]) + return +}