nimbus-eth2/beacon_chain/gossip_processing/light_client_processor.nim
Etan Kissling 7e276937dc
make LC data fork aware (#4493)
In a future fork, light client data will be extended with execution info
to support more use cases. To anticipate such an upgrade, introduce
`Forky` and `Forked` types, and ready the database schema.
Because the mapping of sync committee periods to fork versions is not
necessarily unique (fork schedule not in sync with period boundaries),
an additional column is added to `period` -> `LightClientUpdate` table.
2023-01-12 18:11:38 +01:00

545 lines
20 KiB
Nim

# beacon_chain
# Copyright (c) 2022-2023 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.
when (NimMajor, NimMinor) < (1, 4):
{.push raises: [Defect].}
else:
{.push raises: [].}
import
stew/objects,
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: [Defect].}
VoidCallback* =
proc() {.gcsafe, raises: [Defect].}
ValueObserver[V] =
proc(v: V) {.gcsafe, raises: [Defect].}
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
# Consumer
# ----------------------------------------------------------------
store: ref Option[LightClientStore]
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
numDuplicatesSinceProgress: 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 Option[LightClientStore],
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
): ref LightClientProcessor =
(ref LightClientProcessor)(
dumpEnabled: dumpEnabled,
dumpDirInvalid: dumpDirInvalid,
dumpDirIncoming: dumpDirIncoming,
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()
store = self.store
if store[].isSome:
doAssert self.finalizationMode == LightClientFinalizationMode.Optimistic
case store[].get.process_light_client_store_force_update(wallSlot)
of NoUpdate:
discard
of DidUpdateWithoutSupermajority:
warn "Light client force-updated without supermajority",
finalizedSlot = store[].get.finalized_header.slot,
optimisticSlot = store[].get.optimistic_header.slot
of DidUpdateWithoutFinality:
warn "Light client force-updated without finality proof",
finalizedSlot = store[].get.finalized_header.slot,
optimisticSlot = store[].get.optimistic_header.slot
proc processObject(
self: var LightClientProcessor,
obj: SomeForkedLightClientObject,
wallTime: BeaconTime): Result[void, VerifierError] =
let
wallSlot = wallTime.slotOrZero()
store = self.store
res = withForkyObject(obj):
when lcDataFork >= LightClientDataFork.Altair:
when forkyObject is ForkyLightClientBootstrap:
if store[].isSome:
err(VerifierError.Duplicate)
else:
let trustedBlockRoot = self.getTrustedBlockRoot()
if trustedBlockRoot.isNone:
err(VerifierError.MissingParent)
else:
let initRes =
initialize_light_client_store(trustedBlockRoot.get, forkyObject)
if initRes.isErr:
err(initRes.error)
else:
store[] = some(initRes.get)
ok()
elif forkyObject is SomeForkyLightClientUpdate:
if store[].isNone:
err(VerifierError.MissingParent)
else:
store[].get.process_light_client_update(
forkyObject, wallSlot, self.cfg, self.genesis_validators_root)
else:
err(VerifierError.Invalid)
withForkyObject(obj):
when lcDataFork >= LightClientDataFork.Altair:
self.dumpObject(forkyObject, res)
if res.isErr:
when obj is ForkedLightClientUpdate:
const storeDataFork = typeof(store[].get).kind
if self.finalizationMode == LightClientFinalizationMode.Optimistic and
store[].isSome and store[].get.best_valid_update.isSome and
obj.kind > LightClientDataFork.None and obj.kind <= storeDataFork:
# `best_valid_update` gets set when no supermajority / finality proof
# is available. In that case, we will wait for a better update.
# If none is made available within reasonable time, the light client
# is force-updated using the best known data to ensure sync progress.
case res.error
of VerifierError.Duplicate:
if wallTime >= self.lastDuplicateTick + duplicateRateLimit:
if self.numDuplicatesSinceProgress < minForceUpdateDuplicates:
let upgradedObj = obj.migratingToDataFork(storeDataFork)
if upgradedObj.forky(storeDataFork).matches(
store[].get.best_valid_update.get):
self.lastDuplicateTick = wallTime
inc self.numDuplicatesSinceProgress
if self.numDuplicatesSinceProgress >= minForceUpdateDuplicates and
wallTime >= self.lastProgressTick + minForceUpdateDelay:
self.tryForceUpdate(wallTime)
self.lastProgressTick = wallTime
self.lastDuplicateTick = wallTime + duplicateCountDelay
self.numDuplicatesSinceProgress = 0
else: discard
return res
when obj is ForkedLightClientBootstrap | ForkedLightClientUpdate:
if self.finalizationMode == LightClientFinalizationMode.Optimistic:
self.lastProgressTick = wallTime
self.lastDuplicateTick = wallTime + duplicateCountDelay
self.numDuplicatesSinceProgress = 0
res
template withReportedProgress(
obj: SomeForkedLightClientObject | Nothing, body: untyped): bool =
block:
let
previousWasInitialized = store[].isSome
previousNextCommitteeKnown =
if store[].isSome:
store[].get.is_next_sync_committee_known
else:
false
previousFinalized =
if store[].isSome:
store[].get.finalized_header
else:
BeaconBlockHeader()
previousOptimistic =
if store[].isSome:
store[].get.optimistic_header
else:
BeaconBlockHeader()
body
var
didProgress = false
didSignificantProgress = false
if store[].isSome != previousWasInitialized:
didProgress = true
didSignificantProgress = true
if self.onStoreInitialized != nil:
self.onStoreInitialized()
self.onStoreInitialized = nil
if store[].isSome:
if store[].get.optimistic_header != previousOptimistic:
didProgress = true
when obj isnot SomeLightClientUpdateWithFinality:
didSignificantProgress = true
if self.onOptimisticHeader != nil:
self.onOptimisticHeader()
if store[].get.finalized_header != previousFinalized:
didProgress = true
didSignificantProgress = true
if self.onFinalizedHeader != nil:
self.onFinalizedHeader()
if store[].get.is_next_sync_committee_known != previousNextCommitteeKnown:
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()
store = self.store
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.Altair:
when forkyObject is ForkyLightClientBootstrap:
forkyObject.header.slot
elif forkyObject is SomeForkyLightClientUpdateWithFinality:
forkyObject.finalized_header.slot
else:
forkyObject.attested_header.slot
else:
GENESIS_SLOT
debug "LC object processed",
finalizedSlot = store[].get.finalized_header.slot,
optimisticSlot = store[].get.optimistic_header.slot,
kind = typeof(obj).name,
objectSlot = objSlot,
storeObjectDur
ok didSignificantProgress
proc resetToFinalizedHeader*(
self: var LightClientProcessor,
header: BeaconBlockHeader,
current_sync_committee: SyncCommittee) =
let store = self.store
discard withReportedProgress:
store[] = some LightClientStore(
finalized_header: header,
current_sync_committee: current_sync_committee,
optimistic_header: header)
debug "LC reset to finalized header",
finalizedSlot = store[].get.finalized_header.slot,
optimisticSlot = store[].get.optimistic_header.slot
# Enqueue
# ------------------------------------------------------------------------------
proc addObject*(
self: var LightClientProcessor,
src: MsgSource,
obj: SomeForkedLightClientObject,
resfut: Future[Result[void, VerifierError]] = 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, wallSlot) = wallTime.toSlot()
if not afterGenesis:
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.Altair:
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.slot` is greater than that of
# all previously forwarded `finality_update`s
# [IGNORE] The `attested_header.slot` is greater than that of all
# previously forwarded `optimistic_update`s
errIgnore($r.error)
# https://github.com/ethereum/consensus-specs/blob/v1.3.0-rc.0/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.isOk:
self.latestFinalityUpdate = finality_update.toOptimistic
v
# https://github.com/ethereum/consensus-specs/blob/v1.3.0-alpha.0/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.isOk:
let
latestFinalitySlot = withForkyOptimisticUpdate(self.latestFinalityUpdate):
when lcDataFork >= LightClientDataFork.Altair:
forkyOptimisticUpdate.attested_header.slot
else:
GENESIS_SLOT
attestedSlot = withForkyOptimisticUpdate(optimistic_update):
when lcDataFork >= LightClientDataFork.Altair:
forkyOptimisticUpdate.attested_header.slot
else:
GENESIS_SLOT
if attestedSlot >= latestFinalitySlot:
self.latestFinalityUpdate.reset() # Only forward once
v