diff --git a/README.md b/README.md index b74102c30d..7b8f16972f 100644 --- a/README.md +++ b/README.md @@ -27,11 +27,14 @@ The current features are: ### Altair -* [Beacon chain changes](specs/altair/beacon-chain.md) +* [Beacon Chain changes](specs/altair/beacon-chain.md) * [Altair fork](specs/altair/fork.md) * [Light client sync protocol](specs/altair/sync-protocol.md) * [Honest Validator guide changes](specs/altair/validator.md) * [P2P Networking](specs/altair/p2p-interface.md) +* [Light Client](specs/altair/light-client/README.md) - In active R&D + * [Sync Protocol](specs/altair/light-client/sync-protocol.md) + * [P2P Networking](specs/altair/light-client/p2p-interface.md) ### Merge diff --git a/setup.py b/setup.py index 0bdf04b55b..bd55f85e55 100644 --- a/setup.py +++ b/setup.py @@ -608,7 +608,7 @@ def combine_constants(old_constants: Dict[str, str], new_constants: Dict[str, st 'Bytes1', 'Bytes4', 'Bytes20', 'Bytes32', 'Bytes48', 'Bytes96', 'Bitlist', 'Bitvector', 'uint8', 'uint16', 'uint32', 'uint64', 'uint128', 'uint256', 'bytes', 'byte', 'ByteList', 'ByteVector', - 'Dict', 'dict', 'field', 'ceillog2', 'floorlog2', 'Set', + 'Dict', 'dict', 'field', 'ceillog2', 'floorlog2', 'Set', 'set' ] @@ -728,7 +728,7 @@ def finalize_options(self): specs/altair/fork.md specs/altair/validator.md specs/altair/p2p-interface.md - specs/altair/sync-protocol.md + specs/altair/light-client/sync-protocol.md """ elif self.spec_fork == MERGE: self.md_doc_paths = """ diff --git a/specs/altair/light-client/README.md b/specs/altair/light-client/README.md new file mode 100644 index 0000000000..8f584390fe --- /dev/null +++ b/specs/altair/light-client/README.md @@ -0,0 +1,26 @@ +## Table of contents + + + + + +- [Ethereum 2.0 Altair - Beacon chain light client](#ethereum-20-altair---beacon-chain-light-client) + - [Specs](#specs) + + + + +# Ethereum 2.0 Altair - Beacon chain light client + +The beacon chain light client protocol is an extra protocol for light clients and servers to communicate. +We expect the beacon nodes that fully sync and verify the latest beacon state to act as servers +while the light clients only have to download a partial of the beacon state from the servers. + +In the current simple design, light client only sync to the latest finalized beacon chain head +so there should be no reorganization. +The reorganizable light client design is still in active R&D. + +## Specs + +- [P2P Networking](./p2p-interface.md): the Req/Resp message formats for light client communications +- [Sync Protocol](./sync-protocol.md): the detailed sync protocol diff --git a/specs/altair/light-client/p2p-interface.md b/specs/altair/light-client/p2p-interface.md new file mode 100644 index 0000000000..30a419efc7 --- /dev/null +++ b/specs/altair/light-client/p2p-interface.md @@ -0,0 +1,61 @@ +# Ethereum Altair Light Client P2P Interface + +**Notice**: This document is a work-in-progress for researchers and implementers. + +This document contains the networking specification for [minimal light client](./sync-protocol.md). +This document should be viewed as a patch to the [Altair networking specification](./../p2p-interface.md). + +## Table of contents + + + + + +- [The Req/Resp domain](#the-reqresp-domain) + - [Messages](#messages) + - [GetLightClientSnapshot](#getlightclientsnapshot) + - [LightClientUpdate](#lightclientupdate) +- [Server discovery](#server-discovery) + + + + +## The Req/Resp domain + +### Messages + +#### GetLightClientSnapshot + +**Protocol ID:** `/eth2/beacon_chain/req/get_light_client_snapshot/1/` + +No Request Content. + +Response Content: + +``` +( + LightClientSnapshot +) +``` + +The `LightClientSnapshot` SSZ container defined in [light client sync protocol](./sync-protocol.md#lightclientsnapshot). + +#### LightClientUpdate + +**Protocol ID:** `/eth2/beacon_chain/req/light_client_update/1/` + +Request Content: + +``` +( + LightClientUpdate +) +``` + +No Response Content. + +The `LightClientUpdate` SSZ container defined in [light client sync protocol](./sync-protocol.md#lightclientupdate). + +## Server discovery + +[TODO] diff --git a/specs/altair/sync-protocol.md b/specs/altair/light-client/sync-protocol.md similarity index 51% rename from specs/altair/sync-protocol.md rename to specs/altair/light-client/sync-protocol.md index 784ea63b2c..7de95fb579 100644 --- a/specs/altair/sync-protocol.md +++ b/specs/altair/light-client/sync-protocol.md @@ -1,4 +1,4 @@ -# Minimal Light Client +# Ethereum Altair Minimal Light Client **Notice**: This document is a work-in-progress for researchers and implementers. @@ -9,6 +9,11 @@ - [Introduction](#introduction) +- [Sync protocol](#sync-protocol) + - [Initialization](#initialization) + - [Minimal light client update](#minimal-light-client-update) + - [Server side](#server-side) + - [Client side](#client-side) - [Constants](#constants) - [Configuration](#configuration) - [Misc](#misc) @@ -18,10 +23,16 @@ - [`LightClientStore`](#lightclientstore) - [Helper functions](#helper-functions) - [`get_subtree_index`](#get_subtree_index) -- [Light client state updates](#light-client-state-updates) - - [`validate_light_client_update`](#validate_light_client_update) - - [`apply_light_client_update`](#apply_light_client_update) - - [`process_light_client_update`](#process_light_client_update) + - [`get_light_client_store`](#get_light_client_store) + - [`get_light_client_slots_since_genesis`](#get_light_client_slots_since_genesis) + - [`get_light_client_current_slot`](#get_light_client_current_slot) + - [`validate_light_client_update`](#validate_light_client_update) + - [`apply_light_client_update`](#apply_light_client_update) +- [Client side handlers](#client-side-handlers) + - [`on_light_client_tick`](#on_light_client_tick) + - [`on_light_client_update`](#on_light_client_update) +- [Server side handlers](#server-side-handlers) +- [Reorg mechanism](#reorg-mechanism) @@ -30,11 +41,36 @@ Eth2 is designed to be light client friendly for constrained environments to access Eth2 with reasonable safety and liveness. -Such environments include resource-constrained devices (e.g. phones for trust-minimised wallets) +Such environments include resource-constrained devices (e.g. phones for trust-minimized wallets) and metered VMs (e.g. blockchain VMs for cross-chain bridges). This document suggests a minimal light client design for the beacon chain that -uses sync committees introduced in [this beacon chain extension](./beacon-chain.md). +uses sync committees introduced in [this beacon chain extension](./../beacon-chain.md). + +## Sync protocol + +### Initialization + +1. The client sends [`Status` message](./../../phase0/p2p-interface.md#status) to the server to exchange the status information. +2. Instead of sending [`BeaconBlocksByRange` request](./../../phase0/p2p-interface.md#beaconblocksbyrange) in the beacon chain syncing, the client sends [`GetLightClientSnapshot` request](./p2p-interface.md#getlightclientsnapshot) to the server. +3. The server responds with the `LightClientSnapshot` object of the finalized beacon chain head. +4. The client would: + 1. check if the hash tree root of the given `header` matches the `finalized_root` in the server's `Status` message. + 2. check if the given response is valid for client to call `get_light_client_store` function to get the initial `store: LightClientStore`. + - If it's invalid, disconnect from the server; otherwise, keep syncing. + +### Minimal light client update + +In this minimal light client design, the light client only follows finality updates. + +#### Server side + +- Whenever `state.finalized_checkpoint` is changed, call `get_light_client_update` to generate the `LightClientUpdate` and then send to its light clients. + +#### Client side + +- `on_light_client_tick(store, time)` whenever `time > store.time` where `time` is the current Unix time +- `on_light_client_update(store, update)` whenever a block `update: LightClientUpdate` is received ## Constants @@ -77,8 +113,7 @@ class LightClientUpdate(Container): finality_header: BeaconBlockHeader finality_branch: Vector[Bytes32, floorlog2(FINALIZED_ROOT_INDEX)] # Sync committee aggregate signature - sync_committee_bits: Bitvector[SYNC_COMMITTEE_SIZE] - sync_committee_signature: BLSSignature + sync_aggregate: SyncAggregate # Fork version for the aggregate signature fork_version: Version ``` @@ -87,9 +122,12 @@ class LightClientUpdate(Container): ```python @dataclass -class LightClientStore(object): +class LightClientStore: + time: uint64 + genesis_time: uint64 + genesis_validators_root: Root snapshot: LightClientSnapshot - valid_updates: Set[LightClientUpdate] + valid_updates: Set[LightClientUpdate] = field(default_factory=set) ``` ## Helper functions @@ -101,22 +139,52 @@ def get_subtree_index(generalized_index: GeneralizedIndex) -> uint64: return uint64(generalized_index % 2**(floorlog2(generalized_index))) ``` -## Light client state updates +### `get_light_client_store` + +```python +def get_light_client_store(snapshot: LightClientSnapshot, + genesis_time: uint64, genesis_validators_root: Root) -> LightClientStore: + return LightClientStore( + time=uint64(genesis_time + SECONDS_PER_SLOT * snapshot.header.slot), + genesis_time=genesis_time, + genesis_validators_root=genesis_validators_root, + snapshot=snapshot, + valid_updates=set(), + ) +``` + +### `get_light_client_slots_since_genesis` + +```python +def get_light_client_slots_since_genesis(store: LightClientStore) -> int: + return (store.time - store.genesis_time) // SECONDS_PER_SLOT +``` + +### `get_light_client_current_slot` -A light client maintains its state in a `store` object of type `LightClientStore` and receives `update` objects of type `LightClientUpdate`. Every `update` triggers `process_light_client_update(store, update, current_slot)` where `current_slot` is the current slot based on some local clock. +```python +def get_light_client_current_slot(store: LightClientStore) -> Slot: + return Slot(GENESIS_SLOT + get_light_client_slots_since_genesis(store)) +``` -#### `validate_light_client_update` +### `validate_light_client_update` ```python -def validate_light_client_update(snapshot: LightClientSnapshot, - update: LightClientUpdate, - genesis_validators_root: Root) -> None: +def validate_light_client_update(store: LightClientStore, update: LightClientUpdate) -> None: + snapshot = store.snapshot + # Verify update slot is larger than snapshot slot assert update.header.slot > snapshot.header.slot + # Verify time + update_time = uint64(store.genesis_time + SECONDS_PER_SLOT * update.header.slot) + assert store.time >= update_time + # Verify update does not skip a sync committee period - snapshot_period = compute_epoch_at_slot(snapshot.header.slot) // EPOCHS_PER_SYNC_COMMITTEE_PERIOD - update_period = compute_epoch_at_slot(update.header.slot) // EPOCHS_PER_SYNC_COMMITTEE_PERIOD + snapshot_epoch = compute_epoch_at_slot(snapshot.header.slot) + update_epoch = compute_epoch_at_slot(update.header.slot) + snapshot_period = compute_sync_committee_period(snapshot_epoch) + update_period = compute_sync_committee_period(update_epoch) assert update_period in (snapshot_period, snapshot_period + 1) # Verify update header root is the finalized root of the finality header, if specified @@ -148,38 +216,52 @@ def validate_light_client_update(snapshot: LightClientSnapshot, ) # Verify sync committee has sufficient participants - assert sum(update.sync_committee_bits) >= MIN_SYNC_COMMITTEE_PARTICIPANTS + assert sum(update.sync_aggregate.sync_committee_bits) >= MIN_SYNC_COMMITTEE_PARTICIPANTS # Verify sync committee aggregate signature - participant_pubkeys = [pubkey for (bit, pubkey) in zip(update.sync_committee_bits, sync_committee.pubkeys) if bit] - domain = compute_domain(DOMAIN_SYNC_COMMITTEE, update.fork_version, genesis_validators_root) + participant_pubkeys = [pubkey for (bit, pubkey) + in zip(update.sync_aggregate.sync_committee_bits, sync_committee.pubkeys) if bit] + domain = compute_domain(DOMAIN_SYNC_COMMITTEE, update.fork_version, store.genesis_validators_root) signing_root = compute_signing_root(signed_header, domain) - assert bls.FastAggregateVerify(participant_pubkeys, signing_root, update.sync_committee_signature) + assert bls.FastAggregateVerify(participant_pubkeys, signing_root, update.sync_aggregate.sync_committee_signature) ``` -#### `apply_light_client_update` +### `apply_light_client_update` ```python def apply_light_client_update(snapshot: LightClientSnapshot, update: LightClientUpdate) -> None: - snapshot_period = compute_epoch_at_slot(snapshot.header.slot) // EPOCHS_PER_SYNC_COMMITTEE_PERIOD - update_period = compute_epoch_at_slot(update.header.slot) // EPOCHS_PER_SYNC_COMMITTEE_PERIOD + snapshot_epoch = compute_epoch_at_slot(snapshot.header.slot) + update_epoch = compute_epoch_at_slot(update.header.slot) + snapshot_period = compute_sync_committee_period(snapshot_epoch) + update_period = compute_sync_committee_period(update_epoch) if update_period == snapshot_period + 1: snapshot.current_sync_committee = snapshot.next_sync_committee snapshot.next_sync_committee = update.next_sync_committee snapshot.header = update.header ``` -#### `process_light_client_update` +## Client side handlers + +### `on_light_client_tick` ```python -def process_light_client_update(store: LightClientStore, update: LightClientUpdate, current_slot: Slot, - genesis_validators_root: Root) -> None: - validate_light_client_update(store.snapshot, update, genesis_validators_root) +def on_light_client_tick(store: LightClientStore, time: uint64) -> None: + # update store time + store.time = time +``` + +### `on_light_client_update` + +A light client maintains its state in a `store` object of type `LightClientStore` and receives `update` objects of type `LightClientUpdate`. Every `update` triggers `on_light_client_update(store, update)`. + +```python +def on_light_client_update(store: LightClientStore, update: LightClientUpdate) -> None: + validate_light_client_update(store, update) store.valid_updates.add(update) update_timeout = SLOTS_PER_EPOCH * EPOCHS_PER_SYNC_COMMITTEE_PERIOD if ( - sum(update.sync_committee_bits) * 3 >= len(update.sync_committee_bits) * 2 + sum(update.sync_aggregate.sync_committee_bits) * 3 >= len(update.sync_aggregate.sync_committee_bits) * 2 and update.finality_header != BeaconBlockHeader() ): # Apply update if (1) 2/3 quorum is reached and (2) we have a finality proof. @@ -187,9 +269,24 @@ def process_light_client_update(store: LightClientStore, update: LightClientUpda # It may be changed to re-organizable light client design. See the on-going issue eth2.0-specs#2182. apply_light_client_update(store.snapshot, update) store.valid_updates = set() - elif current_slot > store.snapshot.header.slot + update_timeout: + elif get_light_client_current_slot(store) > store.snapshot.header.slot + update_timeout: # Forced best update when the update timeout has elapsed apply_light_client_update(store.snapshot, - max(store.valid_updates, key=lambda update: sum(update.sync_committee_bits))) + max(store.valid_updates, + key=lambda update: sum(update.sync_aggregate.sync_committee_bits))) store.valid_updates = set() ``` + +## Server side handlers + +[TODO] + +```python +def get_light_client_update(state: BeaconState) -> LightClientUpdate: + # [TODO] + pass +``` + +## Reorg mechanism + +[TODO] PR#2182 discussion diff --git a/tests/core/pyspec/eth2spec/test/altair/unittests/__init__.py b/tests/core/pyspec/eth2spec/test/altair/unittests/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/tests/core/pyspec/eth2spec/test/altair/unittests/light_client/__init__.py b/tests/core/pyspec/eth2spec/test/altair/unittests/light_client/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/tests/core/pyspec/eth2spec/test/altair/unittests/test_helpers.py b/tests/core/pyspec/eth2spec/test/altair/unittests/light_client/test_helpers.py similarity index 100% rename from tests/core/pyspec/eth2spec/test/altair/unittests/test_helpers.py rename to tests/core/pyspec/eth2spec/test/altair/unittests/light_client/test_helpers.py diff --git a/tests/core/pyspec/eth2spec/test/altair/unittests/test_sync_protocol.py b/tests/core/pyspec/eth2spec/test/altair/unittests/light_client/test_sync_protocol.py similarity index 88% rename from tests/core/pyspec/eth2spec/test/altair/unittests/test_sync_protocol.py rename to tests/core/pyspec/eth2spec/test/altair/unittests/light_client/test_sync_protocol.py index 554cebda85..a1ff6d9dd3 100644 --- a/tests/core/pyspec/eth2spec/test/altair/unittests/test_sync_protocol.py +++ b/tests/core/pyspec/eth2spec/test/altair/unittests/light_client/test_sync_protocol.py @@ -31,6 +31,9 @@ def test_process_light_client_update_not_updated(spec, state): next_sync_committee=state.next_sync_committee, ) store = spec.LightClientStore( + time=state.genesis_time, + genesis_time=state.genesis_time, + genesis_validators_root=state.genesis_validators_root, snapshot=pre_snapshot, valid_updates=set(), ) @@ -55,6 +58,10 @@ def test_process_light_client_update_not_updated(spec, state): block.slot, committee, ) + sync_aggregate = spec.SyncAggregate( + sync_committee_bits=sync_committee_bits, + sync_committee_signature=sync_committee_signature, + ) next_sync_committee_branch = [spec.Bytes32() for _ in range(spec.floorlog2(spec.NEXT_SYNC_COMMITTEE_INDEX))] # Ensure that finality checkpoint is genesis @@ -69,12 +76,12 @@ def test_process_light_client_update_not_updated(spec, state): next_sync_committee_branch=next_sync_committee_branch, finality_header=finality_header, finality_branch=finality_branch, - sync_committee_bits=sync_committee_bits, - sync_committee_signature=sync_committee_signature, + sync_aggregate=sync_aggregate, fork_version=state.fork.current_version, ) - spec.process_light_client_update(store, update, state.slot, state.genesis_validators_root) + spec.on_light_client_tick(store, store.genesis_time + spec.SECONDS_PER_SLOT * update.header.slot) + spec.on_light_client_update(store, update) assert len(store.valid_updates) == 1 assert store.valid_updates.pop() == update @@ -91,6 +98,9 @@ def test_process_light_client_update_timeout(spec, state): next_sync_committee=state.next_sync_committee, ) store = spec.LightClientStore( + time=state.genesis_time, + genesis_time=state.genesis_time, + genesis_validators_root=state.genesis_validators_root, snapshot=pre_snapshot, valid_updates=set(), ) @@ -122,6 +132,10 @@ def test_process_light_client_update_timeout(spec, state): committee, block_root=spec.Root(block_header.hash_tree_root()), ) + sync_aggregate = spec.SyncAggregate( + sync_committee_bits=sync_committee_bits, + sync_committee_signature=sync_committee_signature, + ) # Sync committee is updated next_sync_committee_branch = build_proof(state.get_backing(), spec.NEXT_SYNC_COMMITTEE_INDEX) @@ -135,12 +149,12 @@ def test_process_light_client_update_timeout(spec, state): next_sync_committee_branch=next_sync_committee_branch, finality_header=finality_header, finality_branch=finality_branch, - sync_committee_bits=sync_committee_bits, - sync_committee_signature=sync_committee_signature, + sync_aggregate=sync_aggregate, fork_version=state.fork.current_version, ) - spec.process_light_client_update(store, update, state.slot, state.genesis_validators_root) + spec.on_light_client_tick(store, store.genesis_time + spec.SECONDS_PER_SLOT * update.header.slot) + spec.on_light_client_update(store, update) # snapshot has been updated assert len(store.valid_updates) == 0 @@ -157,6 +171,9 @@ def test_process_light_client_update_finality_updated(spec, state): next_sync_committee=state.next_sync_committee, ) store = spec.LightClientStore( + time=state.genesis_time, + genesis_time=state.genesis_time, + genesis_validators_root=state.genesis_validators_root, snapshot=pre_snapshot, valid_updates=set(), ) @@ -202,6 +219,10 @@ def test_process_light_client_update_finality_updated(spec, state): committee, block_root=spec.Root(block_header.hash_tree_root()), ) + sync_aggregate = spec.SyncAggregate( + sync_committee_bits=sync_committee_bits, + sync_committee_signature=sync_committee_signature, + ) update = spec.LightClientUpdate( header=finalized_block_header, @@ -209,12 +230,12 @@ def test_process_light_client_update_finality_updated(spec, state): next_sync_committee_branch=next_sync_committee_branch, finality_header=block_header, # block_header is the signed header finality_branch=finality_branch, - sync_committee_bits=sync_committee_bits, - sync_committee_signature=sync_committee_signature, + sync_aggregate=sync_aggregate, fork_version=state.fork.current_version, ) - spec.process_light_client_update(store, update, state.slot, state.genesis_validators_root) + spec.on_light_client_tick(store, store.genesis_time + spec.SECONDS_PER_SLOT * update.header.slot) + spec.on_light_client_update(store, update) # snapshot has been updated assert len(store.valid_updates) == 0 diff --git a/tests/core/pyspec/eth2spec/utils/ssz/ssz_impl.py b/tests/core/pyspec/eth2spec/utils/ssz/ssz_impl.py index 65808038ea..c44ab01ad7 100644 --- a/tests/core/pyspec/eth2spec/utils/ssz/ssz_impl.py +++ b/tests/core/pyspec/eth2spec/utils/ssz/ssz_impl.py @@ -3,6 +3,7 @@ from remerkleable.basic import uint from remerkleable.core import View from remerkleable.byte_arrays import Bytes32 +from remerkleable.tree import gindex_bit_iter def serialize(obj: View) -> bytes: @@ -23,3 +24,23 @@ def uint_to_bytes(n: uint) -> bytes: # Helper method for typing copies, and avoiding a example_input.copy() method call, instead of copy(example_input) def copy(obj: V) -> V: return obj.copy() + + +def build_proof(anchor, leaf_index): + if leaf_index <= 1: + return [] # Nothing to prove / invalid index + node = anchor + proof = [] + # Walk down, top to bottom to the leaf + bit_iter, _ = gindex_bit_iter(leaf_index) + for bit in bit_iter: + # Always take the opposite hand for the proof. + # 1 = right as leaf, thus get left + if bit: + proof.append(node.get_left().merkle_root()) + node = node.get_right() + else: + proof.append(node.get_right().merkle_root()) + node = node.get_left() + + return list(reversed(proof))