nimbus-eth2/beacon_chain/fork_choice/fork_choice.nim

585 lines
18 KiB
Nim
Raw Normal View History

# beacon_chain
# Copyright (c) 2018-2020 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.
2020-04-15 09:21:22 +00:00
{.push raises: [Defect].}
import
# Standard library
std/tables, std/options, std/typetraits,
# Status libraries
stew/results,
# Internal
../spec/[datatypes, digest],
# Fork choice
./fork_choice_types, ./proto_array
# https://github.com/ethereum/eth2.0-specs/blob/v0.11.1/specs/phase0/fork-choice.md
# This is a port of https://github.com/sigp/lighthouse/pull/804
# which is a port of "Proto-Array": https://github.com/protolambda/lmd-ghost
# See also:
# - Protolambda port of Lighthouse: https://github.com/protolambda/eth2-py-hacks/blob/ae286567/proto_array.py
# - Prysmatic writeup: https://hackmd.io/bABJiht3Q9SyV3Ga4FT9lQ#High-level-concept
# - Gasper Whitepaper: https://arxiv.org/abs/2003.03052
const DefaultPruneThreshold = 256
# Forward declarations
# ----------------------------------------------------------------------
func compute_deltas(
deltas: var openarray[Delta],
indices: Table[Eth2Digest, Index],
votes: var openArray[VoteTracker],
old_balances: openarray[Gwei],
new_balances: openarray[Gwei]
2020-04-15 09:21:22 +00:00
): ForkChoiceError
# TODO: raises [Defect] - once https://github.com/nim-lang/Nim/issues/12862 is fixed
# https://github.com/status-im/nim-beacon-chain/pull/865#pullrequestreview-389117232
# Fork choice routines
# ----------------------------------------------------------------------
# API:
# - The private procs uses the ForkChoiceError error code
# - The public procs use Result
func initForkChoice*(
finalized_block_slot: Slot,
finalized_block_state_root: Eth2Digest,
justified_epoch: Epoch,
finalized_epoch: Epoch,
finalized_root: Eth2Digest
2020-04-15 09:21:22 +00:00
): Result[ForkChoice, string] =
## Initialize a fork choice context
var proto_array = ProtoArray(
prune_threshold: DefaultPruneThreshold,
justified_epoch: justified_epoch,
finalized_epoch: finalized_epoch
)
let err = proto_array.on_block(
finalized_block_slot,
finalized_root,
none(Eth2Digest),
finalized_block_state_root,
justified_epoch,
finalized_epoch
)
if err.kind != fcSuccess:
return err("Failed to add finalized block to proto_array: " & $err)
return ok(ForkChoice(proto_array: proto_array))
2020-04-15 09:21:22 +00:00
func extend[T](s: var seq[T], minLen: int) =
## Extend a sequence so that it can contains at least `minLen` elements.
## If it's already bigger, the sequence is unmodified.
## The extension is zero-initialized
let curLen = s.len
let diff = minLen - curLen
if diff > 0:
# Note: seq has a length and a capacity.
# If the new length is less than the original capacity
# => setLen will not zeroMem
# If the capacity was too small
# => reallocation occurs
# => the fresh buffer is zeroMem-ed
# In the second case our own zeroMem is redundant
# but this should happen rarely as we reuse the buffer
# most of the time
s.setLen(minLen)
zeroMem(s[curLen].addr, diff * sizeof(T))
func process_attestation*(
self: var ForkChoice,
validator_index: ValidatorIndex,
block_root: Eth2Digest,
target_epoch: Epoch
2020-04-15 09:21:22 +00:00
) =
## Add an attestation to the fork choice context
self.votes.extend(validator_index.int + 1)
template vote: untyped {.dirty.} = self.votes[validator_index.int]
# alias
if target_epoch > vote.next_epoch or vote == default(VoteTracker):
# TODO: the "default" condition is probably unneeded
vote.next_root = block_root
vote.next_epoch = target_epoch
func process_block*(
self: var ForkChoice,
slot: Slot,
block_root: Eth2Digest,
parent_root: Eth2Digest,
state_root: Eth2Digest,
justified_epoch: Epoch,
finalized_epoch: Epoch
2020-04-15 09:21:22 +00:00
): Result[void, string] =
## Add a block to the fork choice context
let err = self.proto_array.on_block(
slot, block_root, some(parent_root), state_root, justified_epoch, finalized_epoch
)
if err.kind != fcSuccess:
return err("process_block_error: " & $err)
return ok()
func find_head*(
self: var ForkChoice,
justified_epoch: Epoch,
justified_root: Eth2Digest,
finalized_epoch: Epoch,
justified_state_balances: seq[Gwei]
2020-04-15 09:21:22 +00:00
): Result[Eth2Digest, string] =
## Returns the new blockchain head
# Compute deltas with previous call
# we might want to reuse the `deltas` buffer across calls
var deltas = newSeq[Delta](self.proto_array.indices.len)
let delta_err = deltas.compute_deltas(
indices = self.proto_array.indices,
votes = self.votes,
old_balances = self.balances,
new_balances = justified_state_balances
)
if delta_err.kind != fcSuccess:
return err("find_head compute_deltas failed: " & $delta_err)
# Apply score changes
let score_err = self.proto_array.apply_score_changes(
deltas, justified_epoch, finalized_epoch
)
if score_err.kind != fcSuccess:
return err("find_head apply_score_changes failed: " & $score_err)
self.balances = justified_state_balances
# Find the best block
var new_head{.noInit.}: Eth2Digest
let ghost_err = self.proto_array.find_head(new_head, justified_root)
if ghost_err.kind != fcSuccess:
return err("find_head failed: " & $ghost_err)
return ok(new_head)
func maybe_prune*(
self: var ForkChoice, finalized_root: Eth2Digest
2020-04-15 09:21:22 +00:00
): Result[void, string] =
## Prune blocks preceding the finalized root as they are now unneeded.
let err = self.proto_array.maybe_prune(finalized_root)
if err.kind != fcSuccess:
return err("find_head maybe_pruned failed: " & $err)
return ok()
func compute_deltas(
deltas: var openarray[Delta],
indices: Table[Eth2Digest, Index],
votes: var openArray[VoteTracker],
old_balances: openarray[Gwei],
new_balances: openarray[Gwei]
2020-04-15 09:21:22 +00:00
): ForkChoiceError =
## Update `deltas`
## between old and new balances
## between votes
##
## `deltas.len` must match `indices.len` (lenght match)
##
## Error:
## - If a value in indices is greater than `indices.len`
## - If a `Eth2Digest` in `votes` does not exist in `indices`
## except for the `default(Eth2Digest)` (i.e. zero hash)
for val_index, vote in votes.mpairs():
# No need to create a score change if the validator has never voted
# or if votes are for the zero hash (alias to the genesis block)
if vote.current_root == default(Eth2Digest) and vote.next_root == default(Eth2Digest):
continue
# If the validator was not included in `old_balances` (i.e. did not exist)
# its balance is zero
let old_balance = if val_index < old_balances.len: old_balances[val_index]
else: 0
# If the validator is not known in the `new_balances` then use balance of zero
#
# It is possible that there is a vote for an unknown validator if we change our
# justified state to a new state with a higher epoch on a different fork
# because that fork may have on-boarded less validators than the previous fork.
#
# Note that attesters are not different as they are activated only under finality
let new_balance = if val_index < new_balances.len: new_balances[val_index]
else: 0
if vote.current_root != vote.next_root or old_balance != new_balance:
# Ignore the current or next vote if it is not known in `indices`.
# We assume that it is outside of our tree (i.e., pre-finalization) and therefore not interesting.
if vote.current_root in indices:
let index = indices.unsafeGet(vote.current_root)
if index >= deltas.len:
return ForkChoiceError(
kind: fcErrInvalidNodeDelta,
index: index
)
deltas[index] -= Delta old_balance
# Note that delta can be negative
# TODO: is int64 big enough?
if vote.next_root in indices:
let index = indices.unsafeGet(vote.next_root)
if index >= deltas.len:
return ForkChoiceError(
kind: fcErrInvalidNodeDelta,
index: index
)
deltas[index] += Delta new_balance
# Note that delta can be negative
# TODO: is int64 big enough?
vote.current_root = vote.next_root
return ForkChoiceSuccess
# Sanity checks
# ----------------------------------------------------------------------
# Sanity checks on internal private procedures
when isMainModule:
import stew/endians2
func fakeHash*(index: SomeInteger): Eth2Digest =
## Create fake hashes
## Those are just the value serialized in big-endian
## We add 16x16 to avoid having a zero hash are those are special cased
## We store them in the first 8 bytes
## as those are the one used in hash tables Table[Eth2Digest, T]
result.data[0 ..< 8] = (16*16+index).uint64.toBytesBE()
proc tZeroHash() =
echo " fork_choice compute_deltas - test zero votes"
const validator_count = 16
var deltas = newSeqUninitialized[Delta](validator_count)
var indices: Table[Eth2Digest, Index]
var votes: seq[VoteTracker]
var old_balances: seq[Gwei]
var new_balances: seq[Gwei]
for i in 0 ..< validator_count:
indices.add fakeHash(i), i
votes.add default(VoteTracker)
old_balances.add 0
new_balances.add 0
let err = deltas.compute_deltas(
indices, votes, old_balances, new_balances
)
doAssert err.kind == fcSuccess, "compute_deltas finished with error: " & $err
doAssert deltas == newSeq[Delta](validator_count), "deltas should be zeros"
for vote in votes:
doAssert vote.current_root == vote.next_root, "The vote should have been updated"
proc tAll_voted_the_same() =
echo " fork_choice compute_deltas - test all same votes"
const
Balance = Gwei(42)
validator_count = 16
var deltas = newSeqUninitialized[Delta](validator_count)
var indices: Table[Eth2Digest, Index]
var votes: seq[VoteTracker]
var old_balances: seq[Gwei]
var new_balances: seq[Gwei]
for i in 0 ..< validator_count:
indices.add fakeHash(i), i
votes.add VoteTracker(
current_root: default(Eth2Digest),
next_root: fakeHash(0), # Get a non-zero hash
next_epoch: Epoch(0)
)
old_balances.add Balance
new_balances.add Balance
let err = deltas.compute_deltas(
indices, votes, old_balances, new_balances
)
doAssert err.kind == fcSuccess, "compute_deltas finished with error: " & $err
for i, delta in deltas.pairs:
if i == 0:
doAssert delta == Delta(Balance * validator_count), "The 0th root should have a delta"
else:
doAssert delta == 0, "The non-0 indexes should have a zero delta"
for vote in votes:
doAssert vote.current_root == vote.next_root, "The vote should have been updated"
proc tDifferent_votes() =
echo " fork_choice compute_deltas - test all different votes"
const
Balance = Gwei(42)
validator_count = 16
var deltas = newSeqUninitialized[Delta](validator_count)
var indices: Table[Eth2Digest, Index]
var votes: seq[VoteTracker]
var old_balances: seq[Gwei]
var new_balances: seq[Gwei]
for i in 0 ..< validator_count:
indices.add fakeHash(i), i
votes.add VoteTracker(
current_root: default(Eth2Digest),
next_root: fakeHash(i), # Each vote for a different root
next_epoch: Epoch(0)
)
old_balances.add Balance
new_balances.add Balance
let err = deltas.compute_deltas(
indices, votes, old_balances, new_balances
)
doAssert err.kind == fcSuccess, "compute_deltas finished with error: " & $err
for i, delta in deltas.pairs:
doAssert delta == Delta(Balance), "Each root should have a delta"
for vote in votes:
doAssert vote.current_root == vote.next_root, "The vote should have been updated"
proc tMoving_votes() =
echo " fork_choice compute_deltas - test moving votes"
const
Balance = Gwei(42)
validator_count = 16
TotalDeltas = Delta(Balance * validator_count)
var deltas = newSeqUninitialized[Delta](validator_count)
var indices: Table[Eth2Digest, Index]
var votes: seq[VoteTracker]
var old_balances: seq[Gwei]
var new_balances: seq[Gwei]
for i in 0 ..< validator_count:
indices.add fakeHash(i), i
votes.add VoteTracker(
# Move vote from root 0 to root 1
current_root: fakeHash(0),
next_root: fakeHash(1),
next_epoch: Epoch(0)
)
old_balances.add Balance
new_balances.add Balance
let err = deltas.compute_deltas(
indices, votes, old_balances, new_balances
)
doAssert err.kind == fcSuccess, "compute_deltas finished with error: " & $err
for i, delta in deltas.pairs:
if i == 0:
doAssert delta == -TotalDeltas, "0th root should have a negative delta"
elif i == 1:
doAssert delta == TotalDeltas, "1st root should have a positive delta"
else:
doAssert delta == 0, "The non-0 and non-1 indexes should have a zero delta"
for vote in votes:
doAssert vote.current_root == vote.next_root, "The vote should have been updated"
proc tMove_out_of_tree() =
echo " fork_choice compute_deltas - test votes for unknown subtree"
const Balance = Gwei(42)
var indices: Table[Eth2Digest, Index]
var votes: seq[VoteTracker]
# Add a block
indices.add fakeHash(1), 0
# 2 validators
var deltas = newSeqUninitialized[Delta](2)
let old_balances = @[Balance, Balance]
let new_balances = @[Balance, Balance]
# One validator moves their vote from the block to the zero hash
votes.add VoteTracker(
current_root: fakeHash(1),
next_root: default(Eth2Digest),
next_epoch: Epoch(0)
)
# One validator moves their vote from the block to something outside of the tree
votes.add VoteTracker(
current_root: fakeHash(1),
next_root: fakeHash(1337),
next_epoch: Epoch(0)
)
let err = deltas.compute_deltas(
indices, votes, old_balances, new_balances
)
doAssert err.kind == fcSuccess, "compute_deltas finished with error: " & $err
doAssert deltas[0] == -Delta(Balance)*2, "The 0th block should have lost both balances."
for vote in votes:
doAssert vote.current_root == vote.next_root, "The vote should have been updated"
proc tChanging_balances() =
echo " fork_choice compute_deltas - test changing balances"
const
OldBalance = Gwei(42)
NewBalance = OldBalance * 2
validator_count = 16
TotalOldDeltas = Delta(OldBalance * validator_count)
TotalNewDeltas = Delta(NewBalance * validator_count)
var deltas = newSeqUninitialized[Delta](validator_count)
var indices: Table[Eth2Digest, Index]
var votes: seq[VoteTracker]
var old_balances: seq[Gwei]
var new_balances: seq[Gwei]
for i in 0 ..< validator_count:
indices.add fakeHash(i), i
votes.add VoteTracker(
# Move vote from root 0 to root 1
current_root: fakeHash(0),
next_root: fakeHash(1),
next_epoch: Epoch(0)
)
old_balances.add OldBalance
new_balances.add NewBalance
let err = deltas.compute_deltas(
indices, votes, old_balances, new_balances
)
doAssert err.kind == fcSuccess, "compute_deltas finished with error: " & $err
for i, delta in deltas.pairs:
if i == 0:
doAssert delta == -TotalOldDeltas, "0th root should have a negative delta"
elif i == 1:
doAssert delta == TotalNewDeltas, "1st root should have a positive delta"
else:
doAssert delta == 0, "The non-0 and non-1 indexes should have a zero delta"
for vote in votes:
doAssert vote.current_root == vote.next_root, "The vote should have been updated"
proc tValidator_appears() =
echo " fork_choice compute_deltas - test validator appears"
const Balance = Gwei(42)
var indices: Table[Eth2Digest, Index]
var votes: seq[VoteTracker]
# Add 2 blocks
indices.add fakeHash(1), 0
indices.add fakeHash(2), 1
# 1 validator at the start, 2 at the end
var deltas = newSeqUninitialized[Delta](2)
let old_balances = @[Balance]
let new_balances = @[Balance, Balance]
# Both moves vote from Block 1 to 2
for _ in 0 ..< 2:
votes.add VoteTracker(
current_root: fakeHash(1),
next_root: fakeHash(2),
next_epoch: Epoch(0)
)
let err = deltas.compute_deltas(
indices, votes, old_balances, new_balances
)
doAssert err.kind == fcSuccess, "compute_deltas finished with error: " & $err
doAssert deltas[0] == -Delta(Balance), "Block 1 should have lost only 1 balance"
doAssert deltas[1] == Delta(Balance)*2, "Block 2 should have gained 2 balances"
for vote in votes:
doAssert vote.current_root == vote.next_root, "The vote should have been updated"
proc tValidator_disappears() =
echo " fork_choice compute_deltas - test validator disappears"
const Balance = Gwei(42)
var indices: Table[Eth2Digest, Index]
var votes: seq[VoteTracker]
# Add 2 blocks
indices.add fakeHash(1), 0
indices.add fakeHash(2), 1
# 1 validator at the start, 2 at the end
var deltas = newSeqUninitialized[Delta](2)
let old_balances = @[Balance, Balance]
let new_balances = @[Balance]
# Both moves vote from Block 1 to 2
for _ in 0 ..< 2:
votes.add VoteTracker(
current_root: fakeHash(1),
next_root: fakeHash(2),
next_epoch: Epoch(0)
)
let err = deltas.compute_deltas(
indices, votes, old_balances, new_balances
)
doAssert err.kind == fcSuccess, "compute_deltas finished with error: " & $err
doAssert deltas[0] == -Delta(Balance)*2, "Block 1 should have lost 2 balances"
doAssert deltas[1] == Delta(Balance), "Block 2 should have gained 1 balance"
for vote in votes:
doAssert vote.current_root == vote.next_root, "The vote should have been updated"
# ----------------------------------------------------------------------
echo "fork_choice internal tests for compute_deltas"
tZeroHash()
tAll_voted_the_same()
tDifferent_votes()
tMoving_votes()
tChanging_balances()
tValidator_appears()
tValidator_disappears()