diff --git a/specs/altair/light-client/p2p-interface.md b/specs/altair/light-client/p2p-interface.md new file mode 100644 index 0000000000..d1a19bc251 --- /dev/null +++ b/specs/altair/light-client/p2p-interface.md @@ -0,0 +1,53 @@ +# 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](../altair/p2p-interface.md). + +## Table of contents + + + + + +- [Messages](#messages) + - [GetLightClientSnapshot](#getlightclientsnapshot) + - [LightClientUpdate](#lightclientupdate) + + + + +### Messages + +#### GetLightClientSnapshot + +**Protocol ID:** `/eth2/beacon_chain/req/get_light_client_snapshot/1/` + +No Request Content. + +Response Content: + +``` +( + GetLightClientSnapshot +) +``` + +The `GetLightClientSnapshot` 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). diff --git a/specs/altair/light-client/sync-protocol.md b/specs/altair/light-client/sync-protocol.md index 28705803bf..0a6cbcc41e 100644 --- a/specs/altair/light-client/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. @@ -19,10 +19,17 @@ - [`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) + - [`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) +- [Sync protocol](#sync-protocol) + - [Initialization](#initialization) + - [Minimal light client update](#minimal-light-client-update) +- [Reorg mechanism](#reorg-mechanism) @@ -31,7 +38,7 @@ 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 @@ -85,8 +92,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 ``` @@ -95,6 +101,9 @@ class LightClientUpdate(Container): ```python class LightClientStore(Container): + time: uint64 + genesis_time: uint64 + genesis_validators_root: Root snapshot: LightClientSnapshot valid_updates: List[LightClientUpdate, MAX_VALID_LIGHT_CLIENT_UPDATES] ``` @@ -108,21 +117,38 @@ def get_subtree_index(generalized_index: GeneralizedIndex) -> uint64: return uint64(generalized_index % 2**(floorlog2(generalized_index))) ``` -## Light client state updates +### `get_light_client_store` -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_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=[], + ) +``` -#### `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 @@ -154,37 +180,51 @@ def validate_light_client_update(snapshot: LightClientSnapshot, update: LightCli ) # 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, current_slot, genesis_validators_root)` where `current_slot` is the current slot based on some local clock. + +```python +def on_light_client_update(store: LightClientStore, update: LightClientUpdate, current_slot: Slot) -> None: + validate_light_client_update(store, update) store.valid_updates.append(update) 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. @@ -194,7 +234,45 @@ def process_light_client_update(store: LightClientStore, update: LightClientUpda store.valid_updates = [] elif current_slot > store.snapshot.header.slot + LIGHT_CLIENT_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))) + apply_light_client_update( + store.snapshot, + max(store.valid_updates, key=lambda update: sum(update.sync_aggregate.sync_committee_bits)) + ) store.valid_updates = [] ``` + +## Server side handlers + +The server sends `LightClientUpdate` to its light client peers periodically. + +[TODO] + +```python +def get_light_client_update(state: BeaconState) -> LightClientUpdate: + # [TODO] + pass +``` + +## Sync protocol + +### Initialization + +1. The client sends `Status` message to the server to exchange the status information. +2. Instead of sending `BeaconBlocksByRange` in the beacon chain syncing, the client sends `GetLightClientSnapshot` request 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. + +Note that in this syncing mechanism, the server is trusted. + +### Minimal light client update + +In this minimal light client design, the light client only follows finality updates. + +[TODO] + +## Reorg mechanism + +[TODO] PR#2182 discussion diff --git a/tests/core/pyspec/eth2spec/test/altair/unittests/light_client/test_sync_protocol.py b/tests/core/pyspec/eth2spec/test/altair/unittests/light_client/test_sync_protocol.py index 4c9b98e0a8..a5f3385729 100644 --- a/tests/core/pyspec/eth2spec/test/altair/unittests/light_client/test_sync_protocol.py +++ b/tests/core/pyspec/eth2spec/test/altair/unittests/light_client/test_sync_protocol.py @@ -29,6 +29,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=[] ) @@ -52,6 +55,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 @@ -66,12 +73,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, state.slot) assert len(store.valid_updates) == 1 assert store.valid_updates[0] == update @@ -88,6 +95,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=[] ) @@ -118,6 +128,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) @@ -131,12 +145,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, state.slot) # snapshot has been updated assert len(store.valid_updates) == 0 @@ -153,8 +167,11 @@ 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=[] + valid_updates=[], ) # Change finality @@ -197,6 +214,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, @@ -204,12 +225,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, state.slot) # 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))