# Beacon light-client progressive server # Spec Need to produce two objects ## `LightClientBootstrap` Parts: - `header`: already stored always by root, available forever - `current_sync_committee`: from block's slot, if finalized slot retrieve by period else retrieve by state root - A: On finalization, check if the sync committee of `period_of(finalized_slot)` is persisted. If not, check if an available state in memory has the same period. If yes, persist its two sync committees. Else, regen states from disk to persist the missing periods. - `current_sync_committee_branch`: retrieve by block root. - A: store to DB for every block in hot DB. At finalization migrate to cold DB the canonical checkpoints - B: store to DB for every block in cold DB. At finalization delete from cold DB the canonical checkpoints - C: store in memory for every block. At finalization persist to cold DB the canonical checkpoints To store the parts **TODO**: Just compute it in `run_migration` with `finalized_state` ```python def after_import_block(state: BeaconState, block: SignedBeaconBlock): block_root = hash_tree_root(block.message) # TODO: Persist current_sync_committee_branch for checkpoint blocks on each branch, # expect prune function to drop abandonded branches and heads parent_block_slot = todo!() # first slot in epoch is always a checkpoint if first_slot_in_epoch(block.message.slot): db.current_sync_committee_branch.set(block_root, compute_merkle_proof(state, CURRENT_SYNC_COMMITTEE_INDEX)) if ( # block crossed an epoch boundary, and is not first slot of the epoch: parent is a checkpoint first_slot_in_epoch(block.message.slot) and epoch_of(parent_block_slot) < epoch_of(block.message.slot) # at least one full epoch of empty slots up to block: parent is a checkpoint of the previous epoch or epoch_of(parent_block_slot) < epoch_of(block.message.slot) - 1 ): # Persist sync_committee on finalization, no need to prune state_period = compute_period_of(state.slot) finalized_period = compute_period_of(state.finalized_checkpoint.epoch) # TODO: Only finalized root is ok, so sync_committee can only be queried by period # Store only if not set yet if finalized_period >= state_period: db.sync_committee.set(state_period + 1, state.next_sync_committee) if finalized_period >= state_period - 1: db.sync_committee.set(state_period, state.current_sync_committee) ``` Queried by any block root of `header`. `current_sync_committee_branch` is specific to each `header`. To construct ```python def get_light_client_bootstrap(block_root: Root) -> LightClientBootstrap: # block is always available up to `MIN_EPOCHS_FOR_BLOCK_REQUESTS`, backfill available header=block_to_light_client_header(db.block.get(block_root)) # current_sync_committee_branch is available for the range of blocks synced by this node. # backfill sync not available current_sync_committee_branch = db.current_sync_committee_branch.get(block_root) header_period = compute_period_of(header.slot) finalized_period = compute_period_of(finalized_slot) current_sync_committee = if header_period <= finalized_period: # TODO may be a race condition, use pivot or some signaling db.sync_committee.get(period) else: # TODO: how to bound this? should it go through a cache? retrieve_sync_committee_from_state(header.state_root) return LightClientBootstrap( header, current_sync_committee, current_sync_committee_branch, ) ``` Pruning strategy ```python def after_prune_fc(canonical_nodes, non_canonical_nodes): for root in canonical_nodes: if is_checkpoint_block(node) db.current_sync_committee_branch.move_to_cold(root) for root in canonical_nodes + non_canonical_nodes: db.current_sync_committee_branch.delete_from_hot(root) ``` ```python def p2p_reqresp_handle('GetLightClientBootstrap', root): yield get_light_client_bootstrap(root) ``` ## `LightClientUpdate` Must be immediately published + queried by sync committee period _Note: `LightClientFinalityUpdate` and `LightClientOptimisticUpdate` can be derived from `LightClientUpdate`_ After successfully importing a block, run `after_import_block`. In summary: - persist best update for period to db - cache latest update in memory - broadcast `create_light_client_finality_update(update)` to the gossip topic `light_client_finality_update` - broadcast `create_light_client_optimistic_update(update)` to the gossip topic `light_client_optimistic_update` **Questions**: - How to handle best updates that are not cannonical, may they include the wrong sync committee? When comparing an old update, how to we know it's cannonical? Should non cannonical updates be pruned on re-org or latter? ```python def after_import_block(state: BeaconState, block: SignedBeaconBlock): # Persist attested header data # ==== block_root = hash_tree_root(block.message) next_sync_committee_root = hash_tree_root(state.next_sync_committee) db.next_sync_committee_branch.set(block_root, { branch: compute_merkle_proof(state, NEXT_SYNC_COMMITTEE_INDEX), root: next_sync_committee_root }) db.sync_committee.set(next_sync_committee_root, state.next_sync_committee) db.finality_branch.set(root, { branch: compute_merkle_proof(state, FINALIZED_ROOT_INDEX), root: state.finalized_checkpoint.root }) # Create update with `block`'s sync aggregate # ==== finality_update = create_light_client_finality_update(block) if finality_update.finalized_slot > cache.latest_finality_update.finalized_slot: cache.latest_finality_update = finality_update p2p.broadcast('light_client_finality_update', finality_update) if finality_update.attested_slot > cache.latest_optimistic_update.attested_slot: optimistic_update = create_light_client_optimistic_update(finality_update) cache.latest_optimistic_update = optimistic_update p2p.broadcast('light_client_optimistic_update', optimistic_update) period = compute_sync_committee_period_at_slot(finality_update.attested_header.slot) if is_better(db.best_period_update.get(period), finality_update): db.best_period_update.set(period, create_light_client_update(finality_update)) ``` ```python def create_light_client_finality_update(block: SignedBeaconBlock) -> LightClientFinalityUpdate: attested_header_root = block.message.parent_root attested_header = block_to_light_client_header(db.block.get(attested_header_root)) update = LightClientFinalityUpdate() update.attested_header = attested_header # Indicate finality whenever possible finality_branch = db.finality_branch.get(attested_header_root) if finality_branch is not None: update.finalized_header = db.light_client_header.get(finality_branch.root) update.finality_branch = finality_branch.branch update.sync_aggregate = block.message.body.sync_aggregate update.signature_slot = block.message.slot return update ``` ```python def create_light_client_update(finality_update: LightClientFinalityUpdate) -> LightClientUpdate: update = LightClientUpdate(...finality_update) # `next_sync_committee` is only useful if the message is signed by the current sync committee if update.signature_slot == compute_sync_committee_period_at_slot(update.attested_header.slot): next_sync_committee_branch = db.next_sync_committee_branch.get(attested_header_root) update.next_sync_committee = db.sync_committee.get(next_sync_committee_branch.root) update.next_sync_committee_branch = next_sync_committee_branch.branch return update ``` For ReqResp: - `LightClientUpdatesByRange`: iterate db `best_updates_by_period` bucket for requested period range - `GetLightClientFinalityUpdate`: returned cached latest update in memory - `GetLightClientOptimisticUpdate`: returned cached latest update in memory ```python def p2p_reqresp_handle('LightClientUpdatesByRange', start_period, count): for period in range(start_period, start_period + count): yield db.best_period_update.get(period) ``` ```python def p2p_reqresp_handle('GetLightClientFinalityUpdate'): yield cache.latest_finality_update ``` ```python def p2p_reqresp_handle('GetLightClientOptimisticUpdate'): yield create_light_client_optimistic_update(cache.latest_finality_update) ``` ## Pruning ```python def after_prune_fc(canonical_nodes, non_canonical_nodes): # Caches to produce `LightClientUpdate` only need non-finalized block roots (potential block parents) for root in canonical_nodes + non_canonical_nodes: db.next_sync_committee_branch.delete(root) db.finality_branch.delete(root) # Caches to produce `LightClientBootstrap` only need canonical checkpoint blocks for root in [node for node in canonical_nodes if not is_checkpoint_block(node)] + non_canonical_nodes: db.current_sync_committee_branch.delete(root) # TODO: Prune `db.sync_committee` ``` The long term persisted data cost is: - 1 sync committee per period: 24576 bytes every 27.3 hours ~= 657KB / month - 1 next_sync_committee_branch per epoch: 192 bytes every 6.4 minutes ~= 1.3MB / month - 1 `LightClientUpdate` per period: ~+24576 bytes every 27.3 hours ~= 657KB / month The temporary non-finalized data cost is: - 1 next_sync_committee_branch per block - 1 current_sync_committee_branch per block - 1 finality_branch per block # impl At the end of the `import_block` routine (after commiting to FC and DB), persist LC data https://github.com/sigp/lighthouse/blob/441fc1691b69f9edc4bbdc6665f3efab16265c9b/beacon_node/beacon_chain/src/beacon_chain.rs#L3126 ## Lodestar ref Most code here: https://github.com/ChainSafe/lodestar/blob/unstable/packages/beacon-node/src/chain/lightClient/index.ts This data structure interacts with the outside world via: ```typescript /** * Call after importing a block head, having the postState available in memory for proof generation. * - Persist state witness * - Use block's syncAggregate */ onImportBlockHead( block: allForks.AllForksLightClient["BeaconBlock"], postState: CachedBeaconStateAltair, parentBlockSlot: Slot ) ``` _https://github.com/ChainSafe/lodestar/blob/618895c92922658c77284f9a31273ade7f894e94/packages/beacon-node/src/chain/lightClient/index.ts#L227_ - Persist post state proofs for `block` - Persists `block`'s syncAggregate linking it with the previously persisted data of its parent