# beacon_chain # Copyright (c) 2022-2024 Status Research & Development GmbH # Licensed and distributed under either of # * MIT license (license terms in the root directory or at https://opensource.org/licenses/MIT). # * Apache v2 license (license terms in the root directory or at https://www.apache.org/licenses/LICENSE-2.0). # at your option. This file may not be copied, modified, or distributed except according to those terms. {.push raises: [].} import chronos, metrics, ../spec/light_client_sync, ../consensus_object_pools/block_pools_types, ".."/[beacon_clock, sszdump], "."/[eth2_processor, gossip_validation] export sszdump, eth2_processor, gossip_validation logScope: topics = "gossip_lc" # Light Client Processor # ------------------------------------------------------------------------------ # The light client processor handles received light client objects declareHistogram light_client_store_object_duration_seconds, "storeObject() duration", buckets = [0.25, 0.5, 1, 2, 4, 8, Inf] type Nothing = object GetTrustedBlockRootCallback* = proc(): Option[Eth2Digest] {.gcsafe, raises: [].} VoidCallback* = proc() {.gcsafe, raises: [].} ValueObserver[V] = proc(v: V) {.gcsafe, raises: [].} BootstrapObserver* = ValueObserver[ForkedLightClientBootstrap] UpdateObserver* = ValueObserver[ForkedLightClientUpdate] FinalityUpdateObserver* = ValueObserver[ForkedLightClientFinalityUpdate] OptimisticUpdateObserver* = ValueObserver[ForkedLightClientOptimisticUpdate] LightClientFinalizationMode* {.pure.} = enum Strict ## Only finalize light client data that: ## - has been signed by a supermajority (2/3) of the sync committee ## - has a valid finality proof ## ## Optimizes for security, but may become stuck if there is any of: ## - non-finality for an entire sync committee period ## - low sync committee participation for an entire sync committee period ## Such periods need to be covered by an out-of-band syncing mechanism. ## ## Note that a compromised supermajority of the sync committee is able to ## sign arbitrary light client data, even after being slashed. The light ## client cannot validate the slashing status of sync committee members. ## Likewise, voluntarily exited validators may sign bad light client data ## for the sync committee periods in which they used to be selected. Optimistic ## Attempt to finalize light client data not satisfying strict conditions ## if there is no progress for an extended period of time and if there are ## repeated messages indicating that it is the best available data on the ## network for the affected time period. ## ## Optimizes for availability of recent data, but may end up on incorrect ## forks if run in a hostile network environment (no honest peers), or if ## the low sync committee participation is being exploited by bad actors. LightClientProcessor* = object ## This manages the processing of received light client objects ## ## from: ## - Gossip: ## - `LightClientFinalityUpdate` ## - `LightClientOptimisticUpdate` ## - `LightClientManager`: ## - `GetLightClientBootstrap` ## - `LightClientUpdatesByRange` ## - `GetLightClientFinalityUpdate` ## - `GetLightClientOptimisticUpdate` ## ## are then verified and added to: ## - `LightClientStore` # Config # ---------------------------------------------------------------- dumpEnabled: bool dumpDirInvalid: string dumpDirIncoming: string strictVerification: bool # Consumer # ---------------------------------------------------------------- store: ref ForkedLightClientStore getBeaconTime: GetBeaconTimeFn getTrustedBlockRoot: GetTrustedBlockRootCallback onStoreInitialized, onFinalizedHeader, onOptimisticHeader: VoidCallback bootstrapObserver: BootstrapObserver updateObserver: UpdateObserver finalityUpdateObserver: FinalityUpdateObserver optimisticUpdateObserver: OptimisticUpdateObserver cfg: RuntimeConfig genesis_validators_root: Eth2Digest case finalizationMode: LightClientFinalizationMode of LightClientFinalizationMode.Strict: discard of LightClientFinalizationMode.Optimistic: lastProgressTick: BeaconTime # Moment when last update made progress lastDuplicateTick: BeaconTime # Moment when last duplicate update received numDupsSinceProgress: int # Number of duplicates since last progress latestFinalityUpdate: ForkedLightClientOptimisticUpdate const # These constants have been chosen empirically and are not backed by spec duplicateRateLimit = chronos.seconds(5) # Rate limit for counting duplicates duplicateCountDelay = chronos.minutes(15) # Delay to start counting duplicates minForceUpdateDelay = chronos.minutes(30) # Minimum delay until forced-update minForceUpdateDuplicates = 100 # Minimum duplicates until forced-update # Initialization # ------------------------------------------------------------------------------ proc new*( T: type LightClientProcessor, dumpEnabled: bool, dumpDirInvalid, dumpDirIncoming: string, cfg: RuntimeConfig, genesis_validators_root: Eth2Digest, finalizationMode: LightClientFinalizationMode, store: ref ForkedLightClientStore, getBeaconTime: GetBeaconTimeFn, getTrustedBlockRoot: GetTrustedBlockRootCallback, onStoreInitialized: VoidCallback = nil, onFinalizedHeader: VoidCallback = nil, onOptimisticHeader: VoidCallback = nil, bootstrapObserver: BootstrapObserver = nil, updateObserver: UpdateObserver = nil, finalityUpdateObserver: FinalityUpdateObserver = nil, optimisticUpdateObserver: OptimisticUpdateObserver = nil, strictVerification = false ): ref LightClientProcessor = (ref LightClientProcessor)( dumpEnabled: dumpEnabled, dumpDirInvalid: dumpDirInvalid, dumpDirIncoming: dumpDirIncoming, strictVerification: strictVerification, store: store, getBeaconTime: getBeaconTime, getTrustedBlockRoot: getTrustedBlockRoot, onStoreInitialized: onStoreInitialized, onFinalizedHeader: onFinalizedHeader, onOptimisticHeader: onOptimisticHeader, bootstrapObserver: bootstrapObserver, updateObserver: updateObserver, finalityUpdateObserver: finalityUpdateObserver, optimisticUpdateObserver: optimisticUpdateObserver, cfg: cfg, genesis_validators_root: genesis_validators_root, finalizationMode: finalizationMode) # Storage # ------------------------------------------------------------------------------ proc dumpInvalidObject( self: LightClientProcessor, obj: SomeForkyLightClientObject) = if self.dumpEnabled: dump(self.dumpDirInvalid, obj) proc dumpObject[T]( self: LightClientProcessor, obj: SomeForkyLightClientObject, res: Result[T, VerifierError]) = if self.dumpEnabled and res.isErr: case res.error of VerifierError.Invalid: self.dumpInvalidObject(obj) of VerifierError.MissingParent: dump(self.dumpDirIncoming, obj) else: discard proc tryForceUpdate( self: var LightClientProcessor, wallTime: BeaconTime) = ## Try to force-update to the next sync committee period. let wallSlot = wallTime.slotOrZero() doAssert self.finalizationMode == LightClientFinalizationMode.Optimistic withForkyStore(self.store[]): when lcDataFork > LightClientDataFork.None: case forkyStore.process_light_client_store_force_update(wallSlot) of NoUpdate: discard of DidUpdateWithoutSupermajority: warn "Light client force-updated without supermajority", finalizedSlot = forkyStore.finalized_header.beacon.slot, optimisticSlot = forkyStore.optimistic_header.beacon.slot of DidUpdateWithoutFinality: warn "Light client force-updated without finality proof", finalizedSlot = forkyStore.finalized_header.beacon.slot, optimisticSlot = forkyStore.optimistic_header.beacon.slot proc doProcessObject( self: var LightClientProcessor, bootstrap: ForkedLightClientBootstrap, wallTime: BeaconTime): Result[void, VerifierError] = if bootstrap.kind == LightClientDataFork.None: err(VerifierError.Invalid) elif self.store[].kind > LightClientDataFork.None: err(VerifierError.Duplicate) else: let trustedBlockRoot = self.getTrustedBlockRoot() if trustedBlockRoot.isNone: err(VerifierError.MissingParent) else: withForkyBootstrap(bootstrap): when lcDataFork > LightClientDataFork.None: let initRes = initialize_light_client_store( trustedBlockRoot.get, forkyBootstrap, self.cfg) if initRes.isErr: err(initRes.error) else: self.store[] = ForkedLightClientStore.init(initRes.get) ok() else: raiseAssert "Unreachable; bootstrap.kind was checked" proc doProcessObject( self: var LightClientProcessor, update: SomeForkedLightClientUpdate, wallTime: BeaconTime): Result[void, VerifierError] = if update.kind == LightClientDataFork.None: err(VerifierError.Invalid) elif self.store[].kind == LightClientDataFork.None: err(VerifierError.MissingParent) else: withForkyObject(update): when lcDataFork > LightClientDataFork.None: if lcDataFork > self.store[].kind: info "Upgrading light client", oldFork = self.store[].kind, newFork = lcDataFork self.store[].migrateToDataFork(lcDataFork) withForkyStore(self.store[]): when lcDataFork > LightClientDataFork.None: let wallSlot = wallTime.slotOrZero() upgradedUpdate = update.migratingToDataFork(lcDataFork) process_light_client_update( forkyStore, upgradedUpdate.forky(lcDataFork), wallSlot, self.cfg, self.genesis_validators_root) else: raiseAssert "Unreachable; self.store[].kind was checked" proc processObject( self: var LightClientProcessor, obj: SomeForkedLightClientObject, wallTime: BeaconTime): Result[void, VerifierError] = let res = self.doProcessObject(obj, wallTime) withForkyObject(obj): when lcDataFork > LightClientDataFork.None: self.dumpObject(forkyObject, res) if res.isErr: when obj is ForkedLightClientUpdate: if self.finalizationMode == LightClientFinalizationMode.Optimistic and obj.kind <= self.store[].kind: withForkyStore(self.store[]): when lcDataFork > LightClientDataFork.None: if forkyStore.best_valid_update.isSome: # `best_valid_update` is set when supermajority / finality proof # is unavailable. In that case, we will wait for a better update. # If none is made available within reasonable time, light client # is force-updated with best known data to ensure sync progress. case res.error of VerifierError.Duplicate: if wallTime >= self.lastDuplicateTick + duplicateRateLimit: if self.numDupsSinceProgress < minForceUpdateDuplicates: let upgradedObj = obj.migratingToDataFork(lcDataFork) if upgradedObj.forky(lcDataFork).matches( forkyStore.best_valid_update.get): self.lastDuplicateTick = wallTime inc self.numDupsSinceProgress if self.numDupsSinceProgress >= minForceUpdateDuplicates and wallTime >= self.lastProgressTick + minForceUpdateDelay: self.tryForceUpdate(wallTime) self.lastProgressTick = wallTime self.lastDuplicateTick = wallTime + duplicateCountDelay self.numDupsSinceProgress = 0 else: discard return res when obj is ForkedLightClientBootstrap | ForkedLightClientUpdate: if self.finalizationMode == LightClientFinalizationMode.Optimistic: self.lastProgressTick = wallTime self.lastDuplicateTick = wallTime + duplicateCountDelay self.numDupsSinceProgress = 0 res template withReportedProgress( obj: SomeForkedLightClientObject | Nothing, body: untyped): bool = block: let oldIsInitialized = self.store[].kind > LightClientDataFork.None oldNextCommitteeKnown = withForkyStore(self.store[]): when lcDataFork > LightClientDataFork.None: forkyStore.is_next_sync_committee_known else: false var oldFinalized = withForkyStore(self.store[]): when lcDataFork > LightClientDataFork.None: ForkedLightClientHeader.init(forkyStore.finalized_header) else: default(ForkedLightClientHeader) oldOptimistic = withForkyStore(self.store[]): when lcDataFork > LightClientDataFork.None: ForkedLightClientHeader.init(forkyStore.optimistic_header) else: default(ForkedLightClientHeader) body var didProgress = false didSignificantProgress = false let newIsInitialized = self.store[].kind > LightClientDataFork.None if newIsInitialized > oldIsInitialized: didProgress = true didSignificantProgress = true if self.onStoreInitialized != nil: self.onStoreInitialized() self.onStoreInitialized = nil withForkyStore(self.store[]): when lcDataFork > LightClientDataFork.None: if oldOptimistic.kind <= lcDataFork: oldOptimistic.migrateToDataFork(lcDataFork) if forkyStore.optimistic_header != oldOptimistic.forky(lcDataFork): didProgress = true when obj isnot SomeForkedLightClientUpdateWithFinality: didSignificantProgress = true if self.onOptimisticHeader != nil: self.onOptimisticHeader() if oldFinalized.kind <= lcDataFork: oldFinalized.migrateToDataFork(lcDataFork) if forkyStore.finalized_header != oldFinalized.forky(lcDataFork): didProgress = true didSignificantProgress = true if self.onFinalizedHeader != nil: self.onFinalizedHeader() if forkyStore.is_next_sync_committee_known != oldNextCommitteeKnown: didProgress = true if didProgress: when obj is Nothing: discard elif obj is ForkedLightClientBootstrap: if self.bootstrapObserver != nil: self.bootstrapObserver(obj) elif obj is ForkedLightClientUpdate: if self.updateObserver != nil: self.updateObserver(obj) elif obj is ForkedLightClientFinalityUpdate: if self.finalityUpdateObserver != nil: self.finalityUpdateObserver(obj) elif obj is ForkedLightClientOptimisticUpdate: if self.optimisticUpdateObserver != nil: self.optimisticUpdateObserver(obj) else: raiseAssert "Unreachable" didSignificantProgress template withReportedProgress(body: untyped): bool = withReportedProgress(Nothing(), body) proc storeObject*( self: var LightClientProcessor, src: MsgSource, wallTime: BeaconTime, obj: SomeForkedLightClientObject): Result[bool, VerifierError] = ## storeObject is the main entry point for unvalidated light client objects - ## all untrusted objects pass through here. When storing an object, we will ## update the `LightClientStore` accordingly let startTick = Moment.now() didSignificantProgress = withReportedProgress(obj): ? self.processObject(obj, wallTime) let storeObjectTick = Moment.now() storeObjectDur = storeObjectTick - startTick light_client_store_object_duration_seconds.observe( storeObjectDur.toFloatSeconds()) let objSlot = withForkyObject(obj): when lcDataFork > LightClientDataFork.None: when forkyObject is ForkyLightClientBootstrap: forkyObject.header.beacon.slot elif forkyObject is SomeForkyLightClientUpdateWithFinality: forkyObject.finalized_header.beacon.slot else: forkyObject.attested_header.beacon.slot else: GENESIS_SLOT withForkyStore(self.store[]): when lcDataFork > LightClientDataFork.None: debug "LC object processed", finalizedSlot = forkyStore.finalized_header.beacon.slot, optimisticSlot = forkyStore.optimistic_header.beacon.slot, kind = typeof(obj).name, objectSlot = objSlot, storeObjectDur ok didSignificantProgress proc resetToFinalizedHeader*( self: var LightClientProcessor, header: ForkedLightClientHeader, current_sync_committee: SyncCommittee) = discard withReportedProgress: withForkyHeader(header): when lcDataFork > LightClientDataFork.None: self.store[] = ForkedLightClientStore.init(lcDataFork.LightClientStore( finalized_header: forkyHeader, current_sync_committee: current_sync_committee, optimistic_header: forkyHeader)) template forkyStore: untyped = self.store[].forky(lcDataFork) debug "LC reset to finalized header", finalizedSlot = forkyStore.finalized_header.beacon.slot, optimisticSlot = forkyStore.optimistic_header.beacon.slot else: self.store[].reset() debug "LC reset" # Enqueue # ------------------------------------------------------------------------------ proc addObject*( self: var LightClientProcessor, src: MsgSource, obj: SomeForkedLightClientObject, resfut: Future[Result[void, VerifierError]].Raising([CancelledError]) = nil) = ## Enqueue a Gossip-validated light client object for verification # Backpressure: # Only one object is validated at any time - # Light client objects are always "fast" to process # Producers: # - Gossip: # - `LightClientFinalityUpdate` # - `LightClientOptimisticUpdate` # - `LightClientManager`: # - `GetLightClientBootstrap` # - `LightClientUpdatesByRange` # - `GetLightClientFinalityUpdate` # - `GetLightClientOptimisticUpdate` let wallTime = self.getBeaconTime() (afterGenesis, _) = wallTime.toSlot() if not afterGenesis: let mayProcessBeforeGenesis = when obj is ForkedLightClientBootstrap: withForkyBootstrap(obj): when lcDataFork > LightClientDataFork.None: forkyBootstrap.header.beacon.slot == GENESIS_SLOT and self.cfg.ALTAIR_FORK_EPOCH == GENESIS_EPOCH else: false else: false if not mayProcessBeforeGenesis: error "Processing LC object before genesis, clock turned back?" quit 1 let res = self.storeObject(src, wallTime, obj) if resfut != nil: if res.isOk: resfut.complete(Result[void, VerifierError].ok()) else: resfut.complete(Result[void, VerifierError].err(res.error)) # Message validators # ------------------------------------------------------------------------------ func toValidationError( self: var LightClientProcessor, r: Result[bool, VerifierError], wallTime: BeaconTime, obj: SomeForkedLightClientObject): Result[void, ValidationError] = if r.isOk: let didSignificantProgress = r.get if didSignificantProgress: let signature_slot = withForkyObject(obj): when lcDataFork > LightClientDataFork.None: forkyObject.signature_slot else: GENESIS_SLOT currentTime = wallTime + MAXIMUM_GOSSIP_CLOCK_DISPARITY forwardTime = signature_slot.light_client_finality_update_time if currentTime < forwardTime: # [IGNORE] The `finality_update` is received after the block # at `signature_slot` was given enough time to propagate through # the network. # [IGNORE] The `optimistic_update` is received after the block # at `signature_slot` was given enough time to propagate through # the network. return errIgnore(typeof(obj).name & ": received too early") ok() else: when obj is ForkedLightClientOptimisticUpdate: # [IGNORE] The `optimistic_update` either matches corresponding fields # of the most recently forwarded `LightClientFinalityUpdate` (if any), # or it advances the `optimistic_header` of the local `LightClientStore` if obj.matches(self.latestFinalityUpdate): return ok() # [IGNORE] The `finality_update` advances the `finalized_header` of the # local `LightClientStore`. errIgnore(typeof(obj).name & ": no significant progress") else: case r.error of VerifierError.Invalid: # [REJECT] The `finality_update` is valid. # [REJECT] The `optimistic_update` is valid. errReject($r.error) of VerifierError.MissingParent, VerifierError.UnviableFork, VerifierError.Duplicate: # [IGNORE] The `finalized_header.beacon.slot` is greater than that of # all previously forwarded `finality_update`s # [IGNORE] The `attested_header.beacon.slot` is greater than that of all # previously forwarded `optimistic_update`s errIgnore($r.error) # https://github.com/ethereum/consensus-specs/blob/v1.4.0-beta.5/specs/altair/light-client/sync-protocol.md#process_light_client_finality_update proc processLightClientFinalityUpdate*( self: var LightClientProcessor, src: MsgSource, finality_update: ForkedLightClientFinalityUpdate ): Result[void, ValidationError] = let wallTime = self.getBeaconTime() r = self.storeObject(src, wallTime, finality_update) v = self.toValidationError(r, wallTime, finality_update) if v.isErr: return checkedResult(v.error, self.strictVerification) self.latestFinalityUpdate = finality_update.toOptimistic v # https://github.com/ethereum/consensus-specs/blob/v1.4.0-beta.5/specs/altair/light-client/sync-protocol.md#process_light_client_finality_update proc processLightClientOptimisticUpdate*( self: var LightClientProcessor, src: MsgSource, optimistic_update: ForkedLightClientOptimisticUpdate ): Result[void, ValidationError] = let wallTime = self.getBeaconTime() r = self.storeObject(src, wallTime, optimistic_update) v = self.toValidationError(r, wallTime, optimistic_update) if v.isErr: return checkedResult(v.error, self.strictVerification) let latestFinalitySlot = withForkyOptimisticUpdate(self.latestFinalityUpdate): when lcDataFork > LightClientDataFork.None: forkyOptimisticUpdate.attested_header.beacon.slot else: GENESIS_SLOT attestedSlot = withForkyOptimisticUpdate(optimistic_update): when lcDataFork > LightClientDataFork.None: forkyOptimisticUpdate.attested_header.beacon.slot else: GENESIS_SLOT if attestedSlot >= latestFinalitySlot: self.latestFinalityUpdate.reset() # Only forward once v