Altair Light Client -- Sync Protocol
Notice: This document is a work-in-progress for researchers and implementers.
Table of contents
Introduction
The beacon chain is designed to be light client friendly for constrained environments to
access Ethereum with reasonable safety and liveness.
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.
Additional documents describe how the light client sync protocol can be used:
- Full node
- Light client
- Networking
Custom types
Name |
SSZ equivalent |
Description |
FinalityBranch |
Vector[Bytes32, floorlog2(FINALIZED_ROOT_GINDEX)] |
Merkle branch of finalized_checkpoint.root within BeaconState |
CurrentSyncCommitteeBranch |
Vector[Bytes32, floorlog2(CURRENT_SYNC_COMMITTEE_GINDEX)] |
Merkle branch of current_sync_committee within BeaconState |
NextSyncCommitteeBranch |
Vector[Bytes32, floorlog2(NEXT_SYNC_COMMITTEE_GINDEX)] |
Merkle branch of next_sync_committee within BeaconState |
Constants
Name |
Value |
FINALIZED_ROOT_GINDEX |
get_generalized_index(BeaconState, 'finalized_checkpoint', 'root') (= 105) |
CURRENT_SYNC_COMMITTEE_GINDEX |
get_generalized_index(BeaconState, 'current_sync_committee') (= 54) |
NEXT_SYNC_COMMITTEE_GINDEX |
get_generalized_index(BeaconState, 'next_sync_committee') (= 55) |
Preset
Misc
Name |
Value |
Unit |
Duration |
MIN_SYNC_COMMITTEE_PARTICIPANTS |
1 |
validators |
|
UPDATE_TIMEOUT |
SLOTS_PER_EPOCH * EPOCHS_PER_SYNC_COMMITTEE_PERIOD |
slots |
~27.3 hours |
Containers
| class LightClientHeader(Container):
# Beacon block header
beacon: BeaconBlockHeader
|
Future upgrades may introduce additional fields to this structure, and validate them by extending is_valid_light_client_header
.
LightClientBootstrap
| class LightClientBootstrap(Container):
# Header matching the requested beacon block root
header: LightClientHeader
# Current sync committee corresponding to `header.beacon.state_root`
current_sync_committee: SyncCommittee
current_sync_committee_branch: CurrentSyncCommitteeBranch
|
LightClientUpdate
| class LightClientUpdate(Container):
# Header attested to by the sync committee
attested_header: LightClientHeader
# Next sync committee corresponding to `attested_header.beacon.state_root`
next_sync_committee: SyncCommittee
next_sync_committee_branch: NextSyncCommitteeBranch
# Finalized header corresponding to `attested_header.beacon.state_root`
finalized_header: LightClientHeader
finality_branch: FinalityBranch
# Sync committee aggregate signature
sync_aggregate: SyncAggregate
# Slot at which the aggregate signature was created (untrusted)
signature_slot: Slot
|
LightClientFinalityUpdate
| class LightClientFinalityUpdate(Container):
# Header attested to by the sync committee
attested_header: LightClientHeader
# Finalized header corresponding to `attested_header.beacon.state_root`
finalized_header: LightClientHeader
finality_branch: FinalityBranch
# Sync committee aggregate signature
sync_aggregate: SyncAggregate
# Slot at which the aggregate signature was created (untrusted)
signature_slot: Slot
|
LightClientOptimisticUpdate
| class LightClientOptimisticUpdate(Container):
# Header attested to by the sync committee
attested_header: LightClientHeader
# Sync committee aggregate signature
sync_aggregate: SyncAggregate
# Slot at which the aggregate signature was created (untrusted)
signature_slot: Slot
|
LightClientStore
| @dataclass
class LightClientStore(object):
# Header that is finalized
finalized_header: LightClientHeader
# Sync committees corresponding to the finalized header
current_sync_committee: SyncCommittee
next_sync_committee: SyncCommittee
# Best available header to switch finalized head to if we see nothing else
best_valid_update: Optional[LightClientUpdate]
# Most recent available reasonably-safe header
optimistic_header: LightClientHeader
# Max number of active participants in a sync committee (used to calculate safety threshold)
previous_max_active_participants: uint64
current_max_active_participants: uint64
|
Helper functions
finalized_root_gindex_at_slot
| def finalized_root_gindex_at_slot(slot: Slot) -> GeneralizedIndex:
# pylint: disable=unused-argument
return FINALIZED_ROOT_GINDEX
|
current_sync_committee_gindex_at_slot
| def current_sync_committee_gindex_at_slot(slot: Slot) -> GeneralizedIndex:
# pylint: disable=unused-argument
return CURRENT_SYNC_COMMITTEE_GINDEX
|
next_sync_committee_gindex_at_slot
| def next_sync_committee_gindex_at_slot(slot: Slot) -> GeneralizedIndex:
# pylint: disable=unused-argument
return NEXT_SYNC_COMMITTEE_GINDEX
|
| def is_valid_light_client_header(header: LightClientHeader) -> bool:
# pylint: disable=unused-argument
return True
|
is_sync_committee_update
| def is_sync_committee_update(update: LightClientUpdate) -> bool:
return update.next_sync_committee_branch != NextSyncCommitteeBranch()
|
is_finality_update
| def is_finality_update(update: LightClientUpdate) -> bool:
return update.finality_branch != FinalityBranch()
|
is_better_update
| def is_better_update(new_update: LightClientUpdate, old_update: LightClientUpdate) -> bool:
# Compare supermajority (> 2/3) sync committee participation
max_active_participants = len(new_update.sync_aggregate.sync_committee_bits)
new_num_active_participants = sum(new_update.sync_aggregate.sync_committee_bits)
old_num_active_participants = sum(old_update.sync_aggregate.sync_committee_bits)
new_has_supermajority = new_num_active_participants * 3 >= max_active_participants * 2
old_has_supermajority = old_num_active_participants * 3 >= max_active_participants * 2
if new_has_supermajority != old_has_supermajority:
return new_has_supermajority
if not new_has_supermajority and new_num_active_participants != old_num_active_participants:
return new_num_active_participants > old_num_active_participants
# Compare presence of relevant sync committee
new_has_relevant_sync_committee = is_sync_committee_update(new_update) and (
compute_sync_committee_period_at_slot(new_update.attested_header.beacon.slot)
== compute_sync_committee_period_at_slot(new_update.signature_slot)
)
old_has_relevant_sync_committee = is_sync_committee_update(old_update) and (
compute_sync_committee_period_at_slot(old_update.attested_header.beacon.slot)
== compute_sync_committee_period_at_slot(old_update.signature_slot)
)
if new_has_relevant_sync_committee != old_has_relevant_sync_committee:
return new_has_relevant_sync_committee
# Compare indication of any finality
new_has_finality = is_finality_update(new_update)
old_has_finality = is_finality_update(old_update)
if new_has_finality != old_has_finality:
return new_has_finality
# Compare sync committee finality
if new_has_finality:
new_has_sync_committee_finality = (
compute_sync_committee_period_at_slot(new_update.finalized_header.beacon.slot)
== compute_sync_committee_period_at_slot(new_update.attested_header.beacon.slot)
)
old_has_sync_committee_finality = (
compute_sync_committee_period_at_slot(old_update.finalized_header.beacon.slot)
== compute_sync_committee_period_at_slot(old_update.attested_header.beacon.slot)
)
if new_has_sync_committee_finality != old_has_sync_committee_finality:
return new_has_sync_committee_finality
# Tiebreaker 1: Sync committee participation beyond supermajority
if new_num_active_participants != old_num_active_participants:
return new_num_active_participants > old_num_active_participants
# Tiebreaker 2: Prefer older data (fewer changes to best)
if new_update.attested_header.beacon.slot != old_update.attested_header.beacon.slot:
return new_update.attested_header.beacon.slot < old_update.attested_header.beacon.slot
return new_update.signature_slot < old_update.signature_slot
|
is_next_sync_committee_known
| def is_next_sync_committee_known(store: LightClientStore) -> bool:
return store.next_sync_committee != SyncCommittee()
|
get_safety_threshold
| def get_safety_threshold(store: LightClientStore) -> uint64:
return max(
store.previous_max_active_participants,
store.current_max_active_participants,
) // 2
|
get_subtree_index
| def get_subtree_index(generalized_index: GeneralizedIndex) -> uint64:
return uint64(generalized_index % 2**(floorlog2(generalized_index)))
|
is_valid_normalized_merkle_branch
| def is_valid_normalized_merkle_branch(leaf: Bytes32,
branch: Sequence[Bytes32],
gindex: GeneralizedIndex,
root: Root) -> bool:
depth = floorlog2(gindex)
index = get_subtree_index(gindex)
num_extra = len(branch) - depth
for i in range(num_extra):
if branch[i] != Bytes32():
return False
return is_valid_merkle_branch(leaf, branch[num_extra:], depth, index, root)
|
compute_sync_committee_period_at_slot
| def compute_sync_committee_period_at_slot(slot: Slot) -> uint64:
return compute_sync_committee_period(compute_epoch_at_slot(slot))
|
Light client initialization
A light client maintains its state in a store
object of type LightClientStore
. initialize_light_client_store
initializes a new store
with a received LightClientBootstrap
derived from a given trusted_block_root
.
initialize_light_client_store
| def initialize_light_client_store(trusted_block_root: Root,
bootstrap: LightClientBootstrap) -> LightClientStore:
assert is_valid_light_client_header(bootstrap.header)
assert hash_tree_root(bootstrap.header.beacon) == trusted_block_root
assert is_valid_normalized_merkle_branch(
leaf=hash_tree_root(bootstrap.current_sync_committee),
branch=bootstrap.current_sync_committee_branch,
gindex=current_sync_committee_gindex_at_slot(bootstrap.header.beacon.slot),
root=bootstrap.header.beacon.state_root,
)
return LightClientStore(
finalized_header=bootstrap.header,
current_sync_committee=bootstrap.current_sync_committee,
next_sync_committee=SyncCommittee(),
best_valid_update=None,
optimistic_header=bootstrap.header,
previous_max_active_participants=0,
current_max_active_participants=0,
)
|
Light client state updates
- A light client receives objects of type
LightClientUpdate
, LightClientFinalityUpdate
and LightClientOptimisticUpdate
:
update: LightClientUpdate
: Every update
triggers process_light_client_update(store, update, current_slot, genesis_validators_root)
where current_slot
is the current slot based on a local clock.
finality_update: LightClientFinalityUpdate
: Every finality_update
triggers process_light_client_finality_update(store, finality_update, current_slot, genesis_validators_root)
.
optimistic_update: LightClientOptimisticUpdate
: Every optimistic_update
triggers process_light_client_optimistic_update(store, optimistic_update, current_slot, genesis_validators_root)
.
process_light_client_store_force_update
MAY be called based on use case dependent heuristics if light client sync appears stuck.
validate_light_client_update
| def validate_light_client_update(store: LightClientStore,
update: LightClientUpdate,
current_slot: Slot,
genesis_validators_root: Root) -> None:
# Verify sync committee has sufficient participants
sync_aggregate = update.sync_aggregate
assert sum(sync_aggregate.sync_committee_bits) >= MIN_SYNC_COMMITTEE_PARTICIPANTS
# Verify update does not skip a sync committee period
assert is_valid_light_client_header(update.attested_header)
update_attested_slot = update.attested_header.beacon.slot
update_finalized_slot = update.finalized_header.beacon.slot
assert current_slot >= update.signature_slot > update_attested_slot >= update_finalized_slot
store_period = compute_sync_committee_period_at_slot(store.finalized_header.beacon.slot)
update_signature_period = compute_sync_committee_period_at_slot(update.signature_slot)
if is_next_sync_committee_known(store):
assert update_signature_period in (store_period, store_period + 1)
else:
assert update_signature_period == store_period
# Verify update is relevant
update_attested_period = compute_sync_committee_period_at_slot(update_attested_slot)
update_has_next_sync_committee = not is_next_sync_committee_known(store) and (
is_sync_committee_update(update) and update_attested_period == store_period
)
assert (
update_attested_slot > store.finalized_header.beacon.slot
or update_has_next_sync_committee
)
# Verify that the `finality_branch`, if present, confirms `finalized_header`
# to match the finalized checkpoint root saved in the state of `attested_header`.
# Note that the genesis finalized checkpoint root is represented as a zero hash.
if not is_finality_update(update):
assert update.finalized_header == LightClientHeader()
else:
if update_finalized_slot == GENESIS_SLOT:
assert update.finalized_header == LightClientHeader()
finalized_root = Bytes32()
else:
assert is_valid_light_client_header(update.finalized_header)
finalized_root = hash_tree_root(update.finalized_header.beacon)
assert is_valid_normalized_merkle_branch(
leaf=finalized_root,
branch=update.finality_branch,
gindex=finalized_root_gindex_at_slot(update.attested_header.beacon.slot),
root=update.attested_header.beacon.state_root,
)
# Verify that the `next_sync_committee`, if present, actually is the next sync committee saved in the
# state of the `attested_header`
if not is_sync_committee_update(update):
assert update.next_sync_committee == SyncCommittee()
else:
if update_attested_period == store_period and is_next_sync_committee_known(store):
assert update.next_sync_committee == store.next_sync_committee
assert is_valid_normalized_merkle_branch(
leaf=hash_tree_root(update.next_sync_committee),
branch=update.next_sync_committee_branch,
gindex=next_sync_committee_gindex_at_slot(update.attested_header.beacon.slot),
root=update.attested_header.beacon.state_root,
)
# Verify sync committee aggregate signature
if update_signature_period == store_period:
sync_committee = store.current_sync_committee
else:
sync_committee = store.next_sync_committee
participant_pubkeys = [
pubkey for (bit, pubkey) in zip(sync_aggregate.sync_committee_bits, sync_committee.pubkeys)
if bit
]
fork_version_slot = max(update.signature_slot, Slot(1)) - Slot(1)
fork_version = compute_fork_version(compute_epoch_at_slot(fork_version_slot))
domain = compute_domain(DOMAIN_SYNC_COMMITTEE, fork_version, genesis_validators_root)
signing_root = compute_signing_root(update.attested_header.beacon, domain)
assert bls.FastAggregateVerify(participant_pubkeys, signing_root, sync_aggregate.sync_committee_signature)
|
apply_light_client_update
| def apply_light_client_update(store: LightClientStore, update: LightClientUpdate) -> None:
store_period = compute_sync_committee_period_at_slot(store.finalized_header.beacon.slot)
update_finalized_period = compute_sync_committee_period_at_slot(update.finalized_header.beacon.slot)
if not is_next_sync_committee_known(store):
assert update_finalized_period == store_period
store.next_sync_committee = update.next_sync_committee
elif update_finalized_period == store_period + 1:
store.current_sync_committee = store.next_sync_committee
store.next_sync_committee = update.next_sync_committee
store.previous_max_active_participants = store.current_max_active_participants
store.current_max_active_participants = 0
if update.finalized_header.beacon.slot > store.finalized_header.beacon.slot:
store.finalized_header = update.finalized_header
if store.finalized_header.beacon.slot > store.optimistic_header.beacon.slot:
store.optimistic_header = store.finalized_header
|
process_light_client_store_force_update
| def process_light_client_store_force_update(store: LightClientStore, current_slot: Slot) -> None:
if (
current_slot > store.finalized_header.beacon.slot + UPDATE_TIMEOUT
and store.best_valid_update is not None
):
# Forced best update when the update timeout has elapsed.
# Because the apply logic waits for `finalized_header.beacon.slot` to indicate sync committee finality,
# the `attested_header` may be treated as `finalized_header` in extended periods of non-finality
# to guarantee progression into later sync committee periods according to `is_better_update`.
if store.best_valid_update.finalized_header.beacon.slot <= store.finalized_header.beacon.slot:
store.best_valid_update.finalized_header = store.best_valid_update.attested_header
apply_light_client_update(store, store.best_valid_update)
store.best_valid_update = None
|
process_light_client_update
| def process_light_client_update(store: LightClientStore,
update: LightClientUpdate,
current_slot: Slot,
genesis_validators_root: Root) -> None:
validate_light_client_update(store, update, current_slot, genesis_validators_root)
sync_committee_bits = update.sync_aggregate.sync_committee_bits
# Update the best update in case we have to force-update to it if the timeout elapses
if (
store.best_valid_update is None
or is_better_update(update, store.best_valid_update)
):
store.best_valid_update = update
# Track the maximum number of active participants in the committee signatures
store.current_max_active_participants = max(
store.current_max_active_participants,
sum(sync_committee_bits),
)
# Update the optimistic header
if (
sum(sync_committee_bits) > get_safety_threshold(store)
and update.attested_header.beacon.slot > store.optimistic_header.beacon.slot
):
store.optimistic_header = update.attested_header
# Update finalized header
update_has_finalized_next_sync_committee = (
not is_next_sync_committee_known(store)
and is_sync_committee_update(update) and is_finality_update(update) and (
compute_sync_committee_period_at_slot(update.finalized_header.beacon.slot)
== compute_sync_committee_period_at_slot(update.attested_header.beacon.slot)
)
)
if (
sum(sync_committee_bits) * 3 >= len(sync_committee_bits) * 2
and (
update.finalized_header.beacon.slot > store.finalized_header.beacon.slot
or update_has_finalized_next_sync_committee
)
):
# Normal update through 2/3 threshold
apply_light_client_update(store, update)
store.best_valid_update = None
|
process_light_client_finality_update
| def process_light_client_finality_update(store: LightClientStore,
finality_update: LightClientFinalityUpdate,
current_slot: Slot,
genesis_validators_root: Root) -> None:
update = LightClientUpdate(
attested_header=finality_update.attested_header,
next_sync_committee=SyncCommittee(),
next_sync_committee_branch=NextSyncCommitteeBranch(),
finalized_header=finality_update.finalized_header,
finality_branch=finality_update.finality_branch,
sync_aggregate=finality_update.sync_aggregate,
signature_slot=finality_update.signature_slot,
)
process_light_client_update(store, update, current_slot, genesis_validators_root)
|
process_light_client_optimistic_update
| def process_light_client_optimistic_update(store: LightClientStore,
optimistic_update: LightClientOptimisticUpdate,
current_slot: Slot,
genesis_validators_root: Root) -> None:
update = LightClientUpdate(
attested_header=optimistic_update.attested_header,
next_sync_committee=SyncCommittee(),
next_sync_committee_branch=NextSyncCommitteeBranch(),
finalized_header=LightClientHeader(),
finality_branch=FinalityBranch(),
sync_aggregate=optimistic_update.sync_aggregate,
signature_slot=optimistic_update.signature_slot,
)
process_light_client_update(store, update, current_slot, genesis_validators_root)
|