Skip to content

Commit

Permalink
builder API liveness failsafe (#4746)
Browse files Browse the repository at this point in the history
* builder API liveness failsafe

* add test summary change
  • Loading branch information
tersec authored Mar 22, 2023
1 parent c9eb89e commit fc1f9a2
Show file tree
Hide file tree
Showing 4 changed files with 176 additions and 25 deletions.
5 changes: 3 additions & 2 deletions AllTests-mainnet.md
Original file line number Diff line number Diff line change
Expand Up @@ -258,11 +258,12 @@ OK: 1/1 Fail: 0/1 Skip: 0/1
## Honest validator
```diff
+ General pubsub topics OK
+ Liveness failsafe conditions OK
+ Mainnet attestation topics OK
+ isNearSyncCommitteePeriod OK
+ is_aggregator OK
```
OK: 4/4 Fail: 0/4 Skip: 0/4
OK: 5/5 Fail: 0/5 Skip: 0/5
## ImportKeystores requests [Beacon Node] [Preset: mainnet]
```diff
+ ImportKeystores/ListKeystores/DeleteKeystores [Beacon Node] [Preset: mainnet] OK
Expand Down Expand Up @@ -635,4 +636,4 @@ OK: 2/2 Fail: 0/2 Skip: 0/2
OK: 9/9 Fail: 0/9 Skip: 0/9

---TOTAL---
OK: 352/357 Fail: 0/357 Skip: 5/357
OK: 353/358 Fail: 0/358 Skip: 5/358
52 changes: 52 additions & 0 deletions beacon_chain/spec/validator.nim
Original file line number Diff line number Diff line change
Expand Up @@ -409,3 +409,55 @@ func is_aggregator*(committee_len: uint64, slot_signature: ValidatorSig): bool =
modulo = max(1'u64, committee_len div TARGET_AGGREGATORS_PER_COMMITTEE)
bytes_to_uint64(eth2digest(
slot_signature.toRaw()).data.toOpenArray(0, 7)) mod modulo == 0

# https://github.com/ethereum/builder-specs/pull/47
func livenessFailsafeInEffect*(
block_roots: array[Limit SLOTS_PER_HISTORICAL_ROOT, Eth2Digest],
slot: Slot): bool =
const
MAX_MISSING_CONTIGUOUS = 3
MAX_MISSING_WINDOW = 5

static: doAssert MAX_MISSING_WINDOW > MAX_MISSING_CONTIGUOUS
if slot <= MAX_MISSING_CONTIGUOUS:
# Cannot ever trigger and allows a bit of safe arithmetic. Furthermore
# there's notionally always a genesis block, which pushes the earliest
# possible failure out an additional slot.
return false

# Using this slightly convoluted construction to handle wraparound better;
# baseIndex + faultInspectionWindow can overflow array but only exactly by
# the required amount. Furthermore, go back one more slot to address using
# that it looks ahead rather than looks back and whether a block's missing
# requires seeing the previous block_root.
let
faultInspectionWindow = min(distinctBase(slot) - 1, SLOTS_PER_EPOCH)
baseIndex = (slot + SLOTS_PER_HISTORICAL_ROOT - faultInspectionWindow) mod
SLOTS_PER_HISTORICAL_ROOT
endIndex = baseIndex + faultInspectionWindow - 1

doAssert endIndex mod SLOTS_PER_HISTORICAL_ROOT ==
(slot - 1) mod SLOTS_PER_HISTORICAL_ROOT

var
totalMissing = 0
streakLen = 0
maxStreakLen = 0

for i in baseIndex .. endIndex:
# This look-forward means checking slot i for being missing uses i - 1
if block_roots[(i mod SLOTS_PER_HISTORICAL_ROOT).int] ==
block_roots[((i + 1) mod SLOTS_PER_HISTORICAL_ROOT).int]:
totalMissing += 1
if totalMissing > MAX_MISSING_WINDOW:
return true

streakLen += 1
if streakLen > maxStreakLen:
maxStreakLen = streakLen
if maxStreakLen > MAX_MISSING_CONTIGUOUS:
return true
else:
streakLen = 0

false
61 changes: 38 additions & 23 deletions beacon_chain/validators/validator_duties.nim
Original file line number Diff line number Diff line change
Expand Up @@ -681,6 +681,7 @@ proc proposeBlockMEV[
node: BeaconNode, head: BlockRef, validator: AttachedValidator, slot: Slot,
randao: ValidatorSig, validator_index: ValidatorIndex):
Future[Opt[BlockRef]] {.async.} =
# Used by the BN's own validators, but not the REST server
when SBBB is bellatrix_mev.SignedBlindedBeaconBlock:
type EPH = bellatrix.ExecutionPayloadHeader
elif SBBB is capella_mev.SignedBlindedBeaconBlock:
Expand Down Expand Up @@ -749,6 +750,9 @@ proc makeBlindedBeaconBlockForHeadAndSlot*[
## Requests a beacon node to produce a valid blinded block, which can then be
## signed by a validator. A blinded block is a block with only a transactions
## root, rather than a full transactions list.
##
## This function is used by the validator client, but not the beacon node for
## its own validators.
when BBB is bellatrix_mev.BlindedBeaconBlock:
type EPH = bellatrix.ExecutionPayloadHeader
elif BBB is capella_mev.BlindedBeaconBlock:
Expand All @@ -760,6 +764,11 @@ proc makeBlindedBeaconBlockForHeadAndSlot*[
pubkey =
# Relevant state for knowledge of validators
withState(node.dag.headState):
if livenessFailsafeInEffect(
forkyState.data.block_roots.data, forkyState.data.slot):
# It's head block's slot which matters here, not proposal slot
return err("Builder API liveness failsafe in effect")

if distinctBase(validator_index) >= forkyState.data.validators.lenu64:
debug "makeBlindedBeaconBlockForHeadAndSlot: invalid validator index",
head = shortLog(head),
Expand Down Expand Up @@ -819,29 +828,35 @@ proc proposeBlock(node: BeaconNode,
res.get()

if node.config.payloadBuilderEnable:
let newBlockMEV =
if slot.epoch >= node.dag.cfg.DENEB_FORK_EPOCH:
debugRaiseAssert $denebImplementationMissing & ": proposeBlock"
await proposeBlockMEV[
capella_mev.SignedBlindedBeaconBlock](
node, head, validator, slot, randao, validator_index)
elif slot.epoch >= node.dag.cfg.CAPELLA_FORK_EPOCH:
await proposeBlockMEV[
capella_mev.SignedBlindedBeaconBlock](
node, head, validator, slot, randao, validator_index)
else:
await proposeBlockMEV[
bellatrix_mev.SignedBlindedBeaconBlock](
node, head, validator, slot, randao, validator_index)

if newBlockMEV.isSome:
# This might be equivalent to the `head` passed in, but it signals that
# `submitBlindedBlock` ran, so don't do anything else. Otherwise, it is
# fine to try again with the local EL.
if newBlockMEV.get == head:
# Returning same block as head indicates failure to generate new block
beacon_block_builder_missed_without_fallback.inc()
return newBlockMEV.get
let failsafeInEffect =
withState(node.dag.headState):
# Head slot, not proposal slot, matters here
livenessFailsafeInEffect(
forkyState.data.block_roots.data, forkyState.data.slot)
if not failsafeInEffect:
let newBlockMEV =
if slot.epoch >= node.dag.cfg.DENEB_FORK_EPOCH:
debugRaiseAssert $denebImplementationMissing & ": proposeBlock"
await proposeBlockMEV[
capella_mev.SignedBlindedBeaconBlock](
node, head, validator, slot, randao, validator_index)
elif slot.epoch >= node.dag.cfg.CAPELLA_FORK_EPOCH:
await proposeBlockMEV[
capella_mev.SignedBlindedBeaconBlock](
node, head, validator, slot, randao, validator_index)
else:
await proposeBlockMEV[
bellatrix_mev.SignedBlindedBeaconBlock](
node, head, validator, slot, randao, validator_index)

if newBlockMEV.isSome:
# This might be equivalent to the `head` passed in, but it signals that
# `submitBlindedBlock` ran, so don't do anything else. Otherwise, it is
# fine to try again with the local EL.
if newBlockMEV.get == head:
# Returning same block as head indicates failure to generate new block
beacon_block_builder_missed_without_fallback.inc()
return newBlockMEV.get

# TODO Compare the value of the MEV block and the execution block
# obtained from the EL below:
Expand Down
83 changes: 83 additions & 0 deletions tests/test_honest_validator.nim
Original file line number Diff line number Diff line change
Expand Up @@ -198,3 +198,86 @@ suite "Honest validator":
for i in 1'u64 .. 20'u64:
for j in (SYNC_COMMITTEE_SUBNET_COUNT + 1'u64) .. 7'u64:
check: nearSyncCommitteePeriod((EPOCHS_PER_SYNC_COMMITTEE_PERIOD * i - j).Epoch).isNone

test "Liveness failsafe conditions":
var x: array[Limit SLOTS_PER_HISTORICAL_ROOT, Eth2Digest]
const MAX_MISSING_CONTIGUOUS = 3
const MAX_MISSING_WINDOW = 5
const FAULT_INSPECTION_WINDOW = 32

# There haven't been enough slots to trigger any of the conditions
for i in 0 .. MAX_MISSING_CONTIGUOUS + 1:
check: not livenessFailsafeInEffect(x, i.Slot)
# But once there are, the default all-equals array shouldn't allow it. An
# additional slot is gained because it's notionally not possible for some
# genesis block not to exist.
for i in MAX_MISSING_CONTIGUOUS + 2 .. FAULT_INSPECTION_WINDOW + 10:
check: livenessFailsafeInEffect(x, i.Slot)

for i in FAULT_INSPECTION_WINDOW * 2 ..< FAULT_INSPECTION_WINDOW * 3:
x[i].data[0] = i.uint8

# There haven't been enough slots to trigger any of the conditions; unlike
# first round this doesn't line up with genesis-adjacent slots and doesn't
# have that additional genesis block additional-slot-before-trigger.
for i in
FAULT_INSPECTION_WINDOW * 3 ..
FAULT_INSPECTION_WINDOW * 3 + MAX_MISSING_CONTIGUOUS:
check: not livenessFailsafeInEffect(x, i.Slot)
for i in
FAULT_INSPECTION_WINDOW * 3 + MAX_MISSING_CONTIGUOUS + 1 ..
FAULT_INSPECTION_WINDOW * 4:
check: livenessFailsafeInEffect(x, i.Slot)

# This time, add some extant blocks to extend non-liveness-failsafe conditions
for i in FAULT_INSPECTION_WINDOW * 4 ..< FAULT_INSPECTION_WINDOW * 5:
x[i].data[0] = i.uint8
# extend last entry to simulate missing blocks
for i in
FAULT_INSPECTION_WINDOW * 5 ..<
FAULT_INSPECTION_WINDOW * 5 + MAX_MISSING_CONTIGUOUS:
x[i].data[0] = (FAULT_INSPECTION_WINDOW * 5 - 1).uint8
# next real block
x[FAULT_INSPECTION_WINDOW * 5 + MAX_MISSING_CONTIGUOUS].data[0] = 34

for i in
FAULT_INSPECTION_WINDOW * 5 ..
FAULT_INSPECTION_WINDOW * 3 + MAX_MISSING_CONTIGUOUS * 2:
check: not livenessFailsafeInEffect(x, i.Slot)
for i in
FAULT_INSPECTION_WINDOW * 5 + MAX_MISSING_CONTIGUOUS * 2 + 1 ..
FAULT_INSPECTION_WINDOW * 6:
check: livenessFailsafeInEffect(x, i.Slot)

# Add some all-present blocks for a few epochs
for i in FAULT_INSPECTION_WINDOW * 6 ..< FAULT_INSPECTION_WINDOW * 9:
x[i].data[0] = i.uint8
static: doAssert MAX_MISSING_WINDOW > MAX_MISSING_CONTIGUOUS
# This satisfies contiguous-missing limit, but not total-per-window limit
for i in countup(
FAULT_INSPECTION_WINDOW * 9,
FAULT_INSPECTION_WINDOW * 9 + MAX_MISSING_CONTIGUOUS * 2, 2):
x[i].data[0] = i.uint8
x[i + 1].data[0] = i.uint8 # missing block

for i in
FAULT_INSPECTION_WINDOW * 7 ..
FAULT_INSPECTION_WINDOW * 9 + MAX_MISSING_WINDOW * 2 - 1:
# i.e. two fullly covered epochs then get into MAX_MISSING_WINDOW * 2 - 1
# of the every-other-block is present. Because only MAX_MISSING_WINDOW of
# these can exist, it's the ones at (FIW*9 base of 0): 1, 3, 5, 7, 9 that
# are missing. Can get up to 9 here, i.e. by 2 * MAX_MISSING_WINDOW, as a
# result of 50% duty cycle pattern.
check: not livenessFailsafeInEffect(x, i.Slot)
for i in
FAULT_INSPECTION_WINDOW * 9 + MAX_MISSING_WINDOW * 2 ..
FAULT_INSPECTION_WINDOW * 10:
check: livenessFailsafeInEffect(x, i.Slot)

# Check wraparound is sane; same mod-equivalent slots but actually near
# genesis don't trigger liveness failures, as they clamp the inspection
# window at element 0 of array rather than wrapping backwards.
for i in
SLOTS_PER_HISTORICAL_ROOT ..
SLOTS_PER_HISTORICAL_ROOT + FAULT_INSPECTION_WINDOW:
check: livenessFailsafeInEffect(x, i.Slot)

0 comments on commit fc1f9a2

Please sign in to comment.