Skip to content

Commit

Permalink
avoid quadratic behavior exiting validators (#6161)
Browse files Browse the repository at this point in the history
* avoid quadratic behavior exiting validators

* fix libnfuzz callers
  • Loading branch information
tersec authored Apr 2, 2024
1 parent 4457334 commit 109007d
Show file tree
Hide file tree
Showing 10 changed files with 184 additions and 79 deletions.
70 changes: 41 additions & 29 deletions beacon_chain/spec/beaconstate.nim
Original file line number Diff line number Diff line change
Expand Up @@ -90,18 +90,9 @@ func get_validator_activation_churn_limit*(
cfg.MAX_PER_EPOCH_ACTIVATION_CHURN_LIMIT,
get_validator_churn_limit(cfg, state, cache))

# https://github.com/ethereum/consensus-specs/blob/v1.4.0-beta.6/specs/phase0/beacon-chain.md#initiate_validator_exit
func initiate_validator_exit*(
cfg: RuntimeConfig, state: var ForkyBeaconState,
index: ValidatorIndex, cache: var StateCache): Result[void, cstring] =
## Initiate the exit of the validator with index ``index``.

if state.validators.item(index).exit_epoch != FAR_FUTURE_EPOCH:
return ok() # Before touching cache

# Return if validator already initiated exit
let validator = addr state.validators.mitem(index)

# https://github.com/ethereum/consensus-specs/blob/v1.4.0/specs/phase0/beacon-chain.md#initiate_validator_exit
func get_state_exit_queue_info*(
cfg: RuntimeConfig, state: var ForkyBeaconState, cache: var StateCache): ExitQueueInfo =
var
exit_queue_epoch = compute_activation_exit_epoch(get_current_epoch(state))
exit_queue_churn: uint64
Expand All @@ -125,9 +116,34 @@ func initiate_validator_exit*(
if exit_epoch == exit_queue_epoch:
inc exit_queue_churn

ExitQueueInfo(
exit_queue_epoch: exit_queue_epoch, exit_queue_churn: exit_queue_churn)

# https://github.com/ethereum/consensus-specs/blob/v1.4.0/specs/phase0/beacon-chain.md#initiate_validator_exit
func initiate_validator_exit*(
cfg: RuntimeConfig, state: var ForkyBeaconState,
index: ValidatorIndex, exit_queue_info: ExitQueueInfo, cache: var StateCache):
Result[ExitQueueInfo, cstring] =
## Initiate the exit of the validator with index ``index``.

if state.validators.item(index).exit_epoch != FAR_FUTURE_EPOCH:
return ok(exit_queue_info) # Before touching cache

# Return if validator already initiated exit
let validator = addr state.validators.mitem(index)

var
exit_queue_epoch = exit_queue_info.exit_queue_epoch
exit_queue_churn = exit_queue_info.exit_queue_churn

if exit_queue_churn >= get_validator_churn_limit(cfg, state, cache):
inc exit_queue_epoch

# Bookkeeping for inter-operation caching; include this exit for next time
exit_queue_churn = 1
else:
inc exit_queue_churn

# Set validator exit epoch and withdrawable epoch
validator.exit_epoch = exit_queue_epoch

Expand All @@ -138,7 +154,8 @@ func initiate_validator_exit*(
validator.withdrawable_epoch =
validator.exit_epoch + cfg.MIN_VALIDATOR_WITHDRAWABILITY_DELAY

ok()
ok(ExitQueueInfo(
exit_queue_epoch: exit_queue_epoch, exit_queue_churn: exit_queue_churn))

from ./datatypes/deneb import BeaconState

Expand Down Expand Up @@ -183,23 +200,16 @@ func get_proposer_reward(state: ForkyBeaconState, whistleblower_reward: Gwei): G
# https://github.com/ethereum/consensus-specs/blob/v1.4.0/specs/bellatrix/beacon-chain.md#modified-slash_validator
proc slash_validator*(
cfg: RuntimeConfig, state: var ForkyBeaconState,
slashed_index: ValidatorIndex, cache: var StateCache):
Result[Gwei, cstring] =
slashed_index: ValidatorIndex, pre_exit_queue_info: ExitQueueInfo,
cache: var StateCache): Result[(Gwei, ExitQueueInfo), cstring] =
## Slash the validator with index ``index``.
let epoch = get_current_epoch(state)
? initiate_validator_exit(cfg, state, slashed_index, cache)
let
epoch = get_current_epoch(state)
post_exit_queue_info = ? initiate_validator_exit(
cfg, state, slashed_index, pre_exit_queue_info, cache)

let validator = addr state.validators.mitem(slashed_index)

trace "slash_validator: ejecting validator via slashing (validator_leaving)",
index = slashed_index,
num_validators = state.validators.len,
current_epoch = get_current_epoch(state),
validator_slashed = validator.slashed,
validator_withdrawable_epoch = validator.withdrawable_epoch,
validator_exit_epoch = validator.exit_epoch,
validator_effective_balance = validator.effective_balance

validator.slashed = true
validator.withdrawable_epoch =
max(validator.withdrawable_epoch, epoch + EPOCHS_PER_SLASHINGS_VECTOR)
Expand All @@ -212,7 +222,7 @@ proc slash_validator*(
# The rest doesn't make sense without there being any proposer index, so skip
let proposer_index = get_beacon_proposer_index(state, cache).valueOr:
debug "No beacon proposer index and probably no active validators"
return ok(0.Gwei)
return ok((0.Gwei, post_exit_queue_info))

# Apply proposer and whistleblower rewards
let
Expand All @@ -223,11 +233,13 @@ proc slash_validator*(

increase_balance(state, proposer_index, proposer_reward)
# TODO: evaluate if spec bug / underflow can be triggered
doAssert(whistleblower_reward >= proposer_reward, "Spec bug: underflow in slash_validator")
doAssert(
whistleblower_reward >= proposer_reward,
"Spec bug: underflow in slash_validator")
increase_balance(
state, whistleblower_index, whistleblower_reward - proposer_reward)

ok(proposer_reward)
ok((proposer_reward, post_exit_queue_info))

func genesis_time_from_eth1_timestamp(
cfg: RuntimeConfig, eth1_timestamp: uint64): uint64 =
Expand Down
4 changes: 4 additions & 0 deletions beacon_chain/spec/datatypes/base.nim
Original file line number Diff line number Diff line change
Expand Up @@ -580,6 +580,10 @@ type

flags*: set[RewardFlags]

ExitQueueInfo* = object
exit_queue_epoch*: Epoch
exit_queue_churn*: uint64

func pubkey*(v: HashedValidatorPubKey): ValidatorPubKey =
if isNil(v.value):
# This should never happen but we guard against it in case a
Expand Down
54 changes: 38 additions & 16 deletions beacon_chain/spec/state_transition_block.nim
Original file line number Diff line number Diff line change
Expand Up @@ -186,9 +186,10 @@ proc check_proposer_slashing*(
proc process_proposer_slashing*(
cfg: RuntimeConfig, state: var ForkyBeaconState,
proposer_slashing: SomeProposerSlashing, flags: UpdateFlags,
cache: var StateCache): Result[Gwei, cstring] =
exit_queue_info: ExitQueueInfo, cache: var StateCache):
Result[(Gwei, ExitQueueInfo), cstring] =
let proposer_index = ? check_proposer_slashing(state, proposer_slashing, flags)
slash_validator(cfg, state, proposer_index, cache)
slash_validator(cfg, state, proposer_index, exit_queue_info, cache)

# https://github.com/ethereum/consensus-specs/blob/v1.4.0-beta.6/specs/phase0/beacon-chain.md#is_slashable_attestation_data
func is_slashable_attestation_data(
Expand Down Expand Up @@ -250,17 +251,24 @@ proc process_attester_slashing*(
state: var ForkyBeaconState,
attester_slashing: SomeAttesterSlashing,
flags: UpdateFlags,
cache: var StateCache
): Result[Gwei, cstring] =
exit_queue_info: ExitQueueInfo, cache: var StateCache
): Result[(Gwei, ExitQueueInfo), cstring] =
let slashed_attesters =
? check_attester_slashing(state, attester_slashing, flags)

var proposer_reward: Gwei
var
proposer_reward: Gwei
cur_exit_queue_info = exit_queue_info

for index in slashed_attesters:
proposer_reward += ? slash_validator(cfg, state, index, cache)
doAssert strictVerification notin flags or
cur_exit_queue_info == get_state_exit_queue_info(cfg, state, cache)
let (new_proposer_reward, new_exit_queue_info) = ? slash_validator(
cfg, state, index, cur_exit_queue_info, cache)
proposer_reward += new_proposer_reward
cur_exit_queue_info = new_exit_queue_info

ok(proposer_reward)
ok((proposer_reward, cur_exit_queue_info))

func findValidatorIndex*(state: ForkyBeaconState, pubkey: ValidatorPubKey):
Opt[ValidatorIndex] =
Expand Down Expand Up @@ -410,12 +418,12 @@ proc process_voluntary_exit*(
cfg: RuntimeConfig,
state: var ForkyBeaconState,
signed_voluntary_exit: SomeSignedVoluntaryExit,
flags: UpdateFlags,
cache: var StateCache): Result[void, cstring] =
flags: UpdateFlags, exit_queue_info: ExitQueueInfo,
cache: var StateCache): Result[ExitQueueInfo, cstring] =
let exited_validator =
? check_voluntary_exit(cfg, state, signed_voluntary_exit, flags)
? initiate_validator_exit(cfg, state, exited_validator, cache)
ok()
ok(? initiate_validator_exit(
cfg, state, exited_validator, exit_queue_info, cache))

proc process_bls_to_execution_change*(
cfg: RuntimeConfig,
Expand Down Expand Up @@ -464,12 +472,25 @@ proc process_operations(cfg: RuntimeConfig,

var operations_rewards: BlockRewards

# It costs a full validator set scan to construct these values; only do so if
# there will be some kind of exit.
var exit_queue_info =
if body.proposer_slashings.len + body.attester_slashings.len +
body.voluntary_exits.len > 0:
get_state_exit_queue_info(cfg, state, cache)
else:
default(ExitQueueInfo) # not used

for op in body.proposer_slashings:
operations_rewards.proposer_slashings +=
? process_proposer_slashing(cfg, state, op, flags, cache)
let (proposer_slashing_reward, new_exit_queue_info) =
? process_proposer_slashing(cfg, state, op, flags, exit_queue_info, cache)
operations_rewards.proposer_slashings += proposer_slashing_reward
exit_queue_info = new_exit_queue_info
for op in body.attester_slashings:
operations_rewards.attester_slashings +=
? process_attester_slashing(cfg, state, op, flags, cache)
let (attester_slashing_reward, new_exit_queue_info) =
? process_attester_slashing(cfg, state, op, flags, exit_queue_info, cache)
operations_rewards.attester_slashings += attester_slashing_reward
exit_queue_info = new_exit_queue_info
for op in body.attestations:
operations_rewards.attestations +=
? process_attestation(state, op, flags, base_reward_per_increment, cache)
Expand All @@ -478,7 +499,8 @@ proc process_operations(cfg: RuntimeConfig,
for op in body.deposits:
? process_deposit(cfg, state, bloom_filter[], op, flags)
for op in body.voluntary_exits:
? process_voluntary_exit(cfg, state, op, flags, cache)
exit_queue_info = ? process_voluntary_exit(
cfg, state, op, flags, exit_queue_info, cache)
when typeof(body).kind >= ConsensusFork.Capella:
for op in body.bls_to_execution_changes:
? process_bls_to_execution_change(cfg, state, op)
Expand Down
15 changes: 14 additions & 1 deletion beacon_chain/spec/state_transition_epoch.nim
Original file line number Diff line number Diff line change
Expand Up @@ -897,14 +897,27 @@ func process_registry_updates*(
get_validator_activation_churn_limit(cfg, state, cache)
else:
get_validator_churn_limit(cfg, state, cache)

var maybe_exit_queue_info: Opt[ExitQueueInfo]

for vidx in state.validators.vindices:
if is_eligible_for_activation_queue(state.validators.item(vidx)):
state.validators.mitem(vidx).activation_eligibility_epoch =
get_current_epoch(state) + 1

if is_active_validator(state.validators.item(vidx), get_current_epoch(state)) and
state.validators.item(vidx).effective_balance <= cfg.EJECTION_BALANCE.Gwei:
? initiate_validator_exit(cfg, state, vidx, cache)
# Typically, there will be no ejected validators, and even more rarely,
# more than one. Therefore, only calculate the information required for
# initiate_validator_exit if there actually is at least one.
let exit_queue_info = maybe_exit_queue_info.valueOr:
let initial_exit_queue_info = get_state_exit_queue_info(
cfg, state, cache)
maybe_exit_queue_info = Opt.some initial_exit_queue_info
initial_exit_queue_info

maybe_exit_queue_info = Opt.some (? initiate_validator_exit(
cfg, state, vidx, exit_queue_info, cache))

let validator = unsafeAddr state.validators.item(vidx)
if is_eligible_for_activation(state, validator[]):
Expand Down
14 changes: 10 additions & 4 deletions nfuzz/libnfuzz.nim
Original file line number Diff line number Diff line change
Expand Up @@ -108,7 +108,9 @@ proc nfuzz_attestation(input: openArray[byte], xoutput: ptr byte,
proc nfuzz_attester_slashing(input: openArray[byte], xoutput: ptr byte,
xoutput_size: ptr uint, disable_bls: bool): bool {.exportc, raises: [FuzzCrashError].} =
decodeAndProcess(AttesterSlashingInput):
process_attester_slashing(getRuntimeConfig(some "mainnet"), data.state, data.attesterSlashing, flags, cache).isOk
process_attester_slashing(getRuntimeConfig(some "mainnet"), data.state,
data.attesterSlashing, flags, get_state_exit_queue_info(
getRuntimeConfig(some "mainnet"), data.state, cache), cache).isOk

proc nfuzz_block(input: openArray[byte], xoutput: ptr byte,
xoutput_size: ptr uint, disable_bls: bool): bool {.exportc, raises: [FuzzCrashError].} =
Expand Down Expand Up @@ -152,12 +154,16 @@ proc nfuzz_deposit(input: openArray[byte], xoutput: ptr byte,
proc nfuzz_proposer_slashing(input: openArray[byte], xoutput: ptr byte,
xoutput_size: ptr uint, disable_bls: bool): bool {.exportc, raises: [FuzzCrashError].} =
decodeAndProcess(ProposerSlashingInput):
process_proposer_slashing(getRuntimeConfig(some "mainnet"), data.state, data.proposerSlashing, flags, cache).isOk
process_proposer_slashing(getRuntimeConfig(some "mainnet"), data.state,
data.proposerSlashing, flags, get_state_exit_queue_info(
getRuntimeConfig(some "mainnet"), data.state, cache), cache).isOk

proc nfuzz_voluntary_exit(input: openArray[byte], xoutput: ptr byte,
xoutput_size: ptr uint, disable_bls: bool): bool {.exportc, raises: [FuzzCrashError].} =
decodeAndProcess(VoluntaryExitInput):
process_voluntary_exit(getRuntimeConfig(some "mainnet"), data.state, data.exit, flags, cache).isOk
process_voluntary_exit(getRuntimeConfig(some "mainnet"), data.state,
data.exit, flags, get_state_exit_queue_info(
getRuntimeConfig(some "mainnet"), data.state, cache), cache).isOk

# Note: Could also accept raw input pointer and access list_size + seed here.
# However, list_size needs to be known also outside this proc to allocate xoutput.
Expand All @@ -182,4 +188,4 @@ func nfuzz_shuffle(input_seed: ptr byte, xoutput: var openArray[uint64]): bool
copyMem(offset(addr xoutput, i), shuffled_seq[i].unsafeAddr,
sizeof(ValidatorIndex))

true
true
19 changes: 14 additions & 5 deletions tests/consensus_spec/altair/test_fixture_operations.nim
Original file line number Diff line number Diff line change
Expand Up @@ -95,7 +95,9 @@ suite baseDescription & "Attester Slashing " & preset():
Result[void, cstring] =
var cache: StateCache
doAssert (? process_attester_slashing(
defaultRuntimeConfig, preState, attesterSlashing, {}, cache)) > 0.Gwei
defaultRuntimeConfig, preState, attesterSlashing, {strictVerification},
get_state_exit_queue_info(defaultRuntimeConfig, preState, cache),
cache))[0] > 0.Gwei
ok()

for path in walkTests(OpAttSlashingDir):
Expand Down Expand Up @@ -134,7 +136,9 @@ suite baseDescription & "Proposer Slashing " & preset():
Result[void, cstring] =
var cache: StateCache
doAssert (? process_proposer_slashing(
defaultRuntimeConfig, preState, proposerSlashing, {}, cache)) > 0.Gwei
defaultRuntimeConfig, preState, proposerSlashing, {},
get_state_exit_queue_info(defaultRuntimeConfig, preState, cache),
cache))[0] > 0.Gwei
ok()

for path in walkTests(OpProposerSlashingDir):
Expand Down Expand Up @@ -162,10 +166,15 @@ suite baseDescription & "Voluntary Exit " & preset():
preState: var altair.BeaconState, voluntaryExit: SignedVoluntaryExit):
Result[void, cstring] =
var cache: StateCache
process_voluntary_exit(
defaultRuntimeConfig, preState, voluntaryExit, {}, cache)
if process_voluntary_exit(
defaultRuntimeConfig, preState, voluntaryExit, {},
get_state_exit_queue_info(defaultRuntimeConfig, preState, cache),
cache).isOk:
ok()
else:
err("")

for path in walkTests(OpVoluntaryExitDir):
runTest[SignedVoluntaryExit, typeof applyVoluntaryExit](
OpVoluntaryExitDir, suiteName, "Voluntary Exit", "voluntary_exit",
applyVoluntaryExit, path)
applyVoluntaryExit, path)
22 changes: 16 additions & 6 deletions tests/consensus_spec/bellatrix/test_fixture_operations.nim
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,8 @@ import
from std/sequtils import mapIt, toSeq
from std/strutils import contains
from ../../../beacon_chain/spec/beaconstate import
get_base_reward_per_increment, get_total_active_balance, process_attestation
get_base_reward_per_increment, get_state_exit_queue_info,
get_total_active_balance, process_attestation

const
OpDir = SszTestsDir/const_preset/"bellatrix"/"operations"
Expand Down Expand Up @@ -100,7 +101,9 @@ suite baseDescription & "Attester Slashing " & preset():
Result[void, cstring] =
var cache: StateCache
doAssert (? process_attester_slashing(
defaultRuntimeConfig, preState, attesterSlashing, {}, cache)) > 0.Gwei
defaultRuntimeConfig, preState, attesterSlashing, {strictVerification},
get_state_exit_queue_info(defaultRuntimeConfig, preState, cache),
cache))[0] > 0.Gwei
ok()

for path in walkTests(OpAttSlashingDir):
Expand Down Expand Up @@ -158,7 +161,9 @@ suite baseDescription & "Proposer Slashing " & preset():
Result[void, cstring] =
var cache: StateCache
doAssert (? process_proposer_slashing(
defaultRuntimeConfig, preState, proposerSlashing, {}, cache)) > 0.Gwei
defaultRuntimeConfig, preState, proposerSlashing, {},
get_state_exit_queue_info(defaultRuntimeConfig, preState, cache),
cache))[0] > 0.Gwei
ok()

for path in walkTests(OpProposerSlashingDir):
Expand Down Expand Up @@ -186,10 +191,15 @@ suite baseDescription & "Voluntary Exit " & preset():
preState: var bellatrix.BeaconState, voluntaryExit: SignedVoluntaryExit):
Result[void, cstring] =
var cache: StateCache
process_voluntary_exit(
defaultRuntimeConfig, preState, voluntaryExit, {}, cache)
if process_voluntary_exit(
defaultRuntimeConfig, preState, voluntaryExit, {},
get_state_exit_queue_info(defaultRuntimeConfig, preState, cache),
cache).isOk:
ok()
else:
err("")

for path in walkTests(OpVoluntaryExitDir):
runTest[SignedVoluntaryExit, typeof applyVoluntaryExit](
OpVoluntaryExitDir, suiteName, "Voluntary Exit", "voluntary_exit",
applyVoluntaryExit, path)
applyVoluntaryExit, path)
Loading

0 comments on commit 109007d

Please sign in to comment.