From 4f67f433c82d916d75aaae1cc9bb6844ddfe78f7 Mon Sep 17 00:00:00 2001 From: Zsolt Felfoldi Date: Tue, 20 Sep 2022 21:36:31 +0200 Subject: [PATCH] cmd/blsync, beacon/light: standalone beacon light sync tool --- beacon/light/api/light_api.go | 489 +++++++++++++++++++++++++ beacon/light/api/sync_server.go | 156 ++++++++ beacon/light/checkpoint.go | 109 ++++++ beacon/light/committee_chain.go | 548 ++++++++++++++++++++++++++++ beacon/light/head_tracker.go | 95 +++++ beacon/light/request/lock.go | 47 +++ beacon/light/request/scheduler.go | 237 ++++++++++++ beacon/light/request/server.go | 166 +++++++++ beacon/light/signature.go | 85 +++++ beacon/light/sync/head_sync.go | 105 ++++++ beacon/light/sync/update_sync.go | 181 +++++++++ beacon/light/types/committee.go | 204 +++++++++++ beacon/light/types/forks.go | 180 +++++++++ beacon/light/types/header.go | 158 ++++++++ beacon/light/types/protocol.go | 242 ++++++++++++ beacon/merkle/binary_merkle.go | 494 +++++++++++++++++++++++++ beacon/merkle/binary_merkle_test.go | 259 +++++++++++++ beacon/params/constants.go | 29 ++ beacon/params/tree_indices.go | 46 +++ cmd/blsync/config.go | 183 ++++++++++ cmd/blsync/main.go | 234 ++++++++++++ cmd/utils/flags.go | 53 +++ common/types.go | 20 + go.mod | 7 + go.sum | 19 + internal/flags/categories.go | 1 + node/node.go | 24 +- 27 files changed, 4362 insertions(+), 9 deletions(-) create mode 100644 beacon/light/api/light_api.go create mode 100644 beacon/light/api/sync_server.go create mode 100644 beacon/light/checkpoint.go create mode 100644 beacon/light/committee_chain.go create mode 100644 beacon/light/head_tracker.go create mode 100644 beacon/light/request/lock.go create mode 100644 beacon/light/request/scheduler.go create mode 100644 beacon/light/request/server.go create mode 100644 beacon/light/signature.go create mode 100644 beacon/light/sync/head_sync.go create mode 100644 beacon/light/sync/update_sync.go create mode 100644 beacon/light/types/committee.go create mode 100644 beacon/light/types/forks.go create mode 100644 beacon/light/types/header.go create mode 100644 beacon/light/types/protocol.go create mode 100644 beacon/merkle/binary_merkle.go create mode 100644 beacon/merkle/binary_merkle_test.go create mode 100644 beacon/params/constants.go create mode 100644 beacon/params/tree_indices.go create mode 100644 cmd/blsync/config.go create mode 100644 cmd/blsync/main.go diff --git a/beacon/light/api/light_api.go b/beacon/light/api/light_api.go new file mode 100644 index 0000000000..0c4fc14a3c --- /dev/null +++ b/beacon/light/api/light_api.go @@ -0,0 +1,489 @@ +// 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 detaiapi. +// +// You should have received a copy of the GNU Lesser General Public License +// along with the go-ethereum library. If not, see . + +package api + +import ( + "encoding/json" + "errors" + "fmt" + "io" + "math/big" + "net/http" + "time" + + "github.com/donovanhide/eventsource" + "github.com/ethereum/go-ethereum/beacon/light" + "github.com/ethereum/go-ethereum/beacon/light/types" + "github.com/ethereum/go-ethereum/beacon/merkle" + "github.com/ethereum/go-ethereum/beacon/params" + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/common/hexutil" + ctypes "github.com/ethereum/go-ethereum/core/types" + "github.com/ethereum/go-ethereum/trie" + "github.com/holiman/uint256" + "github.com/protolambda/zrnt/eth2/beacon/capella" + "github.com/protolambda/zrnt/eth2/configs" + "github.com/protolambda/ztyp/tree" +) + +var ( + ErrNotFound = errors.New("404 Not Found") + ErrInternal = errors.New("500 Internal Server Error") +) + +// BeaconLightApi requests light client information from a beacon node REST API. +// Note: all required API endpoints are currently only implemented by Lodestar. +type BeaconLightApi struct { + url string + client *http.Client + customHeaders map[string]string +} + +func NewBeaconLightApi(url string, customHeaders map[string]string) *BeaconLightApi { + return &BeaconLightApi{ + url: url, + client: &http.Client{ + Timeout: time.Second * 10, + }, + customHeaders: customHeaders, + } +} + +func (api *BeaconLightApi) httpGet(path string) ([]byte, error) { + req, err := http.NewRequest("GET", api.url+path, nil) + if err != nil { + return nil, err + } + for k, v := range api.customHeaders { + req.Header.Set(k, v) + } + resp, err := api.client.Do(req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + switch resp.StatusCode { + case 200: + return io.ReadAll(resp.Body) + case 404: + return nil, ErrNotFound + case 500: + return nil, ErrInternal + default: + return nil, fmt.Errorf("Unexpected error from API endpoint \"%s\": status code %d", path, resp.StatusCode) + } +} + +func (api *BeaconLightApi) httpGetf(format string, params ...any) ([]byte, error) { + return api.httpGet(fmt.Sprintf(format, params...)) +} + +// GetBestUpdateAndCommittee fetches and validates LightClientUpdate for given +// period and full serialized committee for the next period (committee root hash +// equals update.NextSyncCommitteeRoot). +// Note that the results are validated but the update signature should be verified +// by the caller as its validity depends on the update chain. +//TODO handle valid partial results +func (api *BeaconLightApi) GetBestUpdatesAndCommittees(firstPeriod, count uint64) ([]*types.LightClientUpdate, []*types.SerializedCommittee, error) { + resp, err := api.httpGetf("/eth/v1/beacon/light_client/updates?start_period=%d&count=%d", firstPeriod, count) + if err != nil { + return nil, nil, err + } + + var data []types.CommitteeUpdate + if err := json.Unmarshal(resp, &data); err != nil { + return nil, nil, err + } + if len(data) != int(count) { + return nil, nil, errors.New("invalid number of committee updates") + } + updates := make([]*types.LightClientUpdate, int(count)) + committees := make([]*types.SerializedCommittee, int(count)) + for i, d := range data { + if d.Update.Header.SyncPeriod() != firstPeriod+uint64(i) { + return nil, nil, errors.New("wrong committee update header period") + } + if err := d.Update.Validate(); err != nil { + return nil, nil, err + } + if d.NextSyncCommittee.Root() != d.Update.NextSyncCommitteeRoot { + return nil, nil, errors.New("wrong sync committee root") + } + updates[i], committees[i] = d.Update, d.NextSyncCommittee + } + return updates, committees, nil +} + +// GetOptimisticHeadUpdate fetches a signed header based on the latest available +// optimistic update. Note that the signature should be verified by the caller +// as its validity depends on the update chain. +// +// See data structure definition here: +// https://github.com/ethereum/consensus-specs/blob/dev/specs/altair/light-client/sync-protocol.md#lightclientoptimisticupdate +func (api *BeaconLightApi) GetOptimisticHeadUpdate() (types.SignedHead, error) { + resp, err := api.httpGet("/eth/v1/beacon/light_client/optimistic_update") + if err != nil { + return types.SignedHead{}, err + } + return decodeOptimisticHeadUpdate(resp) +} + +func decodeOptimisticHeadUpdate(enc []byte) (types.SignedHead, error) { + var data struct { + Data struct { + Header types.JsonBeaconHeader `json:"attested_header"` + Aggregate types.SyncAggregate `json:"sync_aggregate"` + SignatureSlot common.Decimal `json:"signature_slot"` + } `json:"data"` + } + if err := json.Unmarshal(enc, &data); err != nil { + return types.SignedHead{}, err + } + if data.Data.Header.Beacon.StateRoot == (common.Hash{}) { + // workaround for different event encoding format in Lodestar + if err := json.Unmarshal(enc, &data.Data); err != nil { + return types.SignedHead{}, err + } + } + + if len(data.Data.Aggregate.BitMask) != params.SyncCommitteeBitmaskSize { + return types.SignedHead{}, errors.New("invalid sync_committee_bits length") + } + if len(data.Data.Aggregate.Signature) != params.BlsSignatureSize { + return types.SignedHead{}, errors.New("invalid sync_committee_signature length") + } + return types.SignedHead{ + Header: data.Data.Header.Beacon, + SyncAggregate: data.Data.Aggregate, + SignatureSlot: uint64(data.Data.SignatureSlot), + }, nil +} + +// GetHead fetches and validates the beacon header with the given blockRoot. +// If blockRoot is null hash then the latest head header is fetched. +func (api *BeaconLightApi) GetHeader(blockRoot common.Hash) (types.Header, error) { + var blockId string + if blockRoot == (common.Hash{}) { + blockId = "head" + } else { + blockId = blockRoot.Hex() + } + resp, err := api.httpGetf("/eth/v1/beacon/headers/%s", blockId) + if err != nil { + return types.Header{}, err + } + + var data struct { + Data struct { + Root common.Hash `json:"root"` + Canonical bool `json:"canonical"` + Header struct { + Message types.Header `json:"message"` + Signature hexutil.Bytes `json:"signature"` + } `json:"header"` + } `json:"data"` + } + if err := json.Unmarshal(resp, &data); err != nil { + return types.Header{}, err + } + header := data.Data.Header.Message + if blockRoot == (common.Hash{}) { + blockRoot = data.Data.Root + } + if header.Hash() != blockRoot { + return types.Header{}, errors.New("retrieved beacon header root does not match") + } + return header, nil +} + +// does not verify state root +func (api *BeaconLightApi) GetHeadStateProof(format merkle.ProofFormat) (merkle.MultiProof, error) { + encFormat, bitLength := EncodeCompactProofFormat(format) + return api.getStateProof("head", format, encFormat, bitLength) +} + +type StateProofSub struct { + api *BeaconLightApi + format merkle.ProofFormat + encFormat []byte + bitLength int +} + +func (api *BeaconLightApi) SubscribeStateProof(format merkle.ProofFormat, first, period int) (*StateProofSub, error) { + encFormat, bitLength := EncodeCompactProofFormat(format) + _, err := api.httpGetf("/eth/v0/beacon/proof/subscribe/states?format=0x%x&first=%d&period=%d", encFormat, first, period) + if err != nil && err != ErrNotFound { + // if subscribe endpoint is missing then we expect proof endpoint to serve recent states without subscription + return nil, err + } + return &StateProofSub{ + api: api, + format: format, + encFormat: encFormat, + bitLength: bitLength, + }, nil +} + +// verifies state root +func (sub *StateProofSub) Get(stateRoot common.Hash) (merkle.MultiProof, error) { + proof, err := sub.api.getStateProof(stateRoot.Hex(), sub.format, sub.encFormat, sub.bitLength) + if err != nil { + return merkle.MultiProof{}, err + } + if proof.RootHash() != stateRoot { + return merkle.MultiProof{}, errors.New("Received proof has incorrect state root") + } + return proof, nil +} + +func (api *BeaconLightApi) getStateProof(stateId string, format merkle.ProofFormat, encFormat []byte, bitLength int) (merkle.MultiProof, error) { + resp, err := api.httpGetf("/eth/v0/beacon/proof/state/%s?format=0x%x", stateId, encFormat) + if err != nil { + return merkle.MultiProof{}, err + } + valueCount := (bitLength + 1) / 2 + if len(resp) != valueCount*32 { + return merkle.MultiProof{}, errors.New("Invalid state proof length") + } + values := make(merkle.Values, valueCount) + for i := range values { + copy(values[i][:], resp[i*32:(i+1)*32]) + } + return merkle.MultiProof{Format: format, Values: values}, nil +} + +// EncodeCompactProofFormat encodes a merkle.ProofFormat into a binary compact +// proof format. See description here: +// https://github.com/ChainSafe/consensus-specs/blob/feat/multiproof/ssz/merkle-proofs.md#compact-multiproofs +func EncodeCompactProofFormat(format merkle.ProofFormat) ([]byte, int) { + target := make([]byte, 0, 64) + var bitLength int + encodeProofFormatSubtree(format, &target, &bitLength) + return target, bitLength +} + +// encodeProofFormatSubtree recursively encodes a subtree of a proof format into +// binary compact format. +func encodeProofFormatSubtree(format merkle.ProofFormat, target *[]byte, bitLength *int) { + bytePtr, bitMask := *bitLength>>3, byte(128)>>(*bitLength&7) + *bitLength++ + if bytePtr == len(*target) { + *target = append(*target, byte(0)) + } + if left, right := format.Children(); left == nil { + (*target)[bytePtr] += bitMask + } else { + encodeProofFormatSubtree(left, target, bitLength) + encodeProofFormatSubtree(right, target, bitLength) + } +} + +// GetCheckpointData fetches and validates bootstrap data belonging to the given checkpoint. +func (api *BeaconLightApi) GetCheckpointData(checkpointHash common.Hash) (*light.CheckpointData, error) { + resp, err := api.httpGetf("/eth/v1/beacon/light_client/bootstrap/0x%x", checkpointHash[:]) + if err != nil { + return nil, err + } + + // See data structure definition here: + // https://github.com/ethereum/consensus-specs/blob/dev/specs/altair/light-client/sync-protocol.md#lightclientbootstrap + type bootstrapData struct { + Data struct { + Header types.JsonBeaconHeader `json:"header"` + Committee *types.SerializedCommittee `json:"current_sync_committee"` + CommitteeBranch merkle.Values `json:"current_sync_committee_branch"` + } `json:"data"` + } + + var data bootstrapData + if err := json.Unmarshal(resp, &data); err != nil { + return nil, err + } + header := data.Data.Header.Beacon + if header.Hash() != checkpointHash { + return nil, errors.New("invalid checkpoint block header") + } + checkpoint := &light.CheckpointData{ + Header: header, + CommitteeBranch: data.Data.CommitteeBranch, + CommitteeRoot: data.Data.Committee.Root(), + Committee: data.Data.Committee, + } + if !checkpoint.Validate() { + return nil, errors.New("invalid sync committee Merkle proof") + } + return checkpoint, nil +} + +// GetExecutionPayload fetches the execution block belonging to the beacon block +// specified by beaconRoot and validates its block hash against the expected execRoot. +func (api *BeaconLightApi) GetExecutionPayload(header types.Header) (*ctypes.Block, error) { + resp, err := api.httpGetf("/eth/v2/beacon/blocks/0x%x", header.Hash()) + if err != nil { + return nil, err + } + + spec := configs.Mainnet + // note: eth2 api endpoints serve bellatrix.SignedBeaconBlock instead + // also try github.com/protolambda/eth2api for api bindings + //var beaconBlock bellatrix.BeaconBlock + var beaconBlock capella.BeaconBlock + myJSONBlockData := resp + var beaconBlockMessage struct { + Data struct { + Message capella.BeaconBlock `json:"message"` + } `json:"data"` + } + if err := json.Unmarshal(myJSONBlockData, &beaconBlockMessage); err != nil { + return nil, fmt.Errorf("invalid block json data: %v", err) + } + beaconBlock = beaconBlockMessage.Data.Message + beaconBodyRoot := common.Hash(beaconBlock.Body.HashTreeRoot(spec, tree.GetHashFn())) + if beaconBodyRoot != header.BodyRoot { + return nil, fmt.Errorf("Beacon body root hash mismatch (expected: %x, got: %x)", header.BodyRoot.Bytes(), beaconBodyRoot.Bytes()) + } + + payload := &beaconBlock.Body.ExecutionPayload + txs := make([]*ctypes.Transaction, len(payload.Transactions)) + for i, opaqueTx := range payload.Transactions { + var tx ctypes.Transaction + if err := tx.UnmarshalBinary(opaqueTx); err != nil { + return nil, fmt.Errorf("failed to parse tx %d: %v", i, err) + } + txs[i] = &tx + } + withdrawals := make([]*ctypes.Withdrawal, len(payload.Withdrawals)) + for i, w := range payload.Withdrawals { + withdrawals[i] = &ctypes.Withdrawal{ + Index: uint64(w.Index), + Validator: uint64(w.ValidatorIndex), + Address: common.Address(w.Address), + Amount: uint64(w.Amount), + } + } + wroot := ctypes.DeriveSha(ctypes.Withdrawals(withdrawals), trie.NewStackTrie(nil)) + execHeader := &ctypes.Header{ + ParentHash: common.Hash(payload.ParentHash), + UncleHash: ctypes.EmptyUncleHash, + Coinbase: common.Address(payload.FeeRecipient), + Root: common.Hash(payload.StateRoot), + TxHash: ctypes.DeriveSha(ctypes.Transactions(txs), trie.NewStackTrie(nil)), + ReceiptHash: common.Hash(payload.ReceiptsRoot), + Bloom: ctypes.Bloom(payload.LogsBloom), + Difficulty: big.NewInt(0), // constant + Number: new(big.Int).SetUint64(uint64(payload.BlockNumber)), + GasLimit: uint64(payload.GasLimit), + GasUsed: uint64(payload.GasUsed), + Time: uint64(payload.Timestamp), + Extra: []byte(payload.ExtraData), + MixDigest: common.Hash(payload.PrevRandao), // reused in merge + Nonce: ctypes.BlockNonce{}, // zero + BaseFee: (*uint256.Int)(&payload.BaseFeePerGas).ToBig(), + WithdrawalsHash: &wroot, + } + execBlock := ctypes.NewBlockWithHeader(execHeader).WithBody(txs, nil).WithWithdrawals(withdrawals) + if execBlock.Hash() != common.Hash(payload.BlockHash) { + return nil, fmt.Errorf("Sanity check failed, payload hash does not match.") + } + return execBlock, nil +} + +func decodeHeadEvent(enc []byte) (uint64, common.Hash, error) { + var data struct { + Slot common.Decimal `json:"slot"` + Block common.Hash `json:"block"` + } + if err := json.Unmarshal(enc, &data); err != nil { + return 0, common.Hash{}, err + } + return uint64(data.Slot), data.Block, nil +} + +// StartHeadListener creates an event subscription for heads and signed (optimistic) +// head updates and calls the specified callback functions when they are received. +// The callbacks are also called for the current head and optimistic head at startup. +// They are never called concurrently. +func (api *BeaconLightApi) StartHeadListener(headFn func(slot uint64, blockRoot common.Hash), signedFn func(head types.SignedHead), errFn func(err error)) func() { + closeCh := make(chan struct{}) // initiate closing the stream + closedCh := make(chan struct{}) // stream closed (or failed to create) + stoppedCh := make(chan struct{}) // sync loop stopped + streamCh := make(chan *eventsource.Stream, 1) + go func() { + defer close(closedCh) + // when connected to a Lodestar node the subscription blocks until the + // first actual event arrives; therefore we create the subscription in + // a separate goroutine while letting the main goroutine sync up to the + // current head + stream, err := eventsource.Subscribe(api.url+"/eth/v1/events?topics=head&topics=light_client_optimistic_update", "") + if err != nil { + errFn(fmt.Errorf("Error creating event subscription: %v", err)) + close(streamCh) + return + } + streamCh <- stream + <-closeCh + stream.Close() + }() + go func() { + defer close(stoppedCh) + + if head, err := api.GetHeader(common.Hash{}); err == nil { + headFn(head.Slot, head.Hash()) + } + if signedHead, err := api.GetOptimisticHeadUpdate(); err == nil { + signedFn(signedHead) + } + stream := <-streamCh + if stream == nil { + return + } + for { + select { + case event, ok := <-stream.Events: + if !ok { + break + } + switch event.Event() { + case "head": + if slot, blockRoot, err := decodeHeadEvent([]byte(event.Data())); err == nil { + headFn(slot, blockRoot) + } else { + errFn(fmt.Errorf("Error decoding head event: %v", err)) + } + case "light_client_optimistic_update": + if signedHead, err := decodeOptimisticHeadUpdate([]byte(event.Data())); err == nil { + signedFn(signedHead) + } else { + errFn(fmt.Errorf("Error decoding optimistic update event: %v", err)) + } + default: + errFn(fmt.Errorf("Unexpected event: %s", event.Event())) + } + case err, ok := <-stream.Errors: + if !ok { + break + } + errFn(err) + } + } + }() + return func() { + close(closeCh) + <-closedCh + <-stoppedCh + } +} diff --git a/beacon/light/api/sync_server.go b/beacon/light/api/sync_server.go new file mode 100644 index 0000000000..d107ab2ae4 --- /dev/null +++ b/beacon/light/api/sync_server.go @@ -0,0 +1,156 @@ +// 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 api + +import ( + "sync" + "time" + + "github.com/ethereum/go-ethereum/beacon/light" + "github.com/ethereum/go-ethereum/beacon/light/types" + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/log" +) + +const ( + maxHeadLength = 4 +) + +type SyncServer struct { + api *BeaconLightApi + Stop func() + lock sync.RWMutex + + triggerCallback func() + latestHeadSlot uint64 + latestHeadHash common.Hash + signedHeads []types.SignedHead + canRequestBootstrap bool + firstUpdate uint64 //TODO ... +} + +func NewSyncServer(api *BeaconLightApi) *SyncServer { + s := &SyncServer{ + api: api, + canRequestBootstrap: true, + } + s.Stop = s.api.StartHeadListener(s.newHead, s.newSignedHead, func(err error) { + log.Warn("Head event stream error", "err", err) + }) + return s +} + +func (s *SyncServer) SetTriggerCallback(cb func()) { + s.lock.Lock() + defer s.lock.Unlock() + + s.triggerCallback = cb +} + +func (s *SyncServer) Delay() time.Duration { return 0 } //TODO + +func (s *SyncServer) Fail(desc string) { + log.Warn("API endpoint failure", "URL", s.api.url, "error", desc) +} + +func (s *SyncServer) LatestHead() (uint64, common.Hash) { + s.lock.RLock() + defer s.lock.RUnlock() + + return s.latestHeadSlot, s.latestHeadHash +} + +func (s *SyncServer) SignedHeads() []types.SignedHead { + s.lock.RLock() + defer s.lock.RUnlock() + + return s.signedHeads +} + +func (s *SyncServer) CanRequestBootstrap() bool { + s.lock.RLock() + defer s.lock.RUnlock() + + return s.canRequestBootstrap +} + +func (s *SyncServer) RequestBootstrap(checkpointHash common.Hash, response func(*light.CheckpointData)) { + go func() { + if checkpoint, err := s.api.GetCheckpointData(checkpointHash); err == nil { + response(checkpoint) + } else { + s.lock.Lock() + s.canRequestBootstrap = false + s.lock.Unlock() + response(nil) + } + }() +} + +func (s *SyncServer) UpdateRange() types.PeriodRange { + s.lock.RLock() + defer s.lock.RUnlock() + + if len(s.signedHeads) == 0 { + return types.PeriodRange{} + } + r := types.PeriodRange{First: s.firstUpdate, AfterLast: types.PeriodOfSlot(s.signedHeads[len(s.signedHeads)-1].Header.Slot + 256)} + if !r.IsEmpty() { + return r + } + return types.PeriodRange{} +} + +func (s *SyncServer) RequestUpdates(first, count uint64, response func([]*types.LightClientUpdate, []*types.SerializedCommittee)) { + go func() { + if updates, committees, err := s.api.GetBestUpdatesAndCommittees(first, count); err == nil { + response(updates, committees) + } else { + response(nil, nil) + } + }() +} + +func (s *SyncServer) newHead(slot uint64, blockRoot common.Hash) { + s.lock.Lock() + defer s.lock.Unlock() + + s.latestHeadSlot, s.latestHeadHash = slot, blockRoot +} + +func (s *SyncServer) newSignedHead(signedHead types.SignedHead) { + s.lock.Lock() + defer s.lock.Unlock() + + if s.signedHeads == nil { + s.signedHeads = []types.SignedHead{signedHead} + s.triggerCallback() + return + } + if lastHead := s.signedHeads[len(s.signedHeads)-1]; signedHead.Header.Slot < lastHead.Header.Slot || + (signedHead.Header.Slot == lastHead.Header.Slot && signedHead.SignerCount() <= lastHead.SignerCount()) { + return + } + if len(s.signedHeads) < maxHeadLength { + s.signedHeads = append(s.signedHeads, signedHead) + s.triggerCallback() + return + } + copy(s.signedHeads[:len(s.signedHeads)-1], s.signedHeads[1:]) + s.signedHeads[len(s.signedHeads)-1] = signedHead + s.triggerCallback() +} diff --git a/beacon/light/checkpoint.go b/beacon/light/checkpoint.go new file mode 100644 index 0000000000..79946ae74e --- /dev/null +++ b/beacon/light/checkpoint.go @@ -0,0 +1,109 @@ +// 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 ( + "github.com/ethereum/go-ethereum/beacon/light/types" + "github.com/ethereum/go-ethereum/beacon/merkle" + "github.com/ethereum/go-ethereum/beacon/params" + "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.SerializedCommittee `rlp:"-"` + CommitteeBranch merkle.Values +} + +func (c *CheckpointData) Validate() bool { + if c.CommitteeRoot != c.Committee.Root() { + return false + } + expStateRoot, ok := merkle.VerifySingleProof(c.CommitteeBranch, params.BsiSyncCommittee, merkle.Value(c.CommitteeRoot)) + return ok && expStateRoot == c.Header.StateRoot +} + +// 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..54ecece342 --- /dev/null +++ b/beacon/light/committee_chain.go @@ -0,0 +1,548 @@ +// 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/light/types" + "github.com/ethereum/go-ethereum/beacon/params" + "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 ( + genesisDataKey = []byte("genesis") // RLP(GenesisData) + 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 +) + +// ChainConfig contains built-in chain configuration presets for certain networks +type ChainConfig struct { + GenesisData + Forks types.Forks + Checkpoint common.Hash +} + +// GenesisData is required for signature verification and is set by the +// CommitteeChain.Init function. +type GenesisData struct { + GenesisTime uint64 // unix time (in seconds) of slot 0 + GenesisValidatorsRoot common.Hash // root hash of the genesis validator set, used for signature domain calculation +} + +// 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.SerializedCommittee] + fixedRoots *canonicalStore[common.Hash] + syncCommitteeCache *lru.Cache[uint64, syncCommittee] // cache deserialized committees + unixNano func() int64 + + forks types.Forks + signerThreshold int + minimumUpdateScore types.UpdateScore + enforceTime bool + + genesisData GenesisData + genesisInit bool // genesis data initialized (signature check possible) +} + +// NewCommitteeChain creates a new CommitteeChain +func NewCommitteeChain(db ethdb.KeyValueStore, forks types.Forks, 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.SerializedCommittee](db, syncCommitteeKey, func(committee *types.SerializedCommittee) ([]byte, error) { + return committee[:], nil + }, func(enc []byte) (*types.SerializedCommittee, error) { + if len(enc) == types.SerializedCommitteeSize { + committee := new(types.SerializedCommittee) + 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, + forks: forks, + signerThreshold: signerThreshold, + enforceTime: enforceTime, + minimumUpdateScore: types.UpdateScore{ + SignerCount: uint32(signerThreshold), + SubPeriodIndex: params.SyncPeriodLength / 16, + }, + } + if enc, err := s.db.Get(genesisDataKey); err == nil { + var genesisData GenesisData + if err := rlp.DecodeBytes(enc, &genesisData); err == nil { + s.setGenesisData(genesisData) + log.Trace("Beacon chain genesis data loaded") + } else { + log.Error("Error decoding genesis data", "error", err) + } + } + + // check validity constraints + if !s.updates.periodRange.IsEmpty() { + if !s.genesisInit { + log.Crit("Inconsistent database error: updates present but genesis data is not initialized") + } + if s.fixedRoots.periodRange.IsEmpty() || s.updates.periodRange.First < s.fixedRoots.periodRange.First || + s.updates.periodRange.First >= s.fixedRoots.periodRange.AfterLast { + log.Crit("Inconsistent database error: first update is not in the fixed roots range") + } + if s.committees.periodRange.First > s.updates.periodRange.First || s.committees.periodRange.AfterLast <= s.updates.periodRange.AfterLast { + log.Crit("Inconsistent database error: missing committees in update range") + } + } + if !s.committees.periodRange.IsEmpty() { + if s.fixedRoots.periodRange.IsEmpty() || s.committees.periodRange.First < s.fixedRoots.periodRange.First || + s.committees.periodRange.First >= s.fixedRoots.periodRange.AfterLast { + log.Crit("Inconsistent database error: first committee is not in the fixed roots range") + } + if s.committees.periodRange.AfterLast > s.fixedRoots.periodRange.AfterLast && s.committees.periodRange.AfterLast >= s.updates.periodRange.AfterLast { + 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.periodRange.First, "last period", s.committees.periodRange.AfterLast-1) + } + 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) + } +} + +// InitConfig initializes the tracker with the given GenesisData and starts the update +// syncing process. +// Note that Init may be called either at startup or later if it has to be +// fetched from the network based on a checkpoint hash. +func (s *CommitteeChain) SetGenesisData(genesisData GenesisData) { + s.lock.Lock() + defer s.lock.Unlock() + + if s.genesisInit { + if s.genesisData != genesisData { + log.Crit("Beacon chain genesis data already initialized with different values") + } + return + } + s.setGenesisData(genesisData) + if enc, err := rlp.EncodeToBytes(&genesisData); err == nil { + if err := s.db.Put(genesisDataKey, enc); err != nil { + log.Error("Error storing genesis data", "error", err) + } + } else { + log.Error("Error encoding genesis data", "error", err) + } + log.Trace("Beacon chain genesis data stored and initialized") +} + +// (lock required) +func (s *CommitteeChain) setGenesisData(genesisData GenesisData) { + s.genesisData = genesisData + s.forks.ComputeDomains(genesisData.GenesisValidatorsRoot) + s.genesisInit = true +} + +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.periodRange.CanExpand(period) { + if root != oldRoot { + return ErrInvalidPeriod + } + for p := s.fixedRoots.periodRange.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.periodRange.AfterLast { + return nil + } + batch := s.db.NewBatch() + s.fixedRoots.deleteFrom(batch, period) + if s.updates.periodRange.IsEmpty() || period <= s.updates.periodRange.First { + s.updates.deleteFrom(batch, period) + s.deleteCommitteesFrom(batch, period) + } else { + fromPeriod := s.updates.periodRange.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) AddCommittee(period uint64, committee *types.SerializedCommittee) error { + s.lock.Lock() + defer s.lock.Unlock() + + if !s.committees.periodRange.CanExpand(period) { + return ErrInvalidPeriod + } + root := s.getCommitteeRoot(period) + if root == (common.Hash{}) { + return ErrInvalidPeriod + } + if root != committee.Root() { + return ErrWrongCommitteeRoot + } + if !s.committees.periodRange.Includes(period) { + s.committees.add(nil, period, committee) + s.syncCommitteeCache.Remove(period) + } + return nil +} + +func (s *CommitteeChain) InsertUpdate(update *types.LightClientUpdate, nextCommittee *types.SerializedCommittee) error { + s.lock.Lock() + defer s.lock.Unlock() + + if !s.genesisInit { + return ErrNotInitialized + } + period := update.Header.SyncPeriod() + if !s.updates.periodRange.CanExpand(period) || !s.committees.periodRange.Includes(period) { + return ErrInvalidPeriod + } + 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.periodRange.Includes(period+1) && reorg { + return ErrCannotReorg + } + if !s.verifyUpdate(update) { + return ErrInvalidUpdate + } + addCommittee := !s.committees.periodRange.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.genesisInit || s.committees.periodRange.IsEmpty() { + return 0, false + } + if !s.updates.periodRange.IsEmpty() { + return s.updates.periodRange.AfterLast, true + } + return s.committees.periodRange.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 +} + +// VerifySignedHead 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) VerifySignedHead(head types.SignedHead) (bool, time.Duration) { + s.lock.RLock() + defer s.lock.RUnlock() + + return s.verifySignedHead(head) +} + +// (rlock required) +func (s *CommitteeChain) verifySignedHead(head types.SignedHead) (bool, time.Duration) { + var ( + slotTime = int64(time.Second) * int64(s.genesisData.GenesisTime+head.Header.Slot*12) + age = time.Duration(s.unixNano() - slotTime) + ) + if s.enforceTime && age < 0 { + return false, age + } + committee := s.getSyncCommittee(types.PeriodOfSlot(head.SignatureSlot)) + if committee == nil { + return false, age + } + return s.sigVerifier.verifySignature(committee, s.forks.SigningRoot(head.Header), &head.SyncAggregate), 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.verifySignedHead(types.SignedHead{Header: update.Header, SyncAggregate: update.SyncAggregate, SignatureSlot: update.Header.Slot}) + if age < 0 { + log.Warn("Future committee update received", "age", age) + } + return ok +} + +type canonicalStore[T any] struct { + db ethdb.KeyValueStore + keyPrefix []byte + cache *lru.Cache[uint64, T] + periodRange types.PeriodRange //TODO make it a parent struct? + 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.periodRange.First == 0 { + cs.periodRange.First = period + } else if cs.periodRange.AfterLast != period { + if iter.Next() { + log.Error("Gap in the canonical chain database") + } + break // continuity guaranteed + } + cs.periodRange.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.periodRange.CanExpand(period) { + log.Error("Cannot expand canonical store", "range.first", cs.periodRange.First, "range.afterLast", cs.periodRange.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.periodRange.Expand(period) +} + +// should only be used in batch mode +func (cs *canonicalStore[T]) deleteFrom(batch ethdb.Batch, fromPeriod uint64) (deleted types.PeriodRange) { + if fromPeriod >= cs.periodRange.AfterLast { + return + } + if fromPeriod < cs.periodRange.First { + fromPeriod = cs.periodRange.First + } + deleted = types.PeriodRange{First: fromPeriod, AfterLast: cs.periodRange.AfterLast} + for period := fromPeriod; period < cs.periodRange.AfterLast; period++ { + batch.Delete(cs.getDbKey(period)) + } + if fromPeriod > cs.periodRange.First { + cs.periodRange.AfterLast = fromPeriod + } else { + cs.periodRange = types.PeriodRange{} + } + return +} + +func (cs *canonicalStore[T]) get(period uint64) T { + 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/head_tracker.go b/beacon/light/head_tracker.go new file mode 100644 index 0000000000..74392af583 --- /dev/null +++ b/beacon/light/head_tracker.go @@ -0,0 +1,95 @@ +// 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" + "sync" + "time" + + "github.com/ethereum/go-ethereum/beacon/light/types" + "github.com/ethereum/go-ethereum/log" +) + +type HeadTracker struct { + lock sync.Mutex + committeeChain *CommitteeChain + subs []*headSub +} + +func NewHeadTracker(committeeChain *CommitteeChain) *HeadTracker { + return &HeadTracker{committeeChain: committeeChain} +} + +type headSub struct { + minSignerCount int + nextSlot uint64 + callbacks []func(types.SignedHead) +} + +func (h *HeadTracker) Subscribe(minSignerCount int, callback func(types.SignedHead)) { + h.lock.Lock() + defer h.lock.Unlock() + + insertAt := len(h.subs) + for i, sub := range h.subs { + if sub.minSignerCount == minSignerCount { + sub.callbacks = append(sub.callbacks, callback) + return + } + if sub.minSignerCount > minSignerCount { + insertAt = i + break + } + } + h.subs = append(h.subs, nil) + copy(h.subs[insertAt+1:], h.subs[insertAt:len(h.subs)-1]) + h.subs[insertAt] = &headSub{ + minSignerCount: minSignerCount, + callbacks: []func(types.SignedHead){callback}, + } +} + +func (h *HeadTracker) Add(head types.SignedHead) error { + h.lock.Lock() + defer h.lock.Unlock() + + sigOk, age := h.committeeChain.VerifySignedHead(head) + if age < 0 { + log.Warn("Future signed head received", "age", age) + } + if age > time.Minute*2 { + log.Warn("Old signed head received", "age", age) + } + if !sigOk { + return errors.New("invalid header signature") + } + + signerCount := head.SignerCount() + for _, sub := range h.subs { + if sub.minSignerCount > signerCount { + break + } + if head.Header.Slot >= sub.nextSlot { + for _, cb := range sub.callbacks { + cb(head) + } + sub.nextSlot = head.Header.Slot + 1 + } + } + return nil +} diff --git a/beacon/light/request/lock.go b/beacon/light/request/lock.go new file mode 100644 index 0000000000..758522176c --- /dev/null +++ b/beacon/light/request/lock.go @@ -0,0 +1,47 @@ +// 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 request + +type SingleLock struct { + requestLock map[*Server]uint64 // servers where the request has been sent and not timed out yet +} + +func (s *SingleLock) CanSend(server *Server) bool { + reqId, ok := s.requestLock[server] + if ok && server.Timeout(reqId) { + delete(s.requestLock, server) + return false + } + return !ok && server.CanSend() +} + +// assumes that canSend returned true (no request lock) +func (s *SingleLock) TrySend(srv *Server) (uint64, bool) { + if s.requestLock == nil { + s.requestLock = make(map[*Server]uint64) + } + if reqId, ok := srv.TrySend(); ok { + s.requestLock[srv] = reqId + return reqId, true + } + return 0, false +} + +func (s *SingleLock) Returned(srv *Server, reqId uint64) { + delete(s.requestLock, srv) + srv.Returned(reqId) +} diff --git a/beacon/light/request/scheduler.go b/beacon/light/request/scheduler.go new file mode 100644 index 0000000000..96400df2e9 --- /dev/null +++ b/beacon/light/request/scheduler.go @@ -0,0 +1,237 @@ +// 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 request + +import ( + "sync" + "time" +) + +const softRequestTimeout = time.Second + +type Module interface { + Process(servers []*Server) bool // removed if return value is false +} + +type RequestServer interface { + SetTriggerCallback(func()) + Delay() time.Duration + Fail(string) +} + +// initialized when first trigger is added +type ModuleTrigger struct { // Scheduler lock + s *Scheduler + triggers map[Module]struct{} +} + +func (t *ModuleTrigger) Trigger() { + if t.triggers == nil { + return + } + t.s.triggerLock.Lock() + defer t.s.triggerLock.Unlock() + + for m := range t.triggers { + t.s.moduleTrigger(m) + } +} + +type Scheduler struct { + lock sync.Mutex + modules []Module // first has highest priority + servers []*Server + triggeredBy map[Module][]*ModuleTrigger + stopCh chan chan struct{} + + triggerCh chan struct{} + triggerLock sync.Mutex + processing, triggered bool + trModules map[Module]struct{} + trServers map[*Server]struct{} +} + +func NewScheduler() *Scheduler { + return &Scheduler{ + stopCh: make(chan chan struct{}), + triggerCh: make(chan struct{}, 1), + triggeredBy: make(map[Module][]*ModuleTrigger), + } +} + +// call before starting the scheduler +func (s *Scheduler) RegisterModule(m Module) { + s.lock.Lock() + defer s.lock.Unlock() + + s.modules = append(s.modules, m) +} + +func (s *Scheduler) AddTriggers(m Module, triggeredBy []*ModuleTrigger) { + s.triggeredBy[m] = append(s.triggeredBy[m], triggeredBy...) + for _, t := range triggeredBy { + if t.triggers == nil { + t.s = s + t.triggers = make(map[Module]struct{}) + } + t.triggers[m] = struct{}{} + } +} + +func (s *Scheduler) unregisterModule(m Module, t []*ModuleTrigger) { + for i, module := range s.modules { + if module == m { + copy(s.modules[i:len(s.modules)-1], s.modules[i+1:]) + s.modules = s.modules[:len(s.modules)-1] + break + } + } + triggeredBy := s.triggeredBy[m] + delete(s.triggeredBy, m) + for _, t := range triggeredBy { + delete(t.triggers, m) + } +} + +func (s *Scheduler) RegisterServer(RequestServer RequestServer) { + s.lock.Lock() + defer s.lock.Unlock() + + server := s.newServer(RequestServer) + s.servers = append(s.servers, server) + RequestServer.SetTriggerCallback(func() { + s.ServerTrigger(server) + }) + s.ServerTrigger(server) +} + +func (s *Scheduler) UnregisterServer(RequestServer RequestServer) { + s.lock.Lock() + defer s.lock.Unlock() + + for i, server := range s.servers { + if server.RequestServer == RequestServer { + s.servers[i] = s.servers[len(s.servers)-1] + s.servers = s.servers[:len(s.servers)-1] + server.stop() + return + } + } +} + +// call before registering servers +func (s *Scheduler) Start() { + go s.syncLoop() +} + +func (s *Scheduler) Stop() { + s.lock.Lock() + for _, server := range s.servers { + server.stop() + } + s.servers = nil + s.lock.Unlock() + stop := make(chan struct{}) + s.stopCh <- stop + <-stop +} + +func (s *Scheduler) syncLoop() { + s.lock.Lock() + s.triggerLock.Lock() + s.processing = true + for { + trModules, trServers := s.trModules, s.trServers + s.trModules, s.trServers = nil, nil + if trModules != nil || trServers != nil { + s.triggerLock.Unlock() + s.processModules(trModules, trServers) + s.triggerLock.Lock() + } else { + s.processing = false + s.triggerLock.Unlock() + s.lock.Unlock() + select { + case stop := <-s.stopCh: + close(stop) + return + case <-s.triggerCh: + } + s.lock.Lock() + s.triggerLock.Lock() + s.triggered = false + s.processing = true + } + } +} + +func (s *Scheduler) processModules(trModules map[Module]struct{}, trServers map[*Server]struct{}) { + trs := make([]*Server, 0, len(s.servers)) + for _, server := range s.servers { + if _, ok := trServers[server]; ok { + trs = append(trs, server) + } + } + var i int + for _, module := range s.modules { + keep := true + if _, ok := trModules[module]; ok { + keep = module.Process(s.servers) + } else if len(trs) > 0 { + keep = module.Process(trs) + } + if keep { + s.modules[i] = module + i++ + } + } + s.modules = s.modules[:i] +} + +func (s *Scheduler) ServerTrigger(server *Server) { + s.triggerLock.Lock() + s.serverTrigger(server) + s.triggerLock.Unlock() +} + +func (s *Scheduler) serverTrigger(server *Server) { + if s.trServers == nil { + s.trServers = make(map[*Server]struct{}) + } + s.trServers[server] = struct{}{} + if !s.processing && !s.triggered { + s.triggerCh <- struct{}{} + s.triggered = true + } +} + +func (s *Scheduler) ModuleTrigger(module Module) { + s.triggerLock.Lock() + s.moduleTrigger(module) + s.triggerLock.Unlock() +} + +func (s *Scheduler) moduleTrigger(module Module) { + if s.trModules == nil { + s.trModules = make(map[Module]struct{}) + } + s.trModules[module] = struct{}{} + if !s.processing && !s.triggered { + s.triggerCh <- struct{}{} + s.triggered = true + } +} diff --git a/beacon/light/request/server.go b/beacon/light/request/server.go new file mode 100644 index 0000000000..326df2eed2 --- /dev/null +++ b/beacon/light/request/server.go @@ -0,0 +1,166 @@ +// 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 request + +import ( + "math/rand" + "sync" + "time" +) + +func SelectServer(servers []*Server, priority func(server *Server) uint64) *Server { + var ( + maxPriority uint64 + mpCount int + bestServer *Server + ) + for _, server := range servers { + pri := priority(server) + if pri == 0 || pri < maxPriority { // 0 means it cannot serve the request at all + continue + } + if pri > maxPriority { + maxPriority = pri + mpCount = 1 + bestServer = server + } else { + mpCount++ + if rand.Intn(mpCount) == 0 { + bestServer = server + } + } + } + return bestServer +} + +type Server struct { //TODO name? + RequestServer + scheduler *Scheduler + lock sync.Mutex + sent map[uint64]chan struct{} // closed when returned; nil when timed out + timeoutCount int + delayed bool + delayChecked bool + needTrigger bool + lastReqId uint64 + stopCh chan struct{} +} + +func (s *Scheduler) newServer(server RequestServer) *Server { + return &Server{ + RequestServer: server, + scheduler: s, + sent: make(map[uint64]chan struct{}), + stopCh: make(chan struct{}), + } +} + +// guarantees a server trigger later if the result is false +func (s *Server) CanSend() bool { + s.lock.Lock() + defer s.lock.Unlock() + + if s.isDelayed() || s.timeoutCount != 0 { + s.needTrigger = true + return false + } + return true +} + +// guarantees a server trigger later if the result is false +func (s *Server) TrySend() (uint64, bool) { + s.lock.Lock() + defer s.lock.Unlock() + + if s.isDelayed() || s.timeoutCount != 0 { + s.needTrigger = true + return 0, false + } + s.lastReqId++ + returnCh := make(chan struct{}) + s.sent[s.lastReqId] = returnCh + s.delayChecked = false + go func() { + timer := time.NewTimer(softRequestTimeout) + select { + case <-timer.C: + s.lock.Lock() + if _, ok := s.sent[s.lastReqId]; ok { + s.sent[s.lastReqId] = nil + s.timeoutCount++ + } + s.lock.Unlock() + case <-returnCh: + timer.Stop() + case <-s.stopCh: + timer.Stop() + } + }() + return s.lastReqId, true +} + +func (s *Server) Timeout(reqId uint64) bool { + s.lock.Lock() + defer s.lock.Unlock() + + ch, ok := s.sent[reqId] + return ok && ch == nil +} + +func (s *Server) Returned(reqId uint64) { + s.lock.Lock() + defer s.lock.Unlock() + + if ch, ok := s.sent[reqId]; ok { + if ch != nil { + close(ch) + } else { + s.timeoutCount-- + } + delete(s.sent, reqId) + } +} + +func (s *Server) stop() { + close(s.stopCh) +} + +func (s *Server) isDelayed() bool { + if s.delayChecked { + return s.delayed + } + s.delayChecked = true + delay := s.RequestServer.Delay() + if s.delayed = delay > 0; s.delayed { + go func() { + timer := time.NewTimer(delay) + select { + case <-timer.C: + s.lock.Lock() + s.delayed = false + trigger := s.needTrigger && s.timeoutCount == 0 + s.lock.Unlock() + if trigger { + s.scheduler.serverTrigger(s) + } + case <-s.stopCh: + timer.Stop() + } + }() + } + return s.delayed +} diff --git a/beacon/light/signature.go b/beacon/light/signature.go new file mode 100644 index 0000000000..27f8af3c75 --- /dev/null +++ b/beacon/light/signature.go @@ -0,0 +1,85 @@ +// 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" + + "github.com/ethereum/go-ethereum/beacon/light/types" + "github.com/ethereum/go-ethereum/beacon/params" + "github.com/ethereum/go-ethereum/common" +) + +// 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.SerializedCommittee) (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.SerializedCommittee) (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.SerializedCommittee) (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.BitMask) +} + +func randomDummySyncCommittee() dummySyncCommittee { + var sc dummySyncCommittee + rand.Read(sc[:]) + return sc +} + +func serializeDummySyncCommittee(sc dummySyncCommittee) *types.SerializedCommittee { + s := new(types.SerializedCommittee) + copy(s[:32], sc[:]) + return s +} + +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 +} diff --git a/beacon/light/sync/head_sync.go b/beacon/light/sync/head_sync.go new file mode 100644 index 0000000000..2fd901130c --- /dev/null +++ b/beacon/light/sync/head_sync.go @@ -0,0 +1,105 @@ +// 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 sync + +import ( + "sync" + + "github.com/ethereum/go-ethereum/beacon/light" + "github.com/ethereum/go-ethereum/beacon/light/request" + "github.com/ethereum/go-ethereum/beacon/light/types" +) + +type signedHeadServer interface { + request.RequestServer + SignedHeads() []types.SignedHead +} + +type latestHeads struct { + heads map[uint64]types.SignedHead + oldestSlot uint64 +} + +type HeadSyncer struct { + lock sync.Mutex + headTracker *light.HeadTracker + chain *light.CommitteeChain + added, queued latestHeads + + SignedHeadTrigger request.ModuleTrigger +} + +func NewHeadSyncer(headTracker *light.HeadTracker, chain *light.CommitteeChain) *HeadSyncer { + return &HeadSyncer{ + headTracker: headTracker, + chain: chain, + } +} + +func (s *HeadSyncer) Process(servers []*request.Server) bool { + s.lock.Lock() + defer s.lock.Unlock() + + nextPeriod, ok := s.chain.NextSyncPeriod() + if !ok { + return true + } + for slot, head := range s.queued.heads { + if head.Header.SyncPeriod() <= nextPeriod { + delete(s.queued.heads, slot) + if s.added.add(head) && s.headTracker.Add(head) == nil { + s.SignedHeadTrigger.Trigger() + } + } + } + for _, server := range servers { + if hserver, ok := server.RequestServer.(signedHeadServer); ok { + heads := hserver.SignedHeads() + for _, head := range heads { + if head.Header.SyncPeriod() > nextPeriod { + s.queued.add(head) + } else if s.added.add(head) { + if s.headTracker.Add(head) == nil { + s.SignedHeadTrigger.Trigger() + } else { + hserver.Fail("received invalid signed head") + break + } + } + } + } + } + return true +} + +func (l *latestHeads) add(head types.SignedHead) bool { + if l.heads == nil { + l.heads = make(map[uint64]types.SignedHead) + l.oldestSlot = head.Header.Slot + } + if oldHead, ok := l.heads[head.Header.Slot]; ok { + if head.SignerCount() <= oldHead.SignerCount() { + return false + } + } + l.heads[head.Header.Slot] = head + for len(l.heads) > 4 { + delete(l.heads, l.oldestSlot) + l.oldestSlot++ + } + return true +} diff --git a/beacon/light/sync/update_sync.go b/beacon/light/sync/update_sync.go new file mode 100644 index 0000000000..5d00c76829 --- /dev/null +++ b/beacon/light/sync/update_sync.go @@ -0,0 +1,181 @@ +// 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 sync + +import ( + "sync" + + "github.com/ethereum/go-ethereum/beacon/light" + "github.com/ethereum/go-ethereum/beacon/light/request" + "github.com/ethereum/go-ethereum/beacon/light/types" + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/log" +) + +const maxUpdateRequest = 8 + +type checkpointInitServer interface { + request.RequestServer + CanRequestBootstrap() bool + RequestBootstrap(checkpointHash common.Hash, response func(*light.CheckpointData)) +} + +type CheckpointInit struct { + request.SingleLock + lock sync.Mutex + chain *light.CommitteeChain + cs *light.CheckpointStore + checkpointHash common.Hash + initialized bool + + InitTrigger request.ModuleTrigger +} + +func NewCheckpointInit(chain *light.CommitteeChain, cs *light.CheckpointStore, checkpointHash common.Hash) *CheckpointInit { + return &CheckpointInit{ + chain: chain, + cs: cs, + checkpointHash: checkpointHash, + } +} + +func (s *CheckpointInit) Process(servers []*request.Server) bool { + s.lock.Lock() + defer s.lock.Unlock() + + if s.initialized { + return false + } + if checkpoint := s.cs.Get(s.checkpointHash); checkpoint != nil { + checkpoint.InitChain(s.chain) + s.initialized = true + s.InitTrigger.Trigger() + return false + } + srv := request.SelectServer(servers, func(server *request.Server) uint64 { + if cserver, ok := server.RequestServer.(checkpointInitServer); ok && cserver.CanRequestBootstrap() && s.CanSend(server) { + return 1 + } + return 0 + }) + if srv == nil { + return true + } + reqId, ok := s.TrySend(srv) + if !ok { + return true + } + server := srv.RequestServer.(checkpointInitServer) + server.RequestBootstrap(s.checkpointHash, func(checkpoint *light.CheckpointData) { + s.lock.Lock() + defer s.lock.Unlock() + + s.Returned(srv, reqId) + if checkpoint == nil || !checkpoint.Validate() { + server.Fail("error retrieving checkpoint data") + return + } + checkpoint.InitChain(s.chain) + s.cs.Store(checkpoint) + s.initialized = true + s.InitTrigger.Trigger() + }) + return true +} + +type forwardUpdateServer interface { + request.RequestServer + UpdateRange() types.PeriodRange + RequestUpdates(first, count uint64, response func([]*types.LightClientUpdate, []*types.SerializedCommittee)) +} + +type ForwardUpdateSyncer struct { + request.SingleLock + lock sync.Mutex + chain *light.CommitteeChain + + NewUpdateTrigger request.ModuleTrigger +} + +func NewForwardUpdateSyncer(chain *light.CommitteeChain) *ForwardUpdateSyncer { + return &ForwardUpdateSyncer{chain: chain} +} + +func (s *ForwardUpdateSyncer) Process(servers []*request.Server) bool { + s.lock.Lock() + defer s.lock.Unlock() + + first, ok := s.chain.NextSyncPeriod() + if !ok { + return true + } + srv := request.SelectServer(servers, func(server *request.Server) uint64 { + if fserver, ok := server.RequestServer.(forwardUpdateServer); ok && s.CanSend(server) { + updateRange := fserver.UpdateRange() + if first < updateRange.First { + return 0 + } + return updateRange.AfterLast + } + return 0 + }) + if srv == nil { + return true + } + server := srv.RequestServer.(forwardUpdateServer) + updateRange := server.UpdateRange() + if updateRange.AfterLast <= first { + return true + } + reqId, ok := s.TrySend(srv) + if !ok { + return true + } + count := updateRange.AfterLast - first + if count > maxUpdateRequest { //TODO const + count = maxUpdateRequest + } + server.RequestUpdates(first, count, func(updates []*types.LightClientUpdate, committees []*types.SerializedCommittee) { + s.lock.Lock() + defer s.lock.Unlock() + + s.Returned(srv, reqId) + if len(updates) != int(count) || len(committees) != int(count) { + server.Fail("wrong number of updates received") + return + } + for i, update := range updates { + if update.Header.SyncPeriod() != first+uint64(i) { + server.Fail("update with wrong sync period received") + return + } + if err := s.chain.InsertUpdate(update, committees[i]); err != nil { + if err == light.ErrInvalidUpdate || err == light.ErrWrongCommitteeRoot || err == light.ErrCannotReorg { + server.Fail("invalid update received") + } else { + log.Error("Unexpected InsertUpdate error", "error", err) + } + if i != 0 { + s.NewUpdateTrigger.Trigger() + } + return + } + } + s.NewUpdateTrigger.Trigger() + }) + return true +} diff --git a/beacon/light/types/committee.go b/beacon/light/types/committee.go new file mode 100644 index 0000000000..08beb61d7f --- /dev/null +++ b/beacon/light/types/committee.go @@ -0,0 +1,204 @@ +// 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 types + +import ( + "encoding/json" + "errors" + "math/bits" + + "github.com/ethereum/go-ethereum/beacon/params" + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/common/hexutil" + "github.com/minio/sha256-simd" + bls "github.com/protolambda/bls12-381-util" +) + +const SerializedCommitteeSize = (params.SyncCommitteeSize + 1) * params.BlsPubkeySize + +type SerializedCommittee [SerializedCommitteeSize]byte + +// jsonSyncCommittee is the JSON representation of a sync committee +// +// See data structure definition here: +// https://github.com/ethereum/consensus-specs/blob/dev/specs/altair/beacon-chain.md#syncaggregate +type jsonSyncCommittee struct { + Pubkeys []hexutil.Bytes `json:"pubkeys"` + Aggregate hexutil.Bytes `json:"aggregate_pubkey"` +} + +// MarshalJSON marshals as JSON. +func (s *SerializedCommittee) MarshalJSON() ([]byte, error) { + sc := jsonSyncCommittee{Pubkeys: make([]hexutil.Bytes, params.SyncCommitteeSize)} + for i := range sc.Pubkeys { + sc.Pubkeys[i] = make(hexutil.Bytes, params.BlsPubkeySize) + copy(sc.Pubkeys[i][:], s[i*params.BlsPubkeySize:(i+1)*params.BlsPubkeySize]) + } + sc.Aggregate = make(hexutil.Bytes, params.BlsPubkeySize) + copy(sc.Aggregate[:], s[params.SyncCommitteeSize*params.BlsPubkeySize:]) + return json.Marshal(&sc) +} + +// UnmarshalJSON unmarshals from JSON. +func (s *SerializedCommittee) UnmarshalJSON(input []byte) error { + var sc jsonSyncCommittee + if err := json.Unmarshal(input, &sc); err != nil { + return err + } + if len(sc.Pubkeys) != params.SyncCommitteeSize { + return errors.New("Invalid number of pubkeys") + } + for i, key := range sc.Pubkeys { + if len(key) != params.BlsPubkeySize { + return errors.New("Invalid pubkey size") + } + copy(s[i*params.BlsPubkeySize:(i+1)*params.BlsPubkeySize], key[:]) + } + if len(sc.Aggregate) != params.BlsPubkeySize { + return errors.New("Invalid pubkey size") + } + copy(s[params.SyncCommitteeSize*params.BlsPubkeySize:], sc.Aggregate[:]) + return nil +} + +// SerializedCommitteeRoot calculates the root hash of the binary tree representation +// of a sync committee provided in serialized format +func (s *SerializedCommittee) Root() common.Hash { + var ( + hasher = sha256.New() + padding [64 - params.BlsPubkeySize]byte + data [params.SyncCommitteeSize]common.Hash + l = params.SyncCommitteeSize + ) + for i := range data { + hasher.Reset() + hasher.Write(s[i*params.BlsPubkeySize : (i+1)*params.BlsPubkeySize]) + hasher.Write(padding[:]) + hasher.Sum(data[i][:0]) + } + for l > 1 { + for i := 0; i < l/2; i++ { + hasher.Reset() + hasher.Write(data[i*2][:]) + hasher.Write(data[i*2+1][:]) + hasher.Sum(data[i][:0]) + } + l /= 2 + } + hasher.Reset() + hasher.Write(s[SerializedCommitteeSize-params.BlsPubkeySize : SerializedCommitteeSize]) + hasher.Write(padding[:]) + hasher.Sum(data[1][:0]) + hasher.Reset() + hasher.Write(data[0][:]) + hasher.Write(data[1][:]) + hasher.Sum(data[0][:0]) + return data[0] +} + +func (s *SerializedCommittee) Deserialize() (*SyncCommittee, error) { + sc := new(SyncCommittee) + for i := 0; i <= params.SyncCommitteeSize; i++ { + pk := new(bls.Pubkey) + var sk [params.BlsPubkeySize]byte + copy(sk[:], s[i*params.BlsPubkeySize:(i+1)*params.BlsPubkeySize]) + if err := pk.Deserialize(&sk); err != nil { + return nil, err + } + if i < params.SyncCommitteeSize { + sc.keys[i] = pk + } else { + sc.aggregate = pk + } + } + return sc, nil +} + +// SyncCommittee is a set of sync committee signer pubkeys +// +// See data structure definition here: +// https://github.com/ethereum/consensus-specs/blob/dev/specs/altair/beacon-chain.md#syncaggregate +type SyncCommittee struct { + keys [params.SyncCommitteeSize]*bls.Pubkey + aggregate *bls.Pubkey +} + +func (sc *SyncCommittee) VerifySignature(signingRoot common.Hash, aggregate *SyncAggregate) bool { + var ( + sig bls.Signature + signerKeys [params.SyncCommitteeSize]*bls.Pubkey + signerCount int + ) + if err := sig.Deserialize(&aggregate.Signature); err != nil { + return false + } + for i, key := range sc.keys { + if aggregate.BitMask[i/8]&(byte(1)<<(i%8)) != 0 { + signerKeys[signerCount] = key + signerCount++ + } + } + return bls.FastAggregateVerify(signerKeys[:signerCount], signingRoot[:], &sig) +} + +// SyncAggregate represents an aggregated BLS signature with BitMask referring +// to a subset of the corresponding sync committee +// +// See data structure definition here: +// https://github.com/ethereum/consensus-specs/blob/dev/specs/altair/beacon-chain.md#syncaggregate +type SyncAggregate struct { + BitMask [params.SyncCommitteeBitmaskSize]byte + Signature [params.BlsSignatureSize]byte +} + +type jsonSyncAggregate struct { + BitMask hexutil.Bytes `json:"sync_committee_bits"` + Signature hexutil.Bytes `json:"sync_committee_signature"` +} + +// MarshalJSON marshals as JSON. +func (s *SyncAggregate) MarshalJSON() ([]byte, error) { + return json.Marshal(&jsonSyncAggregate{ + BitMask: hexutil.Bytes(s.BitMask[:]), + Signature: hexutil.Bytes(s.Signature[:]), + }) +} + +// UnmarshalJSON unmarshals from JSON. +func (s *SyncAggregate) UnmarshalJSON(input []byte) error { + var sc jsonSyncAggregate + if err := json.Unmarshal(input, &sc); err != nil { + return err + } + if len(sc.BitMask) != params.SyncCommitteeBitmaskSize { + return errors.New("Invalid aggregate bitmask size") + } + if len(sc.Signature) != params.BlsSignatureSize { + return errors.New("Invalid signature size") + } + copy(s.BitMask[:], []byte(sc.BitMask)) + copy(s.Signature[:], []byte(sc.Signature)) + return nil +} + +func (s *SyncAggregate) SignerCount() int { + var count int + for _, v := range s.BitMask { + count += bits.OnesCount8(v) + } + return count +} diff --git a/beacon/light/types/forks.go b/beacon/light/types/forks.go new file mode 100644 index 0000000000..9cd4963e40 --- /dev/null +++ b/beacon/light/types/forks.go @@ -0,0 +1,180 @@ +// 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 types + +import ( + "bufio" + "fmt" + "io" + "os" + "sort" + "strconv" + "strings" + + "github.com/ethereum/go-ethereum/beacon/merkle" + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/common/hexutil" + "github.com/ethereum/go-ethereum/log" + "github.com/minio/sha256-simd" +) + +// Fork describes a single beacon chain fork and also stores the calculated +// signature domain used after this fork. +type Fork struct { + Epoch uint64 // epoch when given fork version is activated + Name string // name of the fork in the chain config (config.yaml) file + // See fork version definition here: + // https://github.com/ethereum/consensus-specs/blob/dev/specs/phase0/beacon-chain.md#custom-types + Version []byte // fork version + domain merkle.Value // calculated by computeDomain, based on fork version and genesis validators root +} + +// Forks is the list of all beacon chain forks in the chain configuration. +type Forks []Fork + +// Fork returns the fork belonging to the given epoch +func (bf Forks) Fork(epoch uint64) (Fork, bool) { + for i := len(bf) - 1; i >= 0; i-- { + if epoch >= bf[i].Epoch { + return bf[i], true + } + } + return Fork{}, false +} + +// domain returns the signature domain for the given epoch (assumes that domains +// have already been calculated). +func (bf Forks) domain(epoch uint64) merkle.Value { + fork, ok := bf.Fork(epoch) + if !ok { + log.Error("Fork domain unknown", "epoch", epoch) + } + return fork.domain +} + +// computeDomain returns the signature domain based on the given fork version +// and genesis validator set root +func computeDomain(forkVersion []byte, genesisValidatorsRoot common.Hash) merkle.Value { + var ( + hasher = sha256.New() + forkVersion32 merkle.Value + forkDataRoot merkle.Value + domain merkle.Value + ) + copy(forkVersion32[:len(forkVersion)], forkVersion) + hasher.Write(forkVersion32[:]) + hasher.Write(genesisValidatorsRoot[:]) + hasher.Sum(forkDataRoot[:0]) + domain[0] = 7 + copy(domain[4:], forkDataRoot[:28]) + return domain +} + +// computeDomains calculates and stores signature domains for each fork in the list. +func (bf Forks) ComputeDomains(genesisValidatorsRoot common.Hash) { + for i := range bf { + bf[i].domain = computeDomain(bf[i].Version, genesisValidatorsRoot) + } +} + +// signingRoot calculates the signing root of the given header. +func (bf Forks) SigningRoot(header Header) common.Hash { + var ( + signingRoot common.Hash + headerHash = header.Hash() + hasher = sha256.New() + domain = bf.domain(header.Epoch()) + ) + hasher.Write(headerHash[:]) + hasher.Write(domain[:]) + hasher.Sum(signingRoot[:0]) + return signingRoot +} + +func (f Forks) Len() int { return len(f) } +func (f Forks) Swap(i, j int) { f[i], f[j] = f[j], f[i] } +func (f Forks) Less(i, j int) bool { return f[i].Epoch < f[j].Epoch } + +// fieldValue checks if the given fork parameter field is present in the given line +// and if it is then returns the field value and the name of the fork it belongs to. +func fieldValue(line, field string) (name, value string, ok bool) { + if pos := strings.Index(line, field); pos >= 0 { + cutFrom := strings.Index(line, "#") // cut in-line comments + if cutFrom < 0 { + cutFrom = len(line) + } + return line[:pos], strings.TrimSpace(line[pos+len(field) : cutFrom]), true + } + return "", "", false +} + +// LoadForks parses the beacon chain configuration file (config.yaml) and extracts +// the list of forks +func LoadForks(fileName string) (Forks, error) { + file, err := os.Open(fileName) + if err != nil { + return nil, fmt.Errorf("Error opening beacon chain config file: %v", err) + } + defer file.Close() + var ( + forks Forks + forkVersions = make(map[string][]byte) + forkEpochs = make(map[string]uint64) + reader = bufio.NewReader(file) + ) + forkEpochs["GENESIS"] = 0 + + for { + l, _, err := reader.ReadLine() + if err == io.EOF { + break + } + if err != nil { + return nil, fmt.Errorf("Error reading beacon chain config file: %v", err) + } + line := string(l) + if name, value, ok := fieldValue(line, "_FORK_VERSION:"); ok { + if v, err := hexutil.Decode(value); err == nil { + forkVersions[name] = v + } else { + return nil, fmt.Errorf("Error decoding hex fork id \"%s\" in beacon chain config file: %v", value, err) + } + } + if name, value, ok := fieldValue(line, "_FORK_EPOCH:"); ok { + if v, err := strconv.ParseUint(value, 10, 64); err == nil { + forkEpochs[name] = v + } else { + return nil, fmt.Errorf("Error parsing epoch number \"%s\" in beacon chain config file: %v", value, err) + } + } + } + + for name, epoch := range forkEpochs { + if version, ok := forkVersions[name]; ok { + delete(forkVersions, name) + forks = append(forks, Fork{Epoch: epoch, Name: name, Version: version}) + } else { + return nil, fmt.Errorf("Fork id missing for \"%s\" in beacon chain config file", name) + } + } + + for name := range forkVersions { + return nil, fmt.Errorf("Epoch number missing for fork \"%s\" in beacon chain config file", name) + } + sort.Sort(forks) + return forks, nil +} diff --git a/beacon/light/types/header.go b/beacon/light/types/header.go new file mode 100644 index 0000000000..cd4731ffb9 --- /dev/null +++ b/beacon/light/types/header.go @@ -0,0 +1,158 @@ +// 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 types + +import ( + "encoding/binary" + "encoding/json" + + "github.com/ethereum/go-ethereum/beacon/merkle" + "github.com/ethereum/go-ethereum/beacon/params" + "github.com/ethereum/go-ethereum/common" +) + +// Header defines a beacon header +// +// See data structure definition here: +// https://github.com/ethereum/consensus-specs/blob/dev/specs/phase0/beacon-chain.md#beaconblockheader +type Header struct { + Slot uint64 + ProposerIndex uint64 + ParentRoot common.Hash + StateRoot common.Hash + BodyRoot common.Hash +} + +// Header defines a beacon header and supports JSON encoding according to the +// standard beacon API format +// +// See data structure definition here: +// https://github.com/ethereum/consensus-specs/blob/dev/specs/phase0/beacon-chain.md#beaconblockheader +type jsonHeader struct { + Slot common.Decimal `json:"slot"` + ProposerIndex common.Decimal `json:"proposer_index"` + ParentRoot common.Hash `json:"parent_root"` + StateRoot common.Hash `json:"state_root"` + BodyRoot common.Hash `json:"body_root"` +} + +// MarshalJSON marshals as JSON. +func (bh *Header) MarshalJSON() ([]byte, error) { + return json.Marshal(&jsonHeader{ + Slot: common.Decimal(bh.Slot), + ProposerIndex: common.Decimal(bh.ProposerIndex), + ParentRoot: bh.ParentRoot, + StateRoot: bh.StateRoot, + BodyRoot: bh.BodyRoot, + }) +} + +// UnmarshalJSON unmarshals from JSON. +func (bh *Header) UnmarshalJSON(input []byte) error { + var dec jsonHeader + if err := json.Unmarshal(input, &dec); err != nil { + return err + } + bh.Slot = uint64(dec.Slot) + bh.ProposerIndex = uint64(dec.ProposerIndex) + bh.ParentRoot = dec.ParentRoot + bh.StateRoot = dec.StateRoot + bh.BodyRoot = dec.BodyRoot + return nil +} + +// Hash calculates the block root of the header +func (bh *Header) Hash() common.Hash { + var values [8]merkle.Value // values corresponding to indices 8 to 15 of the beacon header tree + binary.LittleEndian.PutUint64(values[params.BhiSlot-8][:8], bh.Slot) + binary.LittleEndian.PutUint64(values[params.BhiProposerIndex-8][:8], bh.ProposerIndex) + values[params.BhiParentRoot-8] = merkle.Value(bh.ParentRoot) + values[params.BhiStateRoot-8] = merkle.Value(bh.StateRoot) + values[params.BhiBodyRoot-8] = merkle.Value(bh.BodyRoot) + return merkle.MultiProof{Format: merkle.NewRangeFormat(8, 15, nil), Values: values[:]}.RootHash() +} + +// Epoch returns the epoch the header belongs to +func (bh *Header) Epoch() uint64 { + return bh.Slot >> params.Log2EpochLength +} + +// SyncPeriod returns the sync period the header belongs to +func (bh *Header) SyncPeriod() uint64 { + return bh.Slot >> params.Log2SyncPeriodLength +} + +// PeriodStart returns the first slot of the given period +func PeriodStart(period uint64) uint64 { + return period << params.Log2SyncPeriodLength +} + +// PeriodOfSlot returns the sync period that the given slot belongs to +func PeriodOfSlot(slot uint64) uint64 { + return slot >> params.Log2SyncPeriodLength +} + +// HeaderWithoutState stores beacon header fields except the state root which can +// be reconstructed from a partial beacon state proof stored alongside the header +type HeaderWithoutState struct { + Slot uint64 + ProposerIndex uint64 + ParentRoot, BodyRoot common.Hash +} + +// Hash calculates the block root of the header +func (bh *HeaderWithoutState) Hash(stateRoot common.Hash) common.Hash { + return bh.Proof(stateRoot).RootHash() +} + +// Proof returns a MultiProof of the header +func (bh *HeaderWithoutState) Proof(stateRoot common.Hash) merkle.MultiProof { + var values [8]merkle.Value // values corresponding to indices 8 to 15 of the beacon header tree + binary.LittleEndian.PutUint64(values[params.BhiSlot-8][:8], bh.Slot) + binary.LittleEndian.PutUint64(values[params.BhiProposerIndex-8][:8], bh.ProposerIndex) + values[params.BhiParentRoot-8] = merkle.Value(bh.ParentRoot) + values[params.BhiStateRoot-8] = merkle.Value(stateRoot) + values[params.BhiBodyRoot-8] = merkle.Value(bh.BodyRoot) + return merkle.MultiProof{Format: merkle.NewRangeFormat(8, 15, nil), Values: values[:]} +} + +// FullHeader reconstructs a full Header from a HeaderWithoutState and a state root +func (bh *HeaderWithoutState) FullHeader(stateRoot common.Hash) Header { + return Header{ + Slot: bh.Slot, + ProposerIndex: bh.ProposerIndex, + ParentRoot: bh.ParentRoot, + StateRoot: stateRoot, + BodyRoot: bh.BodyRoot, + } +} + +// SignedHead represents a beacon header signed by a sync committee +// +// Note: this structure is created from either an optimistic update or an instant update: +// https://github.com/ethereum/consensus-specs/blob/dev/specs/altair/light-client/sync-protocol.md#lightclientoptimisticupdate +// https://github.com/zsfelfoldi/beacon-APIs/blob/instant_update/apis/beacon/light_client/instant_update.yaml +type SignedHead struct { + Header Header // signed beacon header + SyncAggregate SyncAggregate // sync committee signature aggregate + SignatureSlot uint64 // slot in which the signature has been created (newer than Header.Slot, determines the signing sync committee) +} + +// SignerCount returns the number of individual signers in the signature aggregate +func (s *SignedHead) SignerCount() int { + return s.SyncAggregate.SignerCount() +} diff --git a/beacon/light/types/protocol.go b/beacon/light/types/protocol.go new file mode 100644 index 0000000000..37feb755f2 --- /dev/null +++ b/beacon/light/types/protocol.go @@ -0,0 +1,242 @@ +// 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 types + +import ( + "encoding/json" + "errors" + + "github.com/ethereum/go-ethereum/beacon/merkle" + "github.com/ethereum/go-ethereum/beacon/params" + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/log" +) + +const MaxUpdateScoresLength = 128 // max number of advertised update scores of most recent periods + +// LightClientUpdate is a proof of the next sync committee root based on a header +// signed by the sync committee of the given period. Optionally the update can +// prove quasi-finality by the signed header referring to a previous, finalized +// header from the same period, and the finalized header referring to the next +// sync committee root. +// +// See data structure definition here: +// https://github.com/ethereum/consensus-specs/blob/dev/specs/altair/light-client/sync-protocol.md#lightclientupdate +type LightClientUpdate struct { + Header Header + SyncAggregate SyncAggregate + SignatureSlot uint64 + NextSyncCommitteeRoot common.Hash + NextSyncCommitteeBranch merkle.Values + FinalizedHeader Header + FinalityBranch merkle.Values + score UpdateScore // not part of the encoding, calculated after decoding + scoreCalculated bool +} + +type CommitteeUpdate struct { + Version string + Update *LightClientUpdate + NextSyncCommittee *SerializedCommittee +} + +// See data structure definition here: +// https://github.com/ethereum/consensus-specs/blob/dev/specs/altair/light-client/sync-protocol.md#lightclientupdate +type committeeUpdateJson struct { + Version string `json:"version"` + Data committeeUpdateData `json:"data"` +} + +type committeeUpdateData struct { + Header JsonBeaconHeader `json:"attested_header"` + NextSyncCommittee *SerializedCommittee `json:"next_sync_committee"` + NextSyncCommitteeBranch merkle.Values `json:"next_sync_committee_branch"` + FinalizedHeader JsonBeaconHeader `json:"finalized_header"` + FinalityBranch merkle.Values `json:"finality_branch"` + SyncAggregate SyncAggregate `json:"sync_aggregate"` + SignatureSlot common.Decimal `json:"signature_slot"` +} + +type JsonBeaconHeader struct { + Beacon Header `json:"beacon"` +} + +// MarshalJSON marshals as JSON. +func (u *CommitteeUpdate) MarshalJSON() ([]byte, error) { + return json.Marshal(&committeeUpdateJson{ + Version: u.Version, + Data: committeeUpdateData{ + Header: JsonBeaconHeader{Beacon: u.Update.Header}, + NextSyncCommittee: u.NextSyncCommittee, + NextSyncCommitteeBranch: u.Update.NextSyncCommitteeBranch, + FinalizedHeader: JsonBeaconHeader{Beacon: u.Update.FinalizedHeader}, //TODO should we encode it when not present? + FinalityBranch: u.Update.FinalityBranch, + SyncAggregate: u.Update.SyncAggregate, + SignatureSlot: common.Decimal(u.Update.SignatureSlot), + }, + }) +} + +// UnmarshalJSON unmarshals from JSON. +func (u *CommitteeUpdate) UnmarshalJSON(input []byte) error { + var dec committeeUpdateJson + if err := json.Unmarshal(input, &dec); err != nil { + return err + } + u.Version = dec.Version + u.NextSyncCommittee = dec.Data.NextSyncCommittee + u.Update = &LightClientUpdate{ + Header: dec.Data.Header.Beacon, + SyncAggregate: dec.Data.SyncAggregate, + SignatureSlot: uint64(dec.Data.SignatureSlot), + NextSyncCommitteeRoot: u.NextSyncCommittee.Root(), + NextSyncCommitteeBranch: dec.Data.NextSyncCommitteeBranch, + FinalizedHeader: dec.Data.FinalizedHeader.Beacon, + FinalityBranch: dec.Data.FinalityBranch, + } + return nil +} + +// Validate verifies the validity of the update +func (update *LightClientUpdate) Validate() error { + period := update.Header.SyncPeriod() + if PeriodOfSlot(update.SignatureSlot) != period { + return errors.New("signature slot and signed header are from different periods") + } + if update.hasFinalizedHeader() { + if update.FinalizedHeader.SyncPeriod() != period { + return errors.New("finalizedHeader is from previous period") // proves the same committee it is signed by + } + if root, ok := merkle.VerifySingleProof(update.FinalityBranch, params.BsiFinalBlock, merkle.Value(update.FinalizedHeader.Hash())); !ok || root != update.Header.StateRoot { + return errors.New("invalid FinalizedHeader merkle proof") + } + } + if root, ok := merkle.VerifySingleProof(update.NextSyncCommitteeBranch, params.BsiNextSyncCommittee, merkle.Value(update.NextSyncCommitteeRoot)); !ok || root != update.Header.StateRoot { + return errors.New("invalid NextSyncCommittee merkle proof") + } + return nil +} + +// hasFinalizedHeader returns true if the update has a finalized header referred +// by the signed header and referring to the next sync committee. +// Note that in addition to this, a sufficient signer participation is also needed +// in order to fulfill the quasi-finality condition (see UpdateScore.isFinalized). +func (l *LightClientUpdate) hasFinalizedHeader() bool { + return l.FinalizedHeader.BodyRoot != (common.Hash{}) && l.FinalizedHeader.SyncPeriod() == l.Header.SyncPeriod() +} + +// Score returns the UpdateScore describing the proof strength of the update +// Note: thread safety can be ensured by always calling Score on a newly received +// or decoded update before making it potentially available for other threads +func (l *LightClientUpdate) Score() UpdateScore { + if l.scoreCalculated { + return l.score + } + l.score.SignerCount = uint32(l.SyncAggregate.SignerCount()) + l.score.SubPeriodIndex = uint32(l.Header.Slot & 0x1fff) + l.score.FinalizedHeader = l.hasFinalizedHeader() + l.scoreCalculated = true + return l.score +} + +// UpdateScore allows the comparison between updates at the same period in order +// to find the best update chain that provides the strongest proof of being canonical. +// +// UpdateScores have a tightly packed binary encoding format for efficient p2p +// protocol transmission. Each UpdateScore is encoded in 3 bytes. +// When interpreted as a 24 bit little indian unsigned integer: +// - the lowest 10 bits contain the number of signers in the header signature aggregate +// - the next 13 bits contain the "sub-period index" which is he signed header's +// slot modulo params.SyncPeriodLength (which is correlated with the risk of the chain being +// re-orged before the previous period boundary in case of non-finalized updates) +// - the highest bit is set when the update is finalized (meaning that the finality +// header referenced by the signed header is in the same period as the signed +// header, making reorgs before the period boundary impossible +type UpdateScore struct { + SignerCount uint32 // number of signers in the header signature aggregate + SubPeriodIndex uint32 // signed header's slot modulo params.SyncPeriodLength + FinalizedHeader bool // update is considered finalized if has finalized header from the same period and 2/3 signatures +} + +// isFinalized returns true if the update has a header signed by at least 2/3 of +// the committee, referring to a finalized header that refers to the next sync +// committee. This condition is a close approximation of the actual finality +// condition that can only be verified by full beacon nodes. +func (u *UpdateScore) isFinalized() bool { + return u.FinalizedHeader && u.SignerCount >= params.SyncCommitteeSupermajority +} + +// BetterThan returns true if update u is considered better than w. +func (u UpdateScore) BetterThan(w UpdateScore) bool { + var ( + uFinalized = u.isFinalized() + wFinalized = w.isFinalized() + ) + if uFinalized != wFinalized { + return uFinalized + } + return u.SignerCount > w.SignerCount +} + +type PeriodRange struct { + First, AfterLast uint64 +} + +/*func (a PeriodRange) Shared(b PeriodRange) PeriodRange { + if b.First > a.First { + a.First = b.First + } + if b.AfterLast < a.AfterLast { + a.AfterLast = b.AfterLast + } + return a +} + +func (a PeriodRange) IsValid() bool { + return a.AfterLast >= a.First +}*/ + +func (a PeriodRange) IsEmpty() bool { + return a.AfterLast == a.First +} + +func (a PeriodRange) Includes(period uint64) bool { + return period >= a.First && period < a.AfterLast +} + +func (a PeriodRange) CanExpand(period uint64) bool { + return a.IsEmpty() || (period+1 >= a.First && period <= a.AfterLast) +} + +func (a *PeriodRange) 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 + } + log.Error("Could not expand period range", "first", a.First, "") +} diff --git a/beacon/merkle/binary_merkle.go b/beacon/merkle/binary_merkle.go new file mode 100644 index 0000000000..cf54a3a2e2 --- /dev/null +++ b/beacon/merkle/binary_merkle.go @@ -0,0 +1,494 @@ +// 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 merkle + +import ( + "math/bits" + "reflect" + + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/common/hexutil" + "github.com/ethereum/go-ethereum/log" + "github.com/minio/sha256-simd" +) + +// Value represents either a 32 byte value or hash node in a binary merkle tree/partial proof +type ( + Value [32]byte + Values []Value +) + +var ValueT = reflect.TypeOf(Value{}) + +// UnmarshalJSON parses a merkle value in hex syntax. +func (m *Value) UnmarshalJSON(input []byte) error { + return hexutil.UnmarshalFixedJSON(ValueT, input, m[:]) +} + +// VerifySingleProof verifies a Merkle proof branch for a single value in a +// binary Merkle tree (index is a generalized tree index). +func VerifySingleProof(proof Values, index uint64, value Value) (common.Hash, bool) { + hasher := sha256.New() + for _, proofHash := range proof { + hasher.Reset() + if index&1 == 0 { + hasher.Write(value[:]) + hasher.Write(proofHash[:]) + } else { + hasher.Write(proofHash[:]) + hasher.Write(value[:]) + } + hasher.Sum(value[:0]) + index /= 2 + if index == 0 { + return common.Hash{}, false + } + } + if index != 1 { + return common.Hash{}, false + } + return common.Hash(value), true +} + +// ProofFormat defines the shape of a partial proof and allows traversing a subset of a tree +type ProofFormat interface { + Children() (left, right ProofFormat) // either both or neither should be nil +} + +// ProofReader allows traversing and reading a tree structure or a subset of it. +// Note: the hash of each traversed node is always requested. If the internal +// hash is not available then subtrees are always traversed (first left, then right). +// If internal hash is available then subtrees are only traversed if needed by the writer. +type ProofReader interface { + Children() (left, right ProofReader) // subtrees accessible if not nil + ReadNode() (Value, bool) // hash should be available if children are nil (leaf node), optional otherwise (internal node) +} + +// ProofWriter allow collecting data for a partial proof while a subset of a tree is traversed. +type ProofWriter interface { + Children() (left, right ProofWriter) // all non-nil subtrees are traversed + WriteNode(Value) // called for every traversed tree node (both leaf and internal) +} + +// TraverseProof traverses a reader and a writer defined on the same tree +// simultaneously, copies data from the reader to the writer (if writer is not nil) +// and returns the root hash. At least the shape defined by the writer is traversed; +// subtrees not required by the writer are only traversed (with writer == nil) +// if the hash of the internal tree node is not provided by the reader. +func TraverseProof(reader ProofReader, writer ProofWriter) (common.Hash, bool) { + var ( + wl ProofWriter + wr ProofWriter + ) + if writer != nil { + wl, wr = writer.Children() + } + node, nodeAvailable := reader.ReadNode() + if nodeAvailable && wl == nil { + if writer != nil { + writer.WriteNode(node) + } + return common.Hash(node), true + } + rl, rr := reader.Children() + if rl == nil { + return common.Hash{}, false + } + lhash, ok := TraverseProof(rl, wl) + if !ok { + return common.Hash{}, false + } + rhash, ok := TraverseProof(rr, wr) + if !ok { + return common.Hash{}, false + } + if !nodeAvailable { + hasher := sha256.New() + hasher.Write(lhash[:]) + hasher.Write(rhash[:]) + hasher.Sum(node[:0]) + } + if writer != nil { + writer.WriteNode(node) + } + return common.Hash(node), true +} + +// MultiProof stores a partial Merkle tree proof +type MultiProof struct { + Format ProofFormat + Values Values +} + +// multiProofReader implements ProofReader based on a MultiProof and also allows +// attaching further subtree readers at certain indices +// Note: valuePtr is stored and copied as a reference because child readers read +// from the same value list as the tree is traversed +type multiProofReader struct { + format ProofFormat // corresponding proof format + values Values // proof values + valuePtr *int // next index to be read from values + index uint64 // generalized tree index + subtrees func(uint64) ProofReader // attached subtrees +} + +// children implements ProofReader +func (mpr multiProofReader) Children() (left, right ProofReader) { + lf, rf := mpr.format.Children() + if lf == nil { + if mpr.subtrees != nil { + if subtree := mpr.subtrees(mpr.index); subtree != nil { + return subtree.Children() + } + } + return nil, nil + } + return multiProofReader{format: lf, values: mpr.values, valuePtr: mpr.valuePtr, index: mpr.index * 2, subtrees: mpr.subtrees}, + multiProofReader{format: rf, values: mpr.values, valuePtr: mpr.valuePtr, index: mpr.index*2 + 1, subtrees: mpr.subtrees} +} + +// readNode implements ProofReader +func (mpr multiProofReader) ReadNode() (Value, bool) { + if l, _ := mpr.format.Children(); l == nil && len(mpr.values) > *mpr.valuePtr { + hash := mpr.values[*mpr.valuePtr] + (*mpr.valuePtr)++ + return hash, true + } + return Value{}, false +} + +// Reader creates a multiProofReader for the given proof; if subtrees != nil +// then also attaches subtree readers at indices where the function returns a +// non-nil reader. +// Note that the reader can only be traversed once as the values slice is +// sequentially consumed. +func (mp MultiProof) Reader(subtrees func(uint64) ProofReader) multiProofReader { + return multiProofReader{format: mp.Format, values: mp.Values, valuePtr: new(int), index: 1, subtrees: subtrees} +} + +// Finished returns true if all values have been consumed by the traversal. +// Should be checked after TraverseProof if received from an untrusted source in +// order to prevent DoS attacks by excess proof values. +func (mpr multiProofReader) Finished() bool { + return len(mpr.values) == *mpr.valuePtr +} + +// rootHash returns the root hash of the proven structure. +func (mp MultiProof) RootHash() common.Hash { + reader := mp.Reader(nil) + hash, ok := TraverseProof(reader, nil) + if !ok || !reader.Finished() { + log.Error("MultiProof.rootHash: invalid proof format") + } + return hash +} + +// multiProofWriter implements ProofWriter and creates a MultiProof with the +// previously specified format. Also allows attaching further subtree writers at +// certain indices. +// Note: values is stored and copied as a reference because child writers append +// to the same value list as the tree is traversed +type multiProofWriter struct { + format ProofFormat // target proof format + values *Values // target proof value list + index uint64 // generalized tree index + subtrees func(uint64) ProofWriter // attached subtrees +} + +// NewMultiProofWriter creates a new multiproof writer with the specified format. +// If subtrees != nil then further subtree writers are attached at indices where +// the function returns a non-nil writer. +// Note that the specified format should not include these attached subtrees; +// they should be attached at leaf indices of the given format. +// Also note that target can be nil in which case the nodes specified by the format +// are traversed but not stored; subtree writers might still store tree data. +func NewMultiProofWriter(format ProofFormat, target *Values, subtrees func(uint64) ProofWriter) multiProofWriter { + return multiProofWriter{format: format, values: target, index: 1, subtrees: subtrees} +} + +// children implements ProofWriter +func (mpw multiProofWriter) Children() (left, right ProofWriter) { + if mpw.subtrees != nil { + if subtree := mpw.subtrees(mpw.index); subtree != nil { + return subtree.Children() + } + } + lf, rf := mpw.format.Children() + if lf == nil { + return nil, nil + } + return multiProofWriter{format: lf, values: mpw.values, index: mpw.index * 2, subtrees: mpw.subtrees}, + multiProofWriter{format: rf, values: mpw.values, index: mpw.index*2 + 1, subtrees: mpw.subtrees} +} + +// writeNode implements ProofWriter +func (mpw multiProofWriter) WriteNode(node Value) { + if mpw.values != nil { + if lf, _ := mpw.format.Children(); lf == nil { + *mpw.values = append(*mpw.values, node) + } + } + if mpw.subtrees != nil { + if subtree := mpw.subtrees(mpw.index); subtree != nil { + subtree.WriteNode(node) + } + } +} + +// ProofFormatIndexMap creates a generalized tree index -> MultiProof value +// slice index association map based on the given proof format. +func ProofFormatIndexMap(f ProofFormat) map[uint64]int { + var ( + m = make(map[uint64]int) + pos int + ) + addToIndexMap(m, f, &pos, 1) + return m +} + +// addToIndexMap recursively creates index associations for a given proof format subtree. +func addToIndexMap(m map[uint64]int, f ProofFormat, pos *int, index uint64) { + l, r := f.Children() + if l == nil { + m[index] = *pos + (*pos)++ + } else { + addToIndexMap(m, l, pos, index*2) + addToIndexMap(m, r, pos, index*2+1) + } +} + +// ChildIndex returns the generalized tree index of a subtree node in terms of +// the main tree where a is the main tree index of the subtree root and b is the +// subtree index of the node in question. +func ChildIndex(a, b uint64) uint64 { + return (a-1)<<(63-bits.LeadingZeros64(b)) + b +} + +// IndexMapFormat implements ProofFormat based on an index map filled with +// AddLeaf calls. Subtree formats can also be attached at certain indices. +type IndexMapFormat struct { + leaves map[uint64]ProofFormat + index uint64 +} + +// NewIndexMapFormat returns an empty format. +func NewIndexMapFormat() IndexMapFormat { + return IndexMapFormat{leaves: make(map[uint64]ProofFormat), index: 1} +} + +// AddLeaf adds either a single leaf or attaches a subtree at the given tree index. +func (f IndexMapFormat) AddLeaf(index uint64, subtree ProofFormat) IndexMapFormat { + if subtree != nil { + f.leaves[index] = subtree + } + for index > 1 { + index /= 2 + f.leaves[index] = nil + } + return f +} + +// children implements ProofFormat +func (f IndexMapFormat) Children() (left, right ProofFormat) { + if st, ok := f.leaves[f.index]; ok { + if st != nil { + return st.Children() + } + return IndexMapFormat{leaves: f.leaves, index: f.index * 2}, IndexMapFormat{leaves: f.leaves, index: f.index*2 + 1} + } + return nil, nil +} + +// rangeFormat defined a proof format with a continuous range of leaf indices. +// Attaching subtree formats is also possible. +type rangeFormat struct { + begin, end, index uint64 // begin and end should be on the same level + subtree func(uint64) ProofFormat +} + +// NewRangeFormat creates a new rangeFormat with leafs in the begin..end range. +// If subtrees != nil then further subtree formats are attached at indices where +// the function returns a non-nil format. +func NewRangeFormat(begin, end uint64, subtree func(uint64) ProofFormat) rangeFormat { + return rangeFormat{ + begin: begin, + end: end, + index: 1, + subtree: subtree, + } +} + +// children implements ProofFormat +func (rf rangeFormat) Children() (left, right ProofFormat) { + var ( + lzr = bits.LeadingZeros64(rf.begin) + lzi = bits.LeadingZeros64(rf.index) + ) + if lzi < lzr { + return nil, nil + } + if lzi == lzr { + if rf.subtree != nil && rf.index >= rf.begin && rf.index <= rf.end { + if st := rf.subtree(rf.index); st != nil { + return st.Children() + } + } + return nil, nil + } + var ( + // i1..i2 are the descendants of rf.index at the tree level where begin and end are located + i1 = rf.index << (lzi - lzr) + i2 = ((rf.index + 1) << (lzi - lzr)) - 1 + ) + if i1 <= rf.end && i2 >= rf.begin { + // Return child formats if there is an overlap (rf.index has any descendants + // in the begin..end range). + // Note that if begin..end only touches one of the returned child subtrees, + // we still return a rangeFormat for both branches and the other one will + // not have any further children (that child of rf.index will be stored + // in the proof as a single sibling node). + return rangeFormat{begin: rf.begin, end: rf.end, index: rf.index * 2, subtree: rf.subtree}, + rangeFormat{begin: rf.begin, end: rf.end, index: rf.index*2 + 1, subtree: rf.subtree} + } + return nil, nil +} + +// MergedFormat implements ProofFormat and realizes the union of the included +// individual formats. +type MergedFormat []ProofFormat + +// children implements ProofFormat +func (m MergedFormat) Children() (left, right ProofFormat) { + var ( + l = make(MergedFormat, 0, len(m)) + r = make(MergedFormat, 0, len(m)) + ) + for _, f := range m { + if left, right := f.Children(); left != nil { + l = append(l, left) + r = append(r, right) + } + } + if len(l) > 0 { + return l, r + } + return nil, nil +} + +// MergedReader implements ProofReader and realizes the union of the included +// individual readers. +// Note that the readers belonging to the same structure (having the same root) +// is not checked by MergedReader. +// Also note that fully consuming underlying sequential readers is not guaranteed +// (MultiProofReader.Finalized will not necessarily return true so if necessary +// then the well-formedness of individual multiproofs should be checked separately). +type MergedReader []ProofReader + +// children implements ProofReader +func (m MergedReader) Children() (left, right ProofReader) { + var ( + l = make(MergedReader, 0, len(m)) + r = make(MergedReader, 0, len(m)) + ) + for _, reader := range m { + if left, right := reader.Children(); left != nil { + l = append(l, left) + r = append(r, right) + } + } + if len(l) > 0 { + return l, r + } + return nil, nil +} + +// readNode implements ProofReader +func (m MergedReader) ReadNode() (value Value, ok bool) { + var hasChildren bool + for _, reader := range m { + if left, _ := reader.Children(); left != nil { + // ensure that all readers are fully traversed + hasChildren = true + } + if v, o := reader.ReadNode(); o { + value, ok = v, o + } + } + if hasChildren { + return Value{}, false + } + return +} + +// MergedWriter implements ProofWriter and realizes the union of the included +// individual writers. The shape traversed by MergedWriter is the union of the +// shapes traversed by individual writers. +type MergedWriter []ProofWriter + +// children implements ProofWriter +func (m MergedWriter) Children() (left, right ProofWriter) { + var ( + l = make(MergedWriter, 0, len(m)) + r = make(MergedWriter, 0, len(m)) + ) + for _, w := range m { + if left, right := w.Children(); left != nil { + l = append(l, left) + r = append(r, right) + } + } + if len(l) > 0 { + return l, r + } + return nil, nil +} + +// writeNode implements ProofWriter +func (m MergedWriter) WriteNode(value Value) { + for _, w := range m { + w.WriteNode(value) + } +} + +// callbackWriter implements ProofWriter with a simple callback mechanism +type callbackWriter struct { + format ProofFormat + index uint64 + storeCallback func(uint64, Value) +} + +// NewCallbackWriter creates a callbackWriter that traverses the tree subset +// defined by the given proof format and calls callbackWriter for each traversed node +func NewCallbackWriter(format ProofFormat, storeCallback func(uint64, Value)) callbackWriter { + return callbackWriter{format: format, index: 1, storeCallback: storeCallback} +} + +// children implements ProofWriter +func (cw callbackWriter) Children() (left, right ProofWriter) { + lf, rf := cw.format.Children() + if lf == nil { + return nil, nil + } + return callbackWriter{format: lf, index: cw.index * 2, storeCallback: cw.storeCallback}, + callbackWriter{format: rf, index: cw.index*2 + 1, storeCallback: cw.storeCallback} +} + +// writeNode implements ProofWriter +func (cw callbackWriter) WriteNode(node Value) { + cw.storeCallback(cw.index, node) +} diff --git a/beacon/merkle/binary_merkle_test.go b/beacon/merkle/binary_merkle_test.go new file mode 100644 index 0000000000..9dab56ba32 --- /dev/null +++ b/beacon/merkle/binary_merkle_test.go @@ -0,0 +1,259 @@ +// 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 merkle + +import ( + "math/bits" + "math/rand" + "testing" + + "github.com/ethereum/go-ethereum/common" + "github.com/minio/sha256-simd" +) + +func TestMergedFormat(t *testing.T) { + for count := 0; count < 1000; count++ { + single := NewIndexMapFormat() + merged := MergedFormat{} + for { + if rand.Intn(5) == 0 { + break + } + f := NewIndexMapFormat() + for { + if rand.Intn(5) == 0 { + break + } + index := uint64(rand.Intn(255) + 1) + single.AddLeaf(index, nil) + f.AddLeaf(index, nil) + } + merged = append(merged, f) + } + if !formatsEqual(single, merged) { + t.Errorf("Single and merged formats do not match") + } + } +} + +func TestIndexMapSubtrees(t *testing.T) { + for count := 0; count < 1000; count++ { + single := NewIndexMapFormat() + withSubtrees := NewIndexMapFormat() + // put single leaves and subtrees randomly into a single row in order to avoid collisions + for index := uint64(256); index < 512; index++ { + switch rand.Intn(100) { + case 0: // put single leaf at index + single.AddLeaf(index, nil) + withSubtrees.AddLeaf(index, nil) + case 1: // put subtree at index + subtree := NewIndexMapFormat() + for { + subindex := uint64(rand.Intn(255) + 1) + single.AddLeaf(ChildIndex(index, subindex), nil) + subtree.AddLeaf(subindex, nil) + if rand.Intn(5) == 0 { // exit here in order to avoid empty subtrees + break + } + } + withSubtrees.AddLeaf(index, subtree) + } + } + if !formatsEqual(single, withSubtrees) { + t.Errorf("Single and subtree formats do not match") + } + } +} + +func TestRangeFormat(t *testing.T) { + for count := 0; count < 1000; count++ { + single := NewIndexMapFormat() + begin := uint64(rand.Intn(255) + 1) + nextLevel := uint64(1) + for nextLevel <= begin { + nextLevel += nextLevel + } + end := begin + uint64(rand.Intn(int(nextLevel-begin))) + for i := begin; i <= end; i++ { + single.AddLeaf(i, nil) + } + + var subFn func(index uint64) ProofFormat + if rand.Intn(2) == 0 { + subroot := begin + uint64(rand.Intn(int(end+1-begin))) + subBegin := uint64(rand.Intn(255) + 1) + nextLevel = uint64(1) + for nextLevel <= subBegin { + nextLevel += nextLevel + } + subEnd := subBegin + uint64(rand.Intn(int(nextLevel-subBegin))) + for i := subBegin; i <= subEnd; i++ { + single.AddLeaf(ChildIndex(subroot, i), nil) + } + subtree := NewRangeFormat(subBegin, subEnd, nil) + subFn = func(index uint64) ProofFormat { + if index == subroot { + return subtree + } + return nil + } + } + + rangeFormat := NewRangeFormat(begin, end, subFn) + if !formatsEqual(single, rangeFormat) { + t.Errorf("Single and range formats do not match") + } + } +} + +func TestSingleProof(t *testing.T) { + for index := uint64(1); index < 256; index++ { + proof := make(Values, 63-bits.LeadingZeros64(index)) + writer := NewCallbackWriter(NewIndexMapFormat().AddLeaf(index, nil), func(i uint64, v Value) { + shift := bits.LeadingZeros64(i) - bits.LeadingZeros64(index) + if i^(index>>shift) == 1 { + proof[shift] = v + } + }) + testTraverseProof(t, testProofReader, writer, true) + root, ok := VerifySingleProof(proof, index, testMerkleTree[index]) + if root != common.Hash(testMerkleTree[1]) { + t.Errorf("VerifySingleProof root hash mismatch (index = %d)", index) + } + if !ok { + t.Errorf("VerifySingleProof length invalid (index = %d)", index) + } + } +} + +func TestMultiProof(t *testing.T) { + for count := 0; count < 300; count++ { + failIndex := uint64(128 + rand.Intn(128)) + indexList := make([]uint64, 10) + for i := range indexList { + for { + indexList[i] = uint64(128 + rand.Intn(128)) + if indexList[i]^failIndex > 1 { + // failIndex should not be available in proofs so it should not be equal to or sibling of a stored index + break + } + } + } + + readers := make([]ProofReader, len(indexList)) + for i, index := range indexList { + var mp MultiProof + mp.Format = NewIndexMapFormat().AddLeaf(index, nil) + writer := NewMultiProofWriter(mp.Format, &mp.Values, nil) + testTraverseProof(t, testProofReader, writer, true) + readers[i] = mp.Reader(nil) + } + + // create a single multiproof from the merged reader using a subset of indices + var mp MultiProof + format := NewIndexMapFormat() + mpCount := rand.Intn(11) // add a subset of indices to the created multiproof format + for i := 0; i < mpCount; i++ { + format.AddLeaf(indexList[i], nil) + } + mp.Format = format + expSuccess := rand.Intn(2) == 0 + if !expSuccess { + // add an index that should not be available in the merged reader, expect the traversal to fail + format.AddLeaf(failIndex, nil) + } + testTraverseProof(t, MergedReader(readers), NewMultiProofWriter(format, &mp.Values, nil), expSuccess) + + if expSuccess { + mpwCount := rand.Intn(mpCount + 1) // create writers for a subset of the previously selected indices (available in mp) + mps := make([]MultiProof, mpwCount) + writers := make([]ProofWriter, mpwCount) + for i := range mps { + mps[i].Format = NewIndexMapFormat().AddLeaf(indexList[i], nil) + writers[i] = NewMultiProofWriter(mps[i].Format, &mps[i].Values, nil) + } + reader := mp.Reader(nil) + testTraverseProof(t, reader, MergedWriter(writers), true) + if !reader.Finished() { + t.Errorf("MultiProofReader not finished") + } + // test individual single-value multiproofs + for i, mp := range mps { + if valueIndex, ok := ProofFormatIndexMap(mp.Format)[indexList[i]]; !ok || mp.Values[valueIndex] != testMerkleTree[indexList[i]] { + t.Errorf("Could not find tree index %d in single-value multiproof", indexList[i]) + } + } + } + } +} + +func testTraverseProof(t *testing.T, reader ProofReader, writer ProofWriter, expSuccess bool) { + root, ok := TraverseProof(reader, writer) + if expSuccess { + if root != common.Hash(testMerkleTree[1]) { + t.Errorf("TraverseProof root hash mismatch") + } + if !ok { + t.Errorf("TraverseProof insufficient reader data") + } + } else if ok { + t.Errorf("TraverseProof succeeded (expected to fail)") + } +} + +func formatsEqual(f1, f2 ProofFormat) bool { + if f1 == nil && f2 == nil { + return true + } + if f1 == nil || f2 == nil { + return false + } + c1l, c1r := f1.Children() + c2l, c2r := f2.Children() + return formatsEqual(c1l, c2l) && formatsEqual(c1r, c2r) +} + +type testReader byte + +var testProofReader = testReader(1) + +func (r testReader) Children() (left, right ProofReader) { + if r >= 128 { + return nil, nil + } + return r * 2, r*2 + 1 +} + +func (r testReader) ReadNode() (Value, bool) { + return testMerkleTree[r], true +} + +var testMerkleTree [256]Value + +func init() { + hasher := sha256.New() + for i := byte(255); i >= 1; i-- { + if i >= 128 { + testMerkleTree[i][0] = i + } else { + hasher.Reset() + hasher.Write(testMerkleTree[i*2][:]) + hasher.Write(testMerkleTree[i*2+1][:]) + hasher.Sum(testMerkleTree[i][:0]) + } + } +} diff --git a/beacon/params/constants.go b/beacon/params/constants.go new file mode 100644 index 0000000000..3546b0fd90 --- /dev/null +++ b/beacon/params/constants.go @@ -0,0 +1,29 @@ +// 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 params + +const ( + SyncCommitteeSize = 512 + SyncCommitteeBitmaskSize = SyncCommitteeSize / 8 + SyncCommitteeSupermajority = (SyncCommitteeSize*2 + 2) / 3 + BlsSignatureSize = 96 + BlsPubkeySize = 48 + SyncPeriodLength = 8192 + Log2SyncPeriodLength = 13 + EpochLength = 32 + Log2EpochLength = 5 +) diff --git a/beacon/params/tree_indices.go b/beacon/params/tree_indices.go new file mode 100644 index 0000000000..9ca906e525 --- /dev/null +++ b/beacon/params/tree_indices.go @@ -0,0 +1,46 @@ +// 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 params + +import ( + "github.com/ethereum/go-ethereum/beacon/merkle" +) + +const ( + // beacon header fields + BhiSlot = 8 + BhiProposerIndex = 9 + BhiParentRoot = 10 + BhiStateRoot = 11 + BhiBodyRoot = 12 + + // beacon state fields + BsiGenesisTime = 32 + BsiGenesisValidators = 33 + BsiForkVersion = 141 + BsiLatestHeader = 36 + BsiBlockRoots = 37 + BsiStateRoots = 38 + BsiHistoricRoots = 39 + BsiFinalBlock = 105 + BsiSyncCommittee = 54 + BsiNextSyncCommittee = 55 + BsiExecPayload = 56 + BsiExecHead = 908 +) + +var BsiFinalExecHash = merkle.ChildIndex(merkle.ChildIndex(BsiFinalBlock, BhiStateRoot), BsiExecHead) diff --git a/cmd/blsync/config.go b/cmd/blsync/config.go new file mode 100644 index 0000000000..cf5d22969a --- /dev/null +++ b/cmd/blsync/config.go @@ -0,0 +1,183 @@ +// 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 main + +import ( + "context" + + "github.com/ethereum/go-ethereum/beacon/light" + "github.com/ethereum/go-ethereum/beacon/light/types" + "github.com/ethereum/go-ethereum/cmd/utils" + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/common/hexutil" + "github.com/ethereum/go-ethereum/log" + "github.com/ethereum/go-ethereum/node" + "github.com/ethereum/go-ethereum/rpc" + "github.com/urfave/cli/v2" +) + +var ( + MainnetConfig = light.ChainConfig{ + GenesisData: light.GenesisData{ + GenesisValidatorsRoot: common.HexToHash("0x4b363db94e286120d76eb905340fdd4e54bfe9f06bf33ff6cf5ad27f511bfe95"), + GenesisTime: 1606824023, + }, + Forks: types.Forks{ + types.Fork{ + Epoch: 0, + Name: "GENESIS", + Version: []byte{0, 0, 0, 0}, + }, + types.Fork{ + Epoch: 74240, + Name: "ALTAIR", + Version: []byte{1, 0, 0, 0}, + }, + types.Fork{ + Epoch: 144896, + Name: "BELLATRIX", + Version: []byte{2, 0, 0, 0}, + }, + }, + Checkpoint: common.HexToHash("0x388be41594ec7d6a6894f18c73f3469f07e2c19a803de4755d335817ed8e2e5a"), + } + + SepoliaConfig = light.ChainConfig{ + GenesisData: light.GenesisData{ + GenesisValidatorsRoot: common.HexToHash("0xd8ea171f3c94aea21ebc42a1ed61052acf3f9209c00e4efbaaddac09ed9b8078"), + GenesisTime: 1655733600, + }, + Forks: types.Forks{ + types.Fork{ + Epoch: 0, + Name: "GENESIS", + Version: []byte{144, 0, 0, 105}, + }, + types.Fork{ + Epoch: 50, + Name: "ALTAIR", + Version: []byte{144, 0, 0, 112}, + }, + types.Fork{ + Epoch: 100, + Name: "BELLATRIX", + Version: []byte{144, 0, 0, 113}, + }, + types.Fork{ + Epoch: 56832, + Name: "CAPELLA", + Version: []byte{144, 0, 0, 114}, + }, + }, + Checkpoint: common.HexToHash("0x1005a6d9175e96bfbce4d35b80f468e9bff0b674e1e861d16e09e10005a58e81"), + } + + GoerliConfig = light.ChainConfig{ + GenesisData: light.GenesisData{ + GenesisValidatorsRoot: common.HexToHash("0x043db0d9a83813551ee2f33450d23797757d430911a9320530ad8a0eabc43efb"), + GenesisTime: 1614588812, + }, + Forks: types.Forks{ + types.Fork{ + Epoch: 0, + Name: "GENESIS", + Version: []byte{0, 0, 16, 32}, + }, + types.Fork{ + Epoch: 36660, + Name: "ALTAIR", + Version: []byte{1, 0, 16, 32}, + }, + types.Fork{ + Epoch: 112260, + Name: "BELLATRIX", + Version: []byte{2, 0, 16, 32}, + }, + }, + Checkpoint: common.HexToHash("0x53a0f4f0a378e2c4ae0a9ee97407eb69d0d737d8d8cd0a5fb1093f42f7b81c49"), + } +) + +func makeChainConfig(ctx *cli.Context) light.ChainConfig { + utils.CheckExclusive(ctx, utils.MainnetFlag, utils.GoerliFlag, utils.SepoliaFlag) + customConfig := ctx.IsSet(utils.BeaconConfigFlag.Name) || ctx.IsSet(utils.BeaconGenesisRootFlag.Name) || ctx.IsSet(utils.BeaconGenesisTimeFlag.Name) + var config light.ChainConfig + switch { + case ctx.Bool(utils.MainnetFlag.Name): + config = MainnetConfig + case ctx.Bool(utils.SepoliaFlag.Name): + config = SepoliaConfig + case ctx.Bool(utils.GoerliFlag.Name): + config = GoerliConfig + default: + if !customConfig { + config = MainnetConfig + } + } + if customConfig && config.Forks != nil { + utils.Fatalf("Cannot use custom beacon chain config flags in combination with pre-defined network config") + } + if ctx.IsSet(utils.BeaconConfigFlag.Name) { + forks, err := types.LoadForks(ctx.String(utils.BeaconConfigFlag.Name)) + if err != nil { + utils.Fatalf("Could not load beacon chain config file", "file name", ctx.String(utils.BeaconConfigFlag.Name), "error", err) + } + config.Forks = forks + } + if ctx.IsSet(utils.BeaconGenesisRootFlag.Name) { + if c, err := hexutil.Decode(ctx.String(utils.BeaconGenesisRootFlag.Name)); err == nil && len(c) <= 32 { + copy(config.GenesisValidatorsRoot[:len(c)], c) + } else { + utils.Fatalf("Invalid hex string", "beacon.genesis.gvroot", ctx.String(utils.BeaconGenesisRootFlag.Name), "error", err) + } + } + if ctx.IsSet(utils.BeaconGenesisTimeFlag.Name) { + config.GenesisTime = ctx.Uint64(utils.BeaconGenesisTimeFlag.Name) + } + if ctx.IsSet(utils.BeaconCheckpointFlag.Name) { + if c, err := hexutil.Decode(ctx.String(utils.BeaconCheckpointFlag.Name)); err == nil && len(c) <= 32 { + copy(config.Checkpoint[:len(c)], c) + } else { + utils.Fatalf("Invalid hex string", "beacon.checkpoint", ctx.String(utils.BeaconCheckpointFlag.Name), "error", err) + } + } + return config +} + +func makeRPCClient(ctx *cli.Context) *rpc.Client { + if !ctx.IsSet(utils.BlsyncApiFlag.Name) { + log.Warn("No engine API target specified, performing a dry run") + return nil + } + if !ctx.IsSet(utils.BlsyncJWTSecretFlag.Name) { + utils.Fatalf("JWT secret parameter missing") //TODO use default if datadir is specified + } + + engineApiUrl, jwtFileName := ctx.String(utils.BlsyncApiFlag.Name), ctx.String(utils.BlsyncJWTSecretFlag.Name) + var jwtSecret [32]byte + if jwt, err := node.ObtainJWTSecret(jwtFileName); err == nil { + copy(jwtSecret[:], jwt) + } else { + utils.Fatalf("Error loading or generating JWT secret: %v", err) + } + auth := node.NewJWTAuth(jwtSecret) + cl, err := rpc.DialOptions(context.Background(), engineApiUrl, rpc.WithHTTPAuth(auth)) + if err != nil { + utils.Fatalf("Could not create RPC client: %v", err) + } + return cl +} diff --git a/cmd/blsync/main.go b/cmd/blsync/main.go new file mode 100644 index 0000000000..69176c7f25 --- /dev/null +++ b/cmd/blsync/main.go @@ -0,0 +1,234 @@ +// 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 main + +import ( + "context" + "fmt" + "os" + "strings" + "time" + + "github.com/ethereum/go-ethereum/beacon/engine" + "github.com/ethereum/go-ethereum/beacon/light" + "github.com/ethereum/go-ethereum/beacon/light/api" + "github.com/ethereum/go-ethereum/beacon/light/request" + "github.com/ethereum/go-ethereum/beacon/light/sync" + "github.com/ethereum/go-ethereum/beacon/light/types" + "github.com/ethereum/go-ethereum/beacon/merkle" + "github.com/ethereum/go-ethereum/beacon/params" + "github.com/ethereum/go-ethereum/cmd/utils" + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/common/lru" + "github.com/ethereum/go-ethereum/common/mclock" + ctypes "github.com/ethereum/go-ethereum/core/types" + "github.com/ethereum/go-ethereum/ethdb/memorydb" + "github.com/ethereum/go-ethereum/internal/flags" + "github.com/ethereum/go-ethereum/log" + "github.com/ethereum/go-ethereum/rpc" + "github.com/urfave/cli/v2" +) + +func main() { + log.Root().SetHandler(log.LvlFilterHandler(log.LvlInfo, log.StreamHandler(os.Stderr, log.TerminalFormat(true)))) + app := flags.NewApp("beacon light syncer tool") + app.Flags = []cli.Flag{ + utils.BeaconApiFlag, + utils.BeaconApiHeaderFlag, + utils.BeaconThresholdFlag, + utils.BeaconNoFilterFlag, + utils.BeaconConfigFlag, + utils.BeaconGenesisRootFlag, + utils.BeaconGenesisTimeFlag, + utils.BeaconCheckpointFlag, + //TODO datadir for optional permanent database + utils.MainnetFlag, + utils.SepoliaFlag, + utils.GoerliFlag, + utils.BlsyncApiFlag, + utils.BlsyncJWTSecretFlag, + } + app.Action = blsync + + if err := app.Run(os.Args); err != nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(1) + } +} + +var ( + stateProofFormat merkle.ProofFormat // requested multiproof format + execBlockIndex int // index of execution block root in proof.Values where proof.Format == stateProofFormat + finalizedBlockIndex int // index of finalized block root in proof.Values where proof.Format == stateProofFormat +) + +func blsync(ctx *cli.Context) error { + if !ctx.IsSet(utils.BeaconApiFlag.Name) { + utils.Fatalf("Beacon node light client API URL not specified") + } + stateProofFormat = merkle.NewIndexMapFormat().AddLeaf(params.BsiExecHead, nil).AddLeaf(params.BsiFinalBlock, nil) + var ( + stateIndexMap = merkle.ProofFormatIndexMap(stateProofFormat) + chainConfig = makeChainConfig(ctx) + customHeader = make(map[string]string) + ) + execBlockIndex = stateIndexMap[params.BsiExecHead] + finalizedBlockIndex = stateIndexMap[params.BsiFinalBlock] + + for _, s := range utils.SplitAndTrim(ctx.String(utils.BeaconApiHeaderFlag.Name)) { + kv := strings.Split(s, ":") + if len(kv) != 2 { + utils.Fatalf("Invalid custom API header entry: %s", s) + } + customHeader[strings.TrimSpace(kv[0])] = strings.TrimSpace(kv[1]) + } + + var ( + beaconApi = api.NewBeaconLightApi(ctx.String(utils.BeaconApiFlag.Name), customHeader) + db = memorydb.New() + threshold = ctx.Int(utils.BeaconThresholdFlag.Name) + committeeChain = light.NewCommitteeChain(db, chainConfig.Forks, threshold, !ctx.Bool(utils.BeaconNoFilterFlag.Name), light.BLSVerifier{}, &mclock.System{}, func() int64 { return time.Now().UnixNano() }) + checkpointStore = light.NewCheckpointStore(db, committeeChain) + headTracker = light.NewHeadTracker(committeeChain) + scheduler = request.NewScheduler() + ) + committeeChain.SetGenesisData(chainConfig.GenesisData) + + checkpointInit := sync.NewCheckpointInit(committeeChain, checkpointStore, chainConfig.Checkpoint) + forwardSync := sync.NewForwardUpdateSyncer(committeeChain) + headSync := sync.NewHeadSyncer(headTracker, committeeChain) + scheduler.RegisterModule(checkpointInit) + scheduler.RegisterModule(forwardSync) + scheduler.RegisterModule(headSync) + scheduler.AddTriggers(forwardSync, []*request.ModuleTrigger{&checkpointInit.InitTrigger, &forwardSync.NewUpdateTrigger, &headSync.SignedHeadTrigger}) + scheduler.AddTriggers(headSync, []*request.ModuleTrigger{&forwardSync.NewUpdateTrigger}) + + syncer := &execSyncer{ + api: beaconApi, + client: makeRPCClient(ctx), + execRootCache: lru.NewCache[common.Hash, common.Hash](1000), + } + headTracker.Subscribe(threshold, syncer.newHead) + scheduler.Start() + scheduler.RegisterServer(api.NewSyncServer(beaconApi)) + <-ctx.Done() + scheduler.Stop() + return nil +} + +func callNewPayloadV1(client *rpc.Client, block *ctypes.Block) (string, error) { + var resp engine.PayloadStatusV1 + ctx, cancel := context.WithTimeout(context.Background(), time.Second*5) + err := client.CallContext(ctx, &resp, "engine_newPayloadV1", *engine.BlockToExecutableData(block, nil).ExecutionPayload) + cancel() + return resp.Status, err +} + +func callForkchoiceUpdatedV1(client *rpc.Client, headHash, finalizedHash common.Hash) (string, error) { + var resp engine.ForkChoiceResponse + update := engine.ForkchoiceStateV1{ + HeadBlockHash: headHash, + SafeBlockHash: finalizedHash, + FinalizedBlockHash: finalizedHash, + } + ctx, cancel := context.WithTimeout(context.Background(), time.Second*5) + err := client.CallContext(ctx, &resp, "engine_forkchoiceUpdatedV1", update, nil) + cancel() + return resp.PayloadStatus.Status, err +} + +type execSyncer struct { + api *api.BeaconLightApi + sub *api.StateProofSub + client *rpc.Client + execRootCache *lru.Cache[common.Hash, common.Hash] // beacon block root -> execution block root +} + +// newHead fetches state proofs to determine the execution block root and calls +// the engine API if specified +func (e *execSyncer) newHead(signedHead types.SignedHead) { + head := signedHead.Header + log.Info("Received new beacon head", "slot", head.Slot, "blockRoot", head.Hash()) + block, err := e.api.GetExecutionPayload(head) + if err != nil { + log.Error("Error fetching execution payload from beacon API", "error", err) + return + } + blockRoot := block.Hash() + var finalizedExecRoot common.Hash + if e.sub == nil { + if sub, err := e.api.SubscribeStateProof(stateProofFormat, 0, 1); err == nil { + log.Info("Successfully created beacon state subscription") + e.sub = sub + } else { + log.Error("Failed to create beacon state subscription", "error", err) + return + } + } + proof, err := e.sub.Get(head.StateRoot) + if err == nil { + var ( + execBlockRoot = common.Hash(proof.Values[execBlockIndex]) + finalizedBeaconRoot = common.Hash(proof.Values[finalizedBlockIndex]) + beaconRoot = head.Hash() + ) + e.execRootCache.Add(beaconRoot, execBlockRoot) + if blockRoot != execBlockRoot { + log.Error("Execution payload block hash does not match value in beacon state", "expected", execBlockRoot, "got", block.Hash()) + return + } + if _, ok := e.execRootCache.Get(head.ParentRoot); !ok { + e.fetchExecRoots(head.ParentRoot) + } + finalizedExecRoot, _ = e.execRootCache.Get(finalizedBeaconRoot) + } else if err != api.ErrNotFound { + log.Error("Error fetching state proof from beacon API", "error", err) + } + if e.client == nil { // dry run, no engine API specified + log.Info("New execution block retrieved", "block number", block.NumberU64(), "block hash", blockRoot, "finalized block hash", finalizedExecRoot) + return + } + if status, err := callNewPayloadV1(e.client, block); err == nil { + log.Info("Successful NewPayload", "block number", block.NumberU64(), "block hash", blockRoot, "status", status) + } else { + log.Error("Failed NewPayload", "block number", block.NumberU64(), "block hash", blockRoot, "error", err) + } + if status, err := callForkchoiceUpdatedV1(e.client, blockRoot, finalizedExecRoot); err == nil { + log.Info("Successful ForkchoiceUpdated", "head", blockRoot, "finalized", finalizedExecRoot, "status", status) + } else { + log.Error("Failed ForkchoiceUpdated", "head", blockRoot, "finalized", finalizedExecRoot, "error", err) + } +} + +func (e *execSyncer) fetchExecRoots(blockRoot common.Hash) { + for maxFetch := 256; maxFetch > 0; maxFetch-- { + header, err := e.api.GetHeader(blockRoot) + if err != nil { + break + } + proof, err := e.sub.Get(header.StateRoot) + if err != nil { + // exit silently because we expect running into an error when parent is unknown + break + } + e.execRootCache.Add(header.Hash(), common.Hash(proof.Values[execBlockIndex])) + if _, ok := e.execRootCache.Get(header.ParentRoot); ok { + break + } + blockRoot = header.ParentRoot + } +} diff --git a/cmd/utils/flags.go b/cmd/utils/flags.go index b67671e03b..05fdced469 100644 --- a/cmd/utils/flags.go +++ b/cmd/utils/flags.go @@ -36,6 +36,7 @@ import ( "github.com/ethereum/go-ethereum/accounts" "github.com/ethereum/go-ethereum/accounts/keystore" + bparams "github.com/ethereum/go-ethereum/beacon/params" "github.com/ethereum/go-ethereum/common" "github.com/ethereum/go-ethereum/common/fdlimit" "github.com/ethereum/go-ethereum/common/hexutil" @@ -273,6 +274,58 @@ var ( Usage: "Manually specify the Cancun fork timestamp, overriding the bundled setting", Category: flags.EthCategory, } + // Beacon client light sync settings + BeaconApiFlag = &cli.StringFlag{ + Name: "beacon.api", + Usage: "Beacon node (CL) light client API URL (currently only supports LodeStar)", + Category: flags.BeaconCategory, + } + BeaconApiHeaderFlag = &cli.StringFlag{ + Name: "beacon.api.header", + Usage: "Remote beacon node API custom HTTP header fields (\"key:value,key:value\")", + Category: flags.BeaconCategory, + } + BeaconThresholdFlag = &cli.IntFlag{ + Name: "beacon.threshold", + Usage: "Beacon sync committee participation threshold", + Value: bparams.SyncCommitteeSupermajority, + Category: flags.BeaconCategory, + } + BeaconNoFilterFlag = &cli.BoolFlag{ + Name: "beacon.nofilter", + Usage: "Disable future slot signature filter", + Category: flags.BeaconCategory, + } + BeaconConfigFlag = &cli.StringFlag{ + Name: "beacon.config", + Usage: "Beacon chain config YAML file", + Category: flags.BeaconCategory, + } + BeaconGenesisRootFlag = &cli.StringFlag{ + Name: "beacon.genesis.gvroot", + Usage: "Beacon chain genesis validators root", + Category: flags.BeaconCategory, + } + BeaconGenesisTimeFlag = &cli.Uint64Flag{ + Name: "beacon.genesis.time", + Usage: "Beacon chain genesis time", + Category: flags.BeaconCategory, + } + BeaconCheckpointFlag = &cli.StringFlag{ + Name: "beacon.checkpoint", + Usage: "Beacon chain weak subjectivity checkpoint block hash", + Category: flags.BeaconCategory, + } + BlsyncApiFlag = &cli.StringFlag{ + Name: "blsync.engine.api", + Usage: "Target EL engine API URL", + Category: flags.BeaconCategory, + } + BlsyncJWTSecretFlag = &cli.StringFlag{ + Name: "blsync.jwtsecret", + Usage: "Path to a JWT secret to use for target engine API endpoint", + Category: flags.BeaconCategory, + } // Light server and client settings LightServeFlag = &cli.IntFlag{ Name: "light.serve", diff --git a/common/types.go b/common/types.go index 0b68a19dd3..b3397e1ec9 100644 --- a/common/types.go +++ b/common/types.go @@ -26,6 +26,7 @@ import ( "math/big" "math/rand" "reflect" + "strconv" "strings" "github.com/ethereum/go-ethereum/common/hexutil" @@ -429,3 +430,22 @@ func (ma *MixedcaseAddress) ValidChecksum() bool { func (ma *MixedcaseAddress) Original() string { return ma.original } + +type Decimal uint64 + +func isString(input []byte) bool { + return len(input) >= 2 && input[0] == '"' && input[len(input)-1] == '"' +} + +// UnmarshalJSON parses a hash in hex syntax. +func (d *Decimal) UnmarshalJSON(input []byte) error { + if !isString(input) { + return &json.UnmarshalTypeError{Value: "non-string", Type: reflect.TypeOf(uint64(0))} + } + if i, err := strconv.ParseInt(string(input[1:len(input)-1]), 10, 64); err == nil { + *d = Decimal(i) + return nil + } else { + return err + } +} diff --git a/go.mod b/go.mod index 68929fb8af..f515efbeca 100644 --- a/go.mod +++ b/go.mod @@ -18,6 +18,7 @@ require ( github.com/davecgh/go-spew v1.1.1 github.com/deckarep/golang-set/v2 v2.1.0 github.com/docker/docker v1.6.2 + github.com/donovanhide/eventsource v0.0.0-20210830082556-c59027999da0 github.com/dop251/goja v0.0.0-20230122112309-96b1610dd4f7 github.com/edsrzf/mmap-go v1.0.0 github.com/ethereum/c-kzg-4844 v0.1.0 @@ -49,9 +50,13 @@ require ( github.com/kylelemons/godebug v1.1.0 github.com/mattn/go-colorable v0.1.13 github.com/mattn/go-isatty v0.0.16 + github.com/minio/sha256-simd v1.0.0 github.com/naoina/toml v0.1.2-0.20170918210437-9fafd6967416 github.com/olekukonko/tablewriter v0.0.5 github.com/peterh/liner v1.1.1-0.20190123174540-a2c9a5303de7 + github.com/protolambda/bls12-381-util v0.0.0-20220416220906-d8552aa452c7 + github.com/protolambda/zrnt v0.30.0 + github.com/protolambda/ztyp v0.2.2 github.com/rs/cors v1.7.0 github.com/shirou/gopsutil v3.21.4-0.20210419000835-c7a38de76ee5+incompatible github.com/status-im/keycard-go v0.2.0 @@ -99,7 +104,9 @@ require ( github.com/go-sourcemap/sourcemap v2.1.3+incompatible // indirect github.com/gogo/protobuf v1.3.2 // indirect github.com/influxdata/line-protocol v0.0.0-20210311194329-9aa0e372d097 // indirect + github.com/kilic/bls12-381 v0.1.0 // indirect github.com/klauspost/compress v1.15.15 // indirect + github.com/klauspost/cpuid/v2 v2.0.4 // indirect github.com/kr/pretty v0.3.1 // indirect github.com/kr/text v0.2.0 // indirect github.com/mattn/go-runewidth v0.0.9 // indirect diff --git a/go.sum b/go.sum index 7f463184be..9b8a6bc724 100644 --- a/go.sum +++ b/go.sum @@ -114,6 +114,8 @@ github.com/dnaeon/go-vcr v1.2.0 h1:zHCHvJYTMh1N7xnV7zf1m1GPBF9Ad0Jk/whtQ1663qI= github.com/dnaeon/go-vcr v1.2.0/go.mod h1:R4UdLID7HZT3taECzJs4YgbbH6PIGXB6W/sc5OLb6RQ= github.com/docker/docker v1.6.2 h1:HlFGsy+9/xrgMmhmN+NGhCc5SHGJ7I+kHosRR1xc/aI= github.com/docker/docker v1.6.2/go.mod h1:eEKB0N0r5NX/I1kEveEz05bcu8tLC/8azJZsviup8Sk= +github.com/donovanhide/eventsource v0.0.0-20210830082556-c59027999da0 h1:C7t6eeMaEQVy6e8CarIhscYQlNmw5e3G36y7l7Y21Ao= +github.com/donovanhide/eventsource v0.0.0-20210830082556-c59027999da0/go.mod h1:56wL82FO0bfMU5RvfXoIwSOP2ggqqxT+tAfNEIyxuHw= github.com/dop251/goja v0.0.0-20211022113120-dc8c55024d06/go.mod h1:R9ET47fwRVRPZnOGvHxxhuZcbrMCuiqOz3Rlrh4KSnk= github.com/dop251/goja v0.0.0-20230122112309-96b1610dd4f7 h1:kgvzE5wLsLa7XKfV85VZl40QXaMCaeFtHpPwJ8fhotY= github.com/dop251/goja v0.0.0-20230122112309-96b1610dd4f7/go.mod h1:yRkwfj0CBpOGre+TwBsqPV0IH0Pk73e4PXJOeNDboGs= @@ -236,6 +238,7 @@ github.com/hashicorp/go-version v1.2.0/go.mod h1:fltr4n8CU8Ke44wwGCBoEymUuxUHl09 github.com/hashicorp/hcl v1.0.0/go.mod h1:E5yfLk+7swimpb2L/Alb/PJmXilQ/rhwaUYs4T20WEQ= github.com/holiman/bloomfilter/v2 v2.0.3 h1:73e0e/V0tCydx14a0SCYS/EWCxgwLZ18CZcZKVu0fao= github.com/holiman/bloomfilter/v2 v2.0.3/go.mod h1:zpoh+gs7qcpqrHr3dB55AMiJwo0iURXE7ZOP9L9hSkA= +github.com/holiman/uint256 v1.2.0/go.mod h1:y4ga/t+u+Xwd7CpDgZESaRcWy0I7XMlTMA25ApIH5Jw= github.com/holiman/uint256 v1.2.2-0.20230321075855-87b91420868c h1:DZfsyhDK1hnSS5lH8l+JggqzEleHteTYfutAiVlSUM8= github.com/holiman/uint256 v1.2.2-0.20230321075855-87b91420868c/go.mod h1:SC8Ryt4n+UBbPbIBKaG9zbbDlp4jOru9xFZmPzLUTxw= github.com/hpcloud/tail v1.0.0/go.mod h1:ab1qPbhIpdTxEkNHXyeSf5vhxWSCs/tWer42PpOxQnU= @@ -276,6 +279,8 @@ github.com/kataras/iris/v12 v12.1.8/go.mod h1:LMYy4VlP67TQ3Zgriz8RE2h2kMZV2SgMYb github.com/kataras/neffos v0.0.14/go.mod h1:8lqADm8PnbeFfL7CLXh1WHw53dG27MC3pgi2R1rmoTE= github.com/kataras/pio v0.0.2/go.mod h1:hAoW0t9UmXi4R5Oyq5Z4irTbaTsOemSrDGUtaTl7Dro= github.com/kataras/sitemap v0.0.5/go.mod h1:KY2eugMKiPwsJgx7+U103YZehfvNGOXURubcGyk0Bz8= +github.com/kilic/bls12-381 v0.1.0 h1:encrdjqKMEvabVQ7qYOKu1OvhqpK4s47wDYtNiPtlp4= +github.com/kilic/bls12-381 v0.1.0/go.mod h1:vDTTHJONJ6G+P2R74EhnyotQDTliQDnFEwhdmfzw1ig= github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8= github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck= github.com/klauspost/compress v1.8.2/go.mod h1:RyIbtBH6LamlWaDj8nUwkbUhJ87Yi3uG0guNDohfE1A= @@ -283,6 +288,8 @@ github.com/klauspost/compress v1.9.7/go.mod h1:RyIbtBH6LamlWaDj8nUwkbUhJ87Yi3uG0 github.com/klauspost/compress v1.15.15 h1:EF27CXIuDsYJ6mmvtBRlEuB2UVOqHG1tAXgZ7yIO+lw= github.com/klauspost/compress v1.15.15/go.mod h1:ZcK2JAFqKOpnBlxcLsJzYfrS9X1akm9fHZNnD9+Vo/4= github.com/klauspost/cpuid v1.2.1/go.mod h1:Pj4uuM528wm8OyEC2QMXAi2YiTZ96dNQPGgoMS4s3ek= +github.com/klauspost/cpuid/v2 v2.0.4 h1:g0I61F2K2DjRHz1cnxlkNSBIaePVoJIjjnHui8QHbiw= +github.com/klauspost/cpuid/v2 v2.0.4/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg= github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI= github.com/kr/pretty v0.3.0/go.mod h1:640gp4NfQd8pI5XOwp5fnNeVWj67G7CFk/SaSQn7NBk= @@ -324,6 +331,9 @@ github.com/matttproud/golang_protobuf_extensions v1.0.4 h1:mmDVorXM7PCGKw94cs5zk github.com/matttproud/golang_protobuf_extensions v1.0.4/go.mod h1:BSXmuO+STAnVfrANrmjBb36TMTDstsz7MSK+HVaYKv4= github.com/mediocregopher/radix/v3 v3.4.2/go.mod h1:8FL3F6UQRXHXIBSPUs5h0RybMF8i4n7wVopoX3x7Bv8= github.com/microcosm-cc/bluemonday v1.0.2/go.mod h1:iVP4YcDBq+n/5fb23BhYFvIMq/leAFZyRl6bYmGDlGc= +github.com/minio/sha256-simd v0.1.0/go.mod h1:2FMWW+8GMoPweT6+pI63m9YE3Lmw4J71hV56Chs1E/U= +github.com/minio/sha256-simd v1.0.0 h1:v1ta+49hkWZyvaKwrQB8elexRqm6Y0aMLjCNsrYxo6g= +github.com/minio/sha256-simd v1.0.0/go.mod h1:OuYzVNI5vcoYIAmbIvHPl3N3jUzVedXbKy5RFepssQM= github.com/mitchellh/go-homedir v1.1.0/go.mod h1:SfyaCUpYCn1Vlf4IUYiD9fPX4A5wJrkLzIz1N1q0pr0= github.com/mitchellh/mapstructure v1.1.2/go.mod h1:FVVH3fgwuzCH5S8UJGiWEs2h04kUh9fWfEaFds41c1Y= github.com/mitchellh/mapstructure v1.4.1 h1:CpVNEelQCZBooIPDn+AR3NpivK/TIKU8bDxdASFVQag= @@ -381,6 +391,14 @@ github.com/prometheus/common v0.39.0 h1:oOyhkDq05hPZKItWVBkJ6g6AtGxi+fy7F4JvUV8u github.com/prometheus/common v0.39.0/go.mod h1:6XBZ7lYdLCbkAVhwRsWTZn+IN5AB9F/NXd5w0BbEX0Y= github.com/prometheus/procfs v0.9.0 h1:wzCHvIvM5SxWqYvwgVL7yJY8Lz3PKn49KQtpgMYJfhI= github.com/prometheus/procfs v0.9.0/go.mod h1:+pB4zwohETzFnmlpe6yd2lSc+0/46IYZRB/chUwxUZY= +github.com/protolambda/bls12-381-util v0.0.0-20210720105258-a772f2aac13e/go.mod h1:MPZvj2Pr0N8/dXyTPS5REeg2sdLG7t8DRzC1rLv925w= +github.com/protolambda/bls12-381-util v0.0.0-20220416220906-d8552aa452c7 h1:cZC+usqsYgHtlBaGulVnZ1hfKAi8iWtujBnRLQE698c= +github.com/protolambda/bls12-381-util v0.0.0-20220416220906-d8552aa452c7/go.mod h1:IToEjHuttnUzwZI5KBSM/LOOW3qLbbrHOEfp3SbECGY= +github.com/protolambda/messagediff v1.4.0/go.mod h1:LboJp0EwIbJsePYpzh5Op/9G1/4mIztMRYzzwR0dR2M= +github.com/protolambda/zrnt v0.30.0 h1:pHEn69ZgaDFGpLGGYG1oD7DvYI7RDirbMBPfbC+8p4g= +github.com/protolambda/zrnt v0.30.0/go.mod h1:qcdX9CXFeVNCQK/q0nswpzhd+31RHMk2Ax/2lMsJ4Jw= +github.com/protolambda/ztyp v0.2.2 h1:rVcL3vBu9W/aV646zF6caLS/dyn9BN8NYiuJzicLNyY= +github.com/protolambda/ztyp v0.2.2/go.mod h1:9bYgKGqg3wJqT9ac1gI2hnVb0STQq7p/1lapqrqY1dU= github.com/rogpeppe/go-internal v1.6.1/go.mod h1:xXDCJY+GAPziupqXw64V24skbSoqbTEfhy4qGm1nDQc= github.com/rogpeppe/go-internal v1.8.1/go.mod h1:JeRgkft04UBgHMgCIwADu4Pn6Mtm5d4nPKWu0nJ5d+o= github.com/rogpeppe/go-internal v1.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8= @@ -538,6 +556,7 @@ golang.org/x/sys v0.0.0-20200519105757-fe76b779f299/go.mod h1:h1NjWce9XRLGQEsW7w golang.org/x/sys v0.0.0-20200814200057-3d37ad5750ed/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200826173525-f9321e4c35a6/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20201101102859-da207088b7d1/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210124154548-22da62e12c0c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210316164454-77fc1eacc6aa/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= diff --git a/internal/flags/categories.go b/internal/flags/categories.go index c2db6c6c1d..7b976e19a2 100644 --- a/internal/flags/categories.go +++ b/internal/flags/categories.go @@ -20,6 +20,7 @@ import "github.com/urfave/cli/v2" const ( EthCategory = "ETHEREUM" + BeaconCategory = "BEACON CHAIN" LightCategory = "LIGHT CLIENT" DevCategory = "DEVELOPER CHAIN" EthashCategory = "ETHASH" diff --git a/node/node.go b/node/node.go index e8494ac3b2..bc91c34ccb 100644 --- a/node/node.go +++ b/node/node.go @@ -338,15 +338,9 @@ func (n *Node) closeDataDir() { } } -// obtainJWTSecret loads the jwt-secret, either from the provided config, -// or from the default location. If neither of those are present, it generates -// a new secret and stores to the default location. -func (n *Node) obtainJWTSecret(cliParam string) ([]byte, error) { - fileName := cliParam - if len(fileName) == 0 { - // no path provided, use default - fileName = n.ResolvePath(datadirJWTKey) - } +// ObtainJWTSecret loads the jwt-secret from the provided config. If the file is not +// present, it generates a new secret and stores to the given location. +func ObtainJWTSecret(fileName string) ([]byte, error) { // try reading from file if data, err := os.ReadFile(fileName); err == nil { jwtSecret := common.FromHex(strings.TrimSpace(string(data))) @@ -372,6 +366,18 @@ func (n *Node) obtainJWTSecret(cliParam string) ([]byte, error) { return jwtSecret, nil } +// obtainJWTSecret loads the jwt-secret, either from the provided config, +// or from the default location. If neither of those are present, it generates +// a new secret and stores to the default location. +func (n *Node) obtainJWTSecret(cliParam string) ([]byte, error) { + fileName := cliParam + if len(fileName) == 0 { + // no path provided, use default + fileName = n.ResolvePath(datadirJWTKey) + } + return ObtainJWTSecret(fileName) +} + // startRPC is a helper method to configure all the various RPC endpoints during node // startup. It's not meant to be called at any time afterwards as it makes certain // assumptions about the state of the node.