add strict mode to light client processor (#3894)

The light client sync protocol employs heuristics to ensure it does not
become stuck during non-finality or low sync committee participation.
These can enable use cases that prefer availability of recent data
over security. For our syncing use case, though, security is preferred.
An option is added to light client processor to configure this tradeoff.
This commit is contained in:
Etan Kissling 2022-07-21 11:16:10 +02:00 committed by GitHub
parent 24d8401054
commit 735c1df62f
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
6 changed files with 271 additions and 175 deletions

View File

@ -287,14 +287,20 @@ OK: 9/9 Fail: 0/9 Skip: 0/9
OK: 3/3 Fail: 0/3 Skip: 0/3 OK: 3/3 Fail: 0/3 Skip: 0/3
## Light client processor [Preset: mainnet] ## Light client processor [Preset: mainnet]
```diff ```diff
+ Duplicate bootstrap [Preset: mainnet] OK + Duplicate bootstrap (Optimistic) [Preset: mainnet] OK
+ Invalid bootstrap [Preset: mainnet] OK + Duplicate bootstrap (Strict) [Preset: mainnet] OK
+ Missing bootstrap (finality update) [Preset: mainnet] OK + Invalid bootstrap (Optimistic) [Preset: mainnet] OK
+ Missing bootstrap (optimistic update) [Preset: mainnet] OK + Invalid bootstrap (Strict) [Preset: mainnet] OK
+ Missing bootstrap (update) [Preset: mainnet] OK + Missing bootstrap (finality update) (Optimistic) [Preset: mainnet] OK
+ Sync [Preset: mainnet] OK + Missing bootstrap (finality update) (Strict) [Preset: mainnet] OK
+ Missing bootstrap (optimistic update) (Optimistic) [Preset: mainnet] OK
+ Missing bootstrap (optimistic update) (Strict) [Preset: mainnet] OK
+ Missing bootstrap (update) (Optimistic) [Preset: mainnet] OK
+ Missing bootstrap (update) (Strict) [Preset: mainnet] OK
+ Sync (Optimistic) [Preset: mainnet] OK
+ Sync (Strict) [Preset: mainnet] OK
``` ```
OK: 6/6 Fail: 0/6 Skip: 0/6 OK: 12/12 Fail: 0/12 Skip: 0/12
## ListKeys requests [Preset: mainnet] ## ListKeys requests [Preset: mainnet]
```diff ```diff
+ Correct token provided [Preset: mainnet] OK + Correct token provided [Preset: mainnet] OK
@ -580,4 +586,4 @@ OK: 1/1 Fail: 0/1 Skip: 0/1
OK: 9/9 Fail: 0/9 Skip: 0/9 OK: 9/9 Fail: 0/9 Skip: 0/9
---TOTAL--- ---TOTAL---
OK: 321/326 Fail: 0/326 Skip: 5/326 OK: 327/332 Fail: 0/332 Skip: 5/332

View File

@ -42,8 +42,8 @@ proc initLightClient*(
config.safeSlotsToImportOptimistically) config.safeSlotsToImportOptimistically)
lightClient = createLightClient( lightClient = createLightClient(
node.network, rng, config, cfg, node.network, rng, config, cfg, forkDigests, getBeaconTime,
forkDigests, getBeaconTime, genesis_validators_root) genesis_validators_root, LightClientFinalizationMode.Strict)
if config.lightClientEnable.get: if config.lightClientEnable.get:
proc shouldSyncOptimistically(slot: Slot): bool = proc shouldSyncOptimistically(slot: Slot): bool =

View File

@ -33,6 +33,33 @@ type
VoidCallback* = VoidCallback* =
proc() {.gcsafe, raises: [Defect].} proc() {.gcsafe, raises: [Defect].}
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 LightClientProcessor* = object
## This manages the processing of received light client objects ## This manages the processing of received light client objects
## ##
@ -48,13 +75,6 @@ type
## ##
## are then verified and added to: ## are then verified and added to:
## - `LightClientStore` ## - `LightClientStore`
##
## The processor will also attempt to force-update the light client state
## if no update seems to be available on the network, that is both signed by
## a supermajority of sync committee members and also improves finality.
## This logic is triggered if there is no progress for an extended period
## of time, and there are repeated messages indicating that this is the best
## available data on the network during that time period.
# Config # Config
# ---------------------------------------------------------------- # ----------------------------------------------------------------
@ -72,6 +92,10 @@ type
cfg: RuntimeConfig cfg: RuntimeConfig
genesis_validators_root: Eth2Digest genesis_validators_root: Eth2Digest
case finalizationMode: LightClientFinalizationMode
of LightClientFinalizationMode.Strict:
discard
of LightClientFinalizationMode.Optimistic:
lastProgressTick: BeaconTime # Moment when last update made progress lastProgressTick: BeaconTime # Moment when last update made progress
lastDuplicateTick: BeaconTime # Moment when last duplicate update received lastDuplicateTick: BeaconTime # Moment when last duplicate update received
numDuplicatesSinceProgress: int # Number of duplicates since last progress numDuplicatesSinceProgress: int # Number of duplicates since last progress
@ -94,6 +118,7 @@ proc new*(
dumpDirInvalid, dumpDirIncoming: string, dumpDirInvalid, dumpDirIncoming: string,
cfg: RuntimeConfig, cfg: RuntimeConfig,
genesis_validators_root: Eth2Digest, genesis_validators_root: Eth2Digest,
finalizationMode: LightClientFinalizationMode,
store: ref Option[LightClientStore], store: ref Option[LightClientStore],
getBeaconTime: GetBeaconTimeFn, getBeaconTime: GetBeaconTimeFn,
getTrustedBlockRoot: GetTrustedBlockRootCallback, getTrustedBlockRoot: GetTrustedBlockRootCallback,
@ -112,8 +137,8 @@ proc new*(
onFinalizedHeader: onFinalizedHeader, onFinalizedHeader: onFinalizedHeader,
onOptimisticHeader: onOptimisticHeader, onOptimisticHeader: onOptimisticHeader,
cfg: cfg, cfg: cfg,
genesis_validators_root: genesis_validators_root genesis_validators_root: genesis_validators_root,
) finalizationMode: finalizationMode)
# Storage # Storage
# ------------------------------------------------------------------------------ # ------------------------------------------------------------------------------
@ -146,6 +171,7 @@ proc tryForceUpdate(
store = self.store store = self.store
if store[].isSome: if store[].isSome:
doAssert self.finalizationMode == LightClientFinalizationMode.Optimistic
case store[].get.try_light_client_store_force_update(wallSlot) case store[].get.try_light_client_store_force_update(wallSlot)
of NoUpdate: of NoUpdate:
discard discard
@ -192,11 +218,12 @@ proc processObject(
if res.isErr: if res.isErr:
when obj is altair.LightClientUpdate: when obj is altair.LightClientUpdate:
if store[].isSome and store[].get.best_valid_update.isSome: if self.finalizationMode == LightClientFinalizationMode.Optimistic and
# `best_valid_update` gets set when no supermajority / improved finality store[].isSome and store[].get.best_valid_update.isSome:
# is available. In that case, we will wait for a better update that once # `best_valid_update` gets set when no supermajority / finality proof
# again fulfills those conditions. If none is received within reasonable # is available. In that case, we will wait for a better update.
# time, the light client store is force-updated to `best_valid_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 case res.error
of BlockError.Duplicate: of BlockError.Duplicate:
if wallTime >= self.lastDuplicateTick + duplicateRateLimit: if wallTime >= self.lastDuplicateTick + duplicateRateLimit:
@ -215,6 +242,7 @@ proc processObject(
return res return res
when obj is altair.LightClientBootstrap | altair.LightClientUpdate: when obj is altair.LightClientBootstrap | altair.LightClientUpdate:
if self.finalizationMode == LightClientFinalizationMode.Optimistic:
self.lastProgressTick = wallTime self.lastProgressTick = wallTime
self.lastDuplicateTick = wallTime + duplicateCountDelay self.lastDuplicateTick = wallTime + duplicateCountDelay
self.numDuplicatesSinceProgress = 0 self.numDuplicatesSinceProgress = 0

View File

@ -20,7 +20,7 @@ import
./sync/light_client_manager, ./sync/light_client_manager,
"."/[beacon_clock, conf_light_client] "."/[beacon_clock, conf_light_client]
export eth2_network, conf_light_client export LightClientFinalizationMode, eth2_network, conf_light_client
logScope: topics = "lightcl" logScope: topics = "lightcl"
@ -60,7 +60,8 @@ proc createLightClient(
cfg: RuntimeConfig, cfg: RuntimeConfig,
forkDigests: ref ForkDigests, forkDigests: ref ForkDigests,
getBeaconTime: GetBeaconTimeFn, getBeaconTime: GetBeaconTimeFn,
genesis_validators_root: Eth2Digest genesis_validators_root: Eth2Digest,
finalizationMode: LightClientFinalizationMode
): LightClient = ): LightClient =
let lightClient = LightClient( let lightClient = LightClient(
network: network, network: network,
@ -85,7 +86,7 @@ proc createLightClient(
lightClient.processor = LightClientProcessor.new( lightClient.processor = LightClientProcessor.new(
dumpEnabled, dumpDirInvalid, dumpDirIncoming, dumpEnabled, dumpDirInvalid, dumpDirIncoming,
cfg, genesis_validators_root, cfg, genesis_validators_root, finalizationMode,
lightClient.store, getBeaconTime, getTrustedBlockRoot, lightClient.store, getBeaconTime, getTrustedBlockRoot,
onStoreInitialized, onFinalizedHeader, onOptimisticHeader) onStoreInitialized, onFinalizedHeader, onOptimisticHeader)
@ -141,12 +142,13 @@ proc createLightClient*(
cfg: RuntimeConfig, cfg: RuntimeConfig,
forkDigests: ref ForkDigests, forkDigests: ref ForkDigests,
getBeaconTime: GetBeaconTimeFn, getBeaconTime: GetBeaconTimeFn,
genesis_validators_root: Eth2Digest genesis_validators_root: Eth2Digest,
finalizationMode: LightClientFinalizationMode
): LightClient = ): LightClient =
createLightClient( createLightClient(
network, rng, network, rng,
config.dumpEnabled, config.dumpDirInvalid, config.dumpDirIncoming, config.dumpEnabled, config.dumpDirInvalid, config.dumpDirIncoming,
cfg, forkDigests, getBeaconTime, genesis_validators_root) cfg, forkDigests, getBeaconTime, genesis_validators_root, finalizationMode)
proc createLightClient*( proc createLightClient*(
network: Eth2Node, network: Eth2Node,
@ -155,12 +157,13 @@ proc createLightClient*(
cfg: RuntimeConfig, cfg: RuntimeConfig,
forkDigests: ref ForkDigests, forkDigests: ref ForkDigests,
getBeaconTime: GetBeaconTimeFn, getBeaconTime: GetBeaconTimeFn,
genesis_validators_root: Eth2Digest genesis_validators_root: Eth2Digest,
finalizationMode: LightClientFinalizationMode
): LightClient = ): LightClient =
createLightClient( createLightClient(
network, rng, network, rng,
dumpEnabled = false, dumpDirInvalid = ".", dumpDirIncoming = ".", dumpEnabled = false, dumpDirInvalid = ".", dumpDirIncoming = ".",
cfg, forkDigests, getBeaconTime, genesis_validators_root) cfg, forkDigests, getBeaconTime, genesis_validators_root, finalizationMode)
proc start*(lightClient: LightClient) = proc start*(lightClient: LightClient) =
notice "Starting light client", notice "Starting light client",

View File

@ -95,8 +95,8 @@ programMain:
config.safeSlotsToImportOptimistically) config.safeSlotsToImportOptimistically)
lightClient = createLightClient( lightClient = createLightClient(
network, rng, config, cfg, network, rng, config, cfg, forkDigests, getBeaconTime,
forkDigests, getBeaconTime, genesis_validators_root) genesis_validators_root, LightClientFinalizationMode.Optimistic)
info "Listening to incoming network requests" info "Listening to incoming network requests"
network.initBeaconSync(cfg, forkDigests, genesisBlockRoot, getBeaconTime) network.initBeaconSync(cfg, forkDigests, genesisBlockRoot, getBeaconTime)

View File

@ -79,6 +79,9 @@ suite "Light client processor" & preset():
0.82 0.82
addBlocks(numFilledEpochsPerPeriod * SLOTS_PER_EPOCH, syncCommitteeRatio) addBlocks(numFilledEpochsPerPeriod * SLOTS_PER_EPOCH, syncCommitteeRatio)
for finalizationMode in LightClientFinalizationMode:
let testNameSuffix = " (" & $finalizationMode & ")" & preset()
setup: setup:
var time = chronos.seconds(0) var time = chronos.seconds(0)
proc getBeaconTime(): BeaconTime = proc getBeaconTime(): BeaconTime =
@ -87,16 +90,16 @@ suite "Light client processor" & preset():
time = chronos.seconds((slot * SECONDS_PER_SLOT).int64) time = chronos.seconds((slot * SECONDS_PER_SLOT).int64)
var numOnStoreInitializedCalls = 0 var numOnStoreInitializedCalls = 0
proc onStoreInitialized() = inc numOnStoreInitializedCalls func onStoreInitialized() = inc numOnStoreInitializedCalls
let store = (ref Option[LightClientStore])() let store = (ref Option[LightClientStore])()
var var
processor = LightClientProcessor.new( processor = LightClientProcessor.new(
false, "", "", cfg, genesis_validators_root, false, "", "", cfg, genesis_validators_root, finalizationMode,
store, getBeaconTime, getTrustedBlockRoot, onStoreInitialized) store, getBeaconTime, getTrustedBlockRoot, onStoreInitialized)
res: Result[bool, BlockError] res: Result[bool, BlockError]
test "Sync" & preset(): test "Sync" & testNameSuffix:
let bootstrap = dag.getLightClientBootstrap(trustedBlockRoot) let bootstrap = dag.getLightClientBootstrap(trustedBlockRoot)
check bootstrap.isOk check bootstrap.isOk
setTimeToSlot(bootstrap.get.header.slot) setTimeToSlot(bootstrap.get.header.slot)
@ -106,7 +109,8 @@ suite "Light client processor" & preset():
res.isOk res.isOk
numOnStoreInitializedCalls == 1 numOnStoreInitializedCalls == 1
for period in lowPeriod .. lastPeriodWithSupermajority: # Reduce stack size by making this a `proc`
proc applyPeriodWithSupermajority(period: SyncCommitteePeriod) =
let update = dag.getLightClientUpdateForPeriod(period) let update = dag.getLightClientUpdateForPeriod(period)
check update.isSome check update.isSome
setTimeToSlot(update.get.signature_slot) setTimeToSlot(update.get.signature_slot)
@ -121,7 +125,11 @@ suite "Light client processor" & preset():
store[].get.finalized_header == bootstrap.get.header store[].get.finalized_header == bootstrap.get.header
store[].get.optimistic_header == update.get.attested_header store[].get.optimistic_header == update.get.attested_header
for period in lastPeriodWithSupermajority + 1 .. highPeriod: for period in lowPeriod .. lastPeriodWithSupermajority:
applyPeriodWithSupermajority(period)
# Reduce stack size by making this a `proc`
proc applyPeriodWithoutSupermajority(period: SyncCommitteePeriod) =
let update = dag.getLightClientUpdateForPeriod(period) let update = dag.getLightClientUpdateForPeriod(period)
check update.isSome check update.isSome
setTimeToSlot(update.get.signature_slot) setTimeToSlot(update.get.signature_slot)
@ -129,21 +137,48 @@ suite "Light client processor" & preset():
for i in 0 ..< 2: for i in 0 ..< 2:
res = processor[].storeObject( res = processor[].storeObject(
MsgSource.gossip, getBeaconTime(), update.get) MsgSource.gossip, getBeaconTime(), update.get)
if finalizationMode == LightClientFinalizationMode.Optimistic or
period == lastPeriodWithSupermajority + 1:
if finalizationMode == LightClientFinalizationMode.Optimistic or
i == 0:
check: check:
res.isOk res.isOk
store[].isSome store[].isSome
store[].get.best_valid_update.isSome store[].get.best_valid_update.isSome
store[].get.best_valid_update.get == update.get store[].get.best_valid_update.get == update.get
else:
proc applyDuplicate() = # Reduce stack size by making this a `proc`
res = processor[].storeObject(
MsgSource.gossip, getBeaconTime(), update.get)
check: check:
res.isErr res.isErr
res.error == BlockError.Duplicate res.error == BlockError.Duplicate
store[].isSome store[].isSome
store[].get.best_valid_update.isSome store[].get.best_valid_update.isSome
store[].get.best_valid_update.get == update.get store[].get.best_valid_update.get == update.get
else:
check:
res.isErr
res.error == BlockError.MissingParent
store[].isSome
store[].get.best_valid_update.isSome
store[].get.best_valid_update.get != update.get
proc applyDuplicate() = # Reduce stack size by making this a `proc`
res = processor[].storeObject(
MsgSource.gossip, getBeaconTime(), update.get)
if finalizationMode == LightClientFinalizationMode.Optimistic or
period == lastPeriodWithSupermajority + 1:
check:
res.isErr
res.error == BlockError.Duplicate
store[].isSome
store[].get.best_valid_update.isSome
store[].get.best_valid_update.get == update.get
else:
check:
res.isErr
res.error == BlockError.MissingParent
store[].isSome
store[].get.best_valid_update.isSome
store[].get.best_valid_update.get != update.get
applyDuplicate() applyDuplicate()
time += chronos.minutes(15) time += chronos.minutes(15)
@ -154,6 +189,7 @@ suite "Light client processor" & preset():
res = processor[].storeObject( res = processor[].storeObject(
MsgSource.gossip, getBeaconTime(), update.get) MsgSource.gossip, getBeaconTime(), update.get)
if finalizationMode == LightClientFinalizationMode.Optimistic:
check: check:
res.isErr res.isErr
res.error == BlockError.Duplicate res.error == BlockError.Duplicate
@ -162,7 +198,27 @@ suite "Light client processor" & preset():
if store[].get.finalized_header == update.get.attested_header: if store[].get.finalized_header == update.get.attested_header:
break break
check store[].get.finalized_header == update.get.finalized_header check store[].get.finalized_header == update.get.finalized_header
elif period == lastPeriodWithSupermajority + 1:
check:
res.isErr
res.error == BlockError.Duplicate
store[].isSome
store[].get.best_valid_update.isSome
store[].get.best_valid_update.get == update.get
else:
check:
res.isErr
res.error == BlockError.MissingParent
store[].isSome
store[].get.best_valid_update.isSome
store[].get.best_valid_update.get != update.get
if finalizationMode == LightClientFinalizationMode.Optimistic:
check store[].get.finalized_header == update.get.attested_header check store[].get.finalized_header == update.get.attested_header
else:
check store[].get.finalized_header != update.get.attested_header
for period in lastPeriodWithSupermajority + 1 .. highPeriod:
applyPeriodWithoutSupermajority(period)
let let
previousFinalized = store[].get.finalized_header previousFinalized = store[].get.finalized_header
@ -173,16 +229,19 @@ suite "Light client processor" & preset():
MsgSource.gossip, getBeaconTime(), finalityUpdate.get) MsgSource.gossip, getBeaconTime(), finalityUpdate.get)
if res.isOk: if res.isOk:
check: check:
finalizationMode == LightClientFinalizationMode.Optimistic
store[].isSome store[].isSome
store[].get.finalized_header == previousFinalized store[].get.finalized_header == previousFinalized
store[].get.best_valid_update.isSome store[].get.best_valid_update.isSome
store[].get.best_valid_update.get.matches(finalityUpdate.get) store[].get.best_valid_update.get.matches(finalityUpdate.get)
store[].get.optimistic_header == finalityUpdate.get.attested_header store[].get.optimistic_header == finalityUpdate.get.attested_header
else: elif finalizationMode == LightClientFinalizationMode.Optimistic:
check res.error == BlockError.Duplicate check res.error == BlockError.Duplicate
else:
check res.error == BlockError.MissingParent
check numOnStoreInitializedCalls == 1 check numOnStoreInitializedCalls == 1
test "Invalid bootstrap" & preset(): test "Invalid bootstrap" & testNameSuffix:
var bootstrap = dag.getLightClientBootstrap(trustedBlockRoot) var bootstrap = dag.getLightClientBootstrap(trustedBlockRoot)
check bootstrap.isOk check bootstrap.isOk
bootstrap.get.header.slot.inc() bootstrap.get.header.slot.inc()
@ -194,7 +253,7 @@ suite "Light client processor" & preset():
res.error == BlockError.Invalid res.error == BlockError.Invalid
numOnStoreInitializedCalls == 0 numOnStoreInitializedCalls == 0
test "Duplicate bootstrap" & preset(): test "Duplicate bootstrap" & testNameSuffix:
let bootstrap = dag.getLightClientBootstrap(trustedBlockRoot) let bootstrap = dag.getLightClientBootstrap(trustedBlockRoot)
check bootstrap.isOk check bootstrap.isOk
setTimeToSlot(bootstrap.get.header.slot) setTimeToSlot(bootstrap.get.header.slot)
@ -210,7 +269,7 @@ suite "Light client processor" & preset():
res.error == BlockError.Duplicate res.error == BlockError.Duplicate
numOnStoreInitializedCalls == 1 numOnStoreInitializedCalls == 1
test "Missing bootstrap (update)" & preset(): test "Missing bootstrap (update)" & testNameSuffix:
let update = dag.getLightClientUpdateForPeriod(lowPeriod) let update = dag.getLightClientUpdateForPeriod(lowPeriod)
check update.isSome check update.isSome
setTimeToSlot(update.get.signature_slot) setTimeToSlot(update.get.signature_slot)
@ -221,7 +280,7 @@ suite "Light client processor" & preset():
res.error == BlockError.MissingParent res.error == BlockError.MissingParent
numOnStoreInitializedCalls == 0 numOnStoreInitializedCalls == 0
test "Missing bootstrap (finality update)" & preset(): test "Missing bootstrap (finality update)" & testNameSuffix:
let finalityUpdate = dag.getLightClientFinalityUpdate() let finalityUpdate = dag.getLightClientFinalityUpdate()
check finalityUpdate.isSome check finalityUpdate.isSome
setTimeToSlot(finalityUpdate.get.signature_slot) setTimeToSlot(finalityUpdate.get.signature_slot)
@ -232,7 +291,7 @@ suite "Light client processor" & preset():
res.error == BlockError.MissingParent res.error == BlockError.MissingParent
numOnStoreInitializedCalls == 0 numOnStoreInitializedCalls == 0
test "Missing bootstrap (optimistic update)" & preset(): test "Missing bootstrap (optimistic update)" & testNameSuffix:
let optimisticUpdate = dag.getLightClientOptimisticUpdate() let optimisticUpdate = dag.getLightClientOptimisticUpdate()
check optimisticUpdate.isSome check optimisticUpdate.isSome
setTimeToSlot(optimisticUpdate.get.signature_slot) setTimeToSlot(optimisticUpdate.get.signature_slot)